From 26ba48a4497a4c69742304a16f205c82124c7eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Thu, 12 Feb 2026 18:44:32 +0100 Subject: [PATCH 1/5] go 1.25 --- adminapi/api.go | 26 ++++++++++++++------------ adminapi/api_test.go | 15 ++++++--------- adminapi/config.go | 2 +- adminapi/config_test.go | 13 +++++-------- go.mod | 2 +- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/adminapi/api.go b/adminapi/api.go index 6ee3583..0334bcd 100644 --- a/adminapi/api.go +++ b/adminapi/api.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -46,17 +47,21 @@ func (s ServerObject) Get(attribute string) any { } // GetString safely retrieves an attribute as a string -func (s ServerObject) GetString(attribute string) any { +func (s ServerObject) GetString(attribute string) string { val := s.Get(attribute) if strVal, isString := val.(string); isString { return strVal } - return nil + return "" } // ObjectID returns the "object_id" attribute of the ServerObject func (s ServerObject) ObjectID() int { - return s.Get("object_id").(int) + val := s.Get("object_id") + if id, ok := val.(int); ok { + return id + } + return 0 } func sendRequest(endpoint string, postData any) (*http.Response, error) { @@ -65,7 +70,10 @@ func sendRequest(endpoint string, postData any) (*http.Response, error) { return nil, fmt.Errorf("failed to get config: %w", err) } - postStr, _ := json.Marshal(postData) + postStr, err := json.Marshal(postData) + if err != nil { + return nil, fmt.Errorf("failed to marshal request data: %w", err) + } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, config.baseURL+endpoint, bytes.NewBuffer(postStr)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -149,15 +157,9 @@ type gzipReadCloser struct { gz *gzip.Reader } -// Close Read reads from the gzip.Reader. +// Close closes the gzip.Reader and the underlying body. func (grc *gzipReadCloser) Close() error { - // Close the gzip.Reader itself - if err := grc.gz.Close(); err != nil { - grc.body.Close() - return err - } - // Then close the underlying body - return grc.body.Close() + return errors.Join(grc.gz.Close(), grc.body.Close()) } // calcSecurityToken calculates HMAC-SHA1 of timestamp:data diff --git a/adminapi/api_test.go b/adminapi/api_test.go index 323ab9f..f0ec710 100644 --- a/adminapi/api_test.go +++ b/adminapi/api_test.go @@ -4,7 +4,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" "sync" "testing" @@ -37,9 +36,8 @@ func TestFakeServer(t *testing.T) { defer server.Close() resetConfig() - os.Clearenv() - _ = os.Setenv("SERVERADMIN_TOKEN", "1234567890") - _ = os.Setenv("SERVERADMIN_BASE_URL", server.URL) + t.Setenv("SERVERADMIN_TOKEN", "1234567890") + t.Setenv("SERVERADMIN_BASE_URL", server.URL) query := NewQuery(Filters{ "hostname": Any(Regexp("test.foo.local"), Regexp(".*\\.bar.local")), @@ -55,9 +53,9 @@ func TestFakeServer(t *testing.T) { assert.Equal(t, "foo.bar.local", object.GetString("hostname")) assert.Equal(t, 483903, object.Get("object_id")) assert.Equal(t, 483903, object.ObjectID()) - assert.Nil(t, object.GetString("object_id")) + assert.Empty(t, object.GetString("object_id")) assert.Nil(t, object.Get("nope")) - assert.Nil(t, object.GetString("nope")) + assert.Empty(t, object.GetString("nope")) one, err := query.One() require.NoError(t, err) @@ -159,9 +157,8 @@ func TestHTTPErrorHandling(t *testing.T) { defer server.Close() resetConfig() - os.Clearenv() - _ = os.Setenv("SERVERADMIN_TOKEN", "1234567890") - _ = os.Setenv("SERVERADMIN_BASE_URL", server.URL) + t.Setenv("SERVERADMIN_TOKEN", "1234567890") + t.Setenv("SERVERADMIN_BASE_URL", server.URL) query := NewQuery(Filters{ "hostname": Regexp("test.local"), diff --git a/adminapi/config.go b/adminapi/config.go index 56117ef..ae29859 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -38,7 +38,7 @@ var loadConfig = func() (config, error) { if baseURL == "" { return cfg, errors.New("env var SERVERADMIN_BASE_URL not set") } - cfg.baseURL = strings.TrimRight(baseURL, "/api") + cfg.baseURL = strings.TrimSuffix(baseURL, "/api") if privateKeyPath, ok := os.LookupEnv("SERVERADMIN_KEY_PATH"); ok { keyBytes, err := os.ReadFile(privateKeyPath) diff --git a/adminapi/config_test.go b/adminapi/config_test.go index f0d9a54..df2d6f9 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -2,7 +2,6 @@ package adminapi import ( "net/http/httptest" - "os" "testing" "github.com/stretchr/testify/assert" @@ -10,20 +9,18 @@ import ( ) func TestLoadConfig(t *testing.T) { - // clear env to not have configs from host here - os.Clearenv() - // make a test without SERVERADMIN_BASE_URL set + t.Setenv("SERVERADMIN_BASE_URL", "") _, err := loadConfig() require.Error(t, err, "env var SERVERADMIN_BASE_URL not set") // spawn mocked serveradmin server server := httptest.NewServer(nil) defer server.Close() - _ = os.Setenv("SERVERADMIN_BASE_URL", server.URL) + t.Setenv("SERVERADMIN_BASE_URL", server.URL) t.Run("load static token", func(t *testing.T) { - _ = os.Setenv("SERVERADMIN_TOKEN", "jolo") + t.Setenv("SERVERADMIN_TOKEN", "jolo") cfg, err := loadConfig() require.NoError(t, err) @@ -32,7 +29,7 @@ func TestLoadConfig(t *testing.T) { }) t.Run("load valid private key", func(t *testing.T) { - _ = os.Setenv("SERVERADMIN_KEY_PATH", "testdata/test.key") + t.Setenv("SERVERADMIN_KEY_PATH", "testdata/test.key") cfg, err := loadConfig() require.NoError(t, err) @@ -41,7 +38,7 @@ func TestLoadConfig(t *testing.T) { }) t.Run("load invalid private Key", func(t *testing.T) { - _ = os.Setenv("SERVERADMIN_KEY_PATH", "testdata/nope.key") + t.Setenv("SERVERADMIN_KEY_PATH", "testdata/nope.key") _, err := loadConfig() assert.Error(t, err, "failed to read private key from testdata/nope.key: open testdata/nope.key: no such file or directory") diff --git a/go.mod b/go.mod index 17560f4..65a7b94 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/innogames/serveradmin-go-client -go 1.24.0 +go 1.25.0 require ( github.com/stretchr/testify v1.10.0 From 6100902b3c828237e5893124cb66080b29d25e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Thu, 12 Feb 2026 19:04:31 +0100 Subject: [PATCH 2/5] implement commit --- .github/workflows/ci.yml | 10 +- Makefile | 3 + adminapi/auth.go | 28 +++ adminapi/auth_test.go | 59 +++++++ adminapi/commit.go | 96 ++++++++++ adminapi/commit_test.go | 88 +++++++++ adminapi/config.go | 2 +- adminapi/config_test.go | 11 ++ adminapi/helpers_test.go | 12 ++ adminapi/query.go | 26 ++- adminapi/server_object.go | 186 ++++++++++++++++++++ adminapi/server_object_test.go | 124 +++++++++++++ adminapi/{api.go => transport.go} | 63 +------ adminapi/{api_test.go => transport_test.go} | 63 ------- examples/update_example.go | 148 ++++++++++++++++ go.mod | 6 +- go.sum | 16 +- main.go | 11 +- 18 files changed, 791 insertions(+), 161 deletions(-) create mode 100644 adminapi/auth.go create mode 100644 adminapi/auth_test.go create mode 100644 adminapi/commit.go create mode 100644 adminapi/commit_test.go create mode 100644 adminapi/helpers_test.go create mode 100644 adminapi/server_object.go create mode 100644 adminapi/server_object_test.go rename adminapi/{api.go => transport.go} (65%) rename adminapi/{api_test.go => transport_test.go} (68%) create mode 100644 examples/update_example.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c735de..f6462c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,11 @@ jobs: with: go-version: stable - - name: Run tests - run: go test ./... + - name: Run tests with race detector + run: make test-race + + - name: Run test coverage + run: make test-coverage build: name: Build @@ -33,9 +36,6 @@ jobs: with: go-version: stable - - name: Install dependencies - run: go mod download - - name: Build for multiple platforms run: | mkdir -p bin diff --git a/Makefile b/Makefile index ccadac4..30cb2f4 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,9 @@ build: test: go test ./... +test-race: + go test -race ./... + test-coverage: go test -v ./... -coverprofile=coverage.out go tool cover -html=coverage.out -o coverage.html diff --git a/adminapi/auth.go b/adminapi/auth.go new file mode 100644 index 0000000..4998460 --- /dev/null +++ b/adminapi/auth.go @@ -0,0 +1,28 @@ +package adminapi + +import ( + "crypto/hmac" + "crypto/sha1" //nolint:gosec // SHA1 is required by the protocol + "encoding/hex" + "strconv" +) + +// calcSecurityToken calculates HMAC-SHA1 of timestamp:data +func calcSecurityToken(authToken []byte, timestamp int64, data []byte) string { + mac := hmac.New(sha1.New, authToken) + mac.Write(calcMessage(timestamp, data)) + + return hex.EncodeToString(mac.Sum(nil)) +} + +// calcMessage efficiently concatenates timestamp:data without redundant allocations +func calcMessage(timestamp int64, data []byte) []byte { + return append(append(strconv.AppendInt(nil, timestamp, 10), ':'), data...) +} + +// calcAppID computes SHA-1 hash of the auth token +func calcAppID(authToken []byte) string { + hash := sha1.Sum(authToken) //nolint:gosec // SHA1 is required by the protocol + + return hex.EncodeToString(hash[:]) +} diff --git a/adminapi/auth_test.go b/adminapi/auth_test.go new file mode 100644 index 0000000..3d76579 --- /dev/null +++ b/adminapi/auth_test.go @@ -0,0 +1,59 @@ +package adminapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// just some simple example tests, e2e tests might make much more sense here for full coverage +func TestAppId(t *testing.T) { + testCases := []struct { + input []byte + expected string + }{ + { + input: []byte("1234567898"), + expected: "d396f232a5ca1f7a0ad8f1b59975515123780553", + }, + } + + for _, testCase := range testCases { + actual := calcAppID(testCase.input) + assert.Equal(t, testCase.expected, actual) + } +} + +func TestSecurityToken(t *testing.T) { + testCases := []struct { + apiKey []byte + message string + expected string + }{ + { + apiKey: []byte("1234567898"), + message: "", + expected: "4199b91c6f92f3e1d29f88a5f67973ad8aaec5b5", + }, + { + apiKey: []byte("1234567898"), + message: "foobar", + expected: "e17ba31a1a664617653869db8289f92a49213e7b", + }, + } + + now := int64(123456789) + for _, testCase := range testCases { + actual := calcSecurityToken(testCase.apiKey, now, []byte(testCase.message)) + assert.Equal(t, testCase.expected, actual) + } +} + +func BenchmarkCalcSecurityToken(b *testing.B) { + now := int64(123456789) + message := []byte("foobar") + authToken := []byte("1234567898") + for b.Loop() { + calcSecurityToken(authToken, now, message) + } +} diff --git a/adminapi/commit.go b/adminapi/commit.go new file mode 100644 index 0000000..279aa56 --- /dev/null +++ b/adminapi/commit.go @@ -0,0 +1,96 @@ +package adminapi + +import ( + "encoding/json" + "fmt" +) + +// 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"` +} + +type commitResponse struct { + Status string `json:"status"` + CommitID int `json:"commit_id"` + Type string `json:"type"` + Message string `json:"message"` +} + +// Commit commits all changed, created, and deleted objects in a single API call. +func (s ServerObjects) Commit() (int, error) { + commit := buildCommit(s) + + commitID, err := sendCommit(commit) + if err != nil { + return 0, err + } + + for _, obj := range s { + obj.confirmChanges() + } + + return commitID, nil +} + +// Rollback reverts all objects to their original state. +func (s ServerObjects) Rollback() { + for _, obj := range s { + obj.Rollback() + } +} + +// Commit commits this single object's changes to the server. +func (s *ServerObject) Commit() (int, error) { + commit := buildCommit(ServerObjects{s}) + + commitID, err := sendCommit(commit) + if err != nil { + return 0, err + } + + s.confirmChanges() + return commitID, nil +} + +func buildCommit(objects ServerObjects) commitRequest { + commit := commitRequest{ + Created: []map[string]any{}, + Changed: []map[string]any{}, + Deleted: []any{}, + } + + for _, obj := range objects { + switch obj.CommitState() { + case "created": + commit.Created = append(commit.Created, obj.attributes) + case "changed": + commit.Changed = append(commit.Changed, obj.serializeChanges()) + case "deleted": + commit.Deleted = append(commit.Deleted, obj.Get("object_id")) + } + } + + return commit +} + +func sendCommit(commit commitRequest) (int, error) { + resp, err := sendRequest(apiEndpointCommit, commit) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + var result commitResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("failed to decode commit response: %w", err) + } + + if result.Status == "error" { + return 0, fmt.Errorf("commit failed: %s", result.Message) + } + + return result.CommitID, nil +} diff --git a/adminapi/commit_test.go b/adminapi/commit_test.go new file mode 100644 index 0000000..5424ad2 --- /dev/null +++ b/adminapi/commit_test.go @@ -0,0 +1,88 @@ +package adminapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitSingle(t *testing.T) { + var receivedBody commitRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + + w.WriteHeader(200) + w.Write([]byte(`{"status": "success", "commit_id": 123}`)) + })) + defer server.Close() + + resetConfig() + t.Setenv("SERVERADMIN_TOKEN", "testtoken") + 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"}, + } + + commitID, err := obj.Commit() + require.NoError(t, err) + assert.Equal(t, 123, commitID) + + // Verify payload + assert.Len(t, receivedBody.Changed, 1) + assert.Empty(t, receivedBody.Created) + assert.Empty(t, receivedBody.Deleted) + + // State should be reset after commit + assert.Equal(t, "consistent", obj.CommitState()) + assert.Empty(t, obj.oldValues) +} + +func TestCommitResultSet(t *testing.T) { + var receivedBody commitRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + + w.WriteHeader(200) + w.Write([]byte(`{"status": "success", "commit_id": 456}`)) + })) + defer server.Close() + + resetConfig() + t.Setenv("SERVERADMIN_TOKEN", "testtoken") + t.Setenv("SERVERADMIN_BASE_URL", server.URL) + + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "changed.local", "object_id": float64(1)}, + oldValues: map[string]any{"hostname": "orig1.local"}, + }, + { + attributes: map[string]any{"hostname": "unchanged.local", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "deleted.local", "object_id": float64(3)}, + oldValues: map[string]any{}, + deleted: true, + }, + } + + commitID, err := objects.Commit() + require.NoError(t, err) + assert.Equal(t, 456, commitID) + + assert.Len(t, receivedBody.Changed, 1) + assert.Len(t, receivedBody.Deleted, 1) + assert.Empty(t, receivedBody.Created) +} diff --git a/adminapi/config.go b/adminapi/config.go index ae29859..4ca91b4 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -40,7 +40,7 @@ var loadConfig = func() (config, error) { } cfg.baseURL = strings.TrimSuffix(baseURL, "/api") - if privateKeyPath, ok := os.LookupEnv("SERVERADMIN_KEY_PATH"); ok { + if privateKeyPath, ok := os.LookupEnv("SERVERADMIN_KEY_PATH"); ok && privateKeyPath != "" { keyBytes, err := os.ReadFile(privateKeyPath) if err != nil { return cfg, fmt.Errorf("failed to read private key from %s: %w", privateKeyPath, err) diff --git a/adminapi/config_test.go b/adminapi/config_test.go index df2d6f9..f068159 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -20,7 +20,12 @@ func TestLoadConfig(t *testing.T) { t.Setenv("SERVERADMIN_BASE_URL", server.URL) t.Run("load static token", func(t *testing.T) { + // Unset SSH-related env vars to prevent SSH agent from taking precedence + t.Setenv("SSH_AUTH_SOCK", "") + t.Setenv("SERVERADMIN_KEY_PATH", "") t.Setenv("SERVERADMIN_TOKEN", "jolo") + + resetConfig() cfg, err := loadConfig() require.NoError(t, err) @@ -29,7 +34,10 @@ func TestLoadConfig(t *testing.T) { }) t.Run("load valid private key", func(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "") t.Setenv("SERVERADMIN_KEY_PATH", "testdata/test.key") + + resetConfig() cfg, err := loadConfig() require.NoError(t, err) @@ -38,7 +46,10 @@ func TestLoadConfig(t *testing.T) { }) t.Run("load invalid private Key", func(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "") t.Setenv("SERVERADMIN_KEY_PATH", "testdata/nope.key") + + resetConfig() _, err := loadConfig() assert.Error(t, err, "failed to read private key from testdata/nope.key: open testdata/nope.key: no such file or directory") diff --git a/adminapi/helpers_test.go b/adminapi/helpers_test.go new file mode 100644 index 0000000..dca0981 --- /dev/null +++ b/adminapi/helpers_test.go @@ -0,0 +1,12 @@ +package adminapi + +import "sync" + +// Because getConfig in config.go calls sync.OnceValues, the new values set to +// SERVERADMIN_BASE_URL between test runs is never changed, as getConfig returns +// cached values. +// We use resetConfig() to reinitialize things, forcing getConfig() to return the +// values from the new env variables. +func resetConfig() { + getConfig = sync.OnceValues(loadConfig) +} diff --git a/adminapi/query.go b/adminapi/query.go index 8a6400c..e060965 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -67,14 +67,14 @@ func (q *Query) All() (ServerObjects, error) { } // One returns exactly one matching SA object. If there is none or more than one, an error is returned. -func (q *Query) One() (ServerObject, error) { +func (q *Query) One() (*ServerObject, error) { err := q.load() if err != nil { - return ServerObject{}, err + return nil, err } if len(q.serverObjects) != 1 { - return ServerObject{}, fmt.Errorf("expected exactly one server object, got %d", len(q.serverObjects)) + return nil, fmt.Errorf("expected exactly one server object, got %d", len(q.serverObjects)) } return q.serverObjects[0], nil @@ -108,8 +108,9 @@ func (q *Query) load() error { // map attribute map into ServerObject objects q.serverObjects = make(ServerObjects, len(respServer.Result)) for idx, object := range respServer.Result { - q.serverObjects[idx] = ServerObject{ + q.serverObjects[idx] = &ServerObject{ attributes: object, + oldValues: map[string]any{}, } } q.loaded = true @@ -118,8 +119,10 @@ func (q *Query) load() error { } // NewObject creates a new server object (fetches default attributes from SA) -func NewObject(serverType string) (ServerObject, error) { - server := ServerObject{} +func NewObject(serverType string) (*ServerObject, error) { + server := &ServerObject{ + oldValues: map[string]any{}, + } // Use url.Values for safe query string encoding params := url.Values{} @@ -128,13 +131,18 @@ func NewObject(serverType string) (ServerObject, error) { resp, err := sendRequest(fullURL, nil) if err != nil { - return server, err + return nil, err } defer resp.Body.Close() - err = json.NewDecoder(resp.Body).Decode(&server.attributes) + if err := json.NewDecoder(resp.Body).Decode(&server.attributes); err != nil { + return nil, err + } + + // Ensure object_id is nil so CommitState() returns "created" + server.attributes["object_id"] = nil - return server, err + return server, nil } // like {"Filters": {"hostname": {"Regexp": "foo.local.*"}}, "restrict": ["hostname", "object_id"]} diff --git a/adminapi/server_object.go b/adminapi/server_object.go new file mode 100644 index 0000000..fa98aad --- /dev/null +++ b/adminapi/server_object.go @@ -0,0 +1,186 @@ +package adminapi + +import ( + "encoding/json" + "errors" + "fmt" +) + +// ServerObjects is a slice of ServerObject pointers +type ServerObjects []*ServerObject + +// ServerObject is a map of key-value attributes of a SA object +type ServerObject struct { + attributes map[string]any + oldValues map[string]any // tracks original values before first modification + deleted bool +} + +// Get safely retrieves an attribute, converting JSON float64 numbers to int when needed +func (s *ServerObject) Get(attribute string) any { + if val, ok := s.attributes[attribute]; ok { + if floatVal, isFloat := val.(float64); isFloat { + return int(floatVal) + } + return val + } + return nil +} + +// GetString safely retrieves an attribute as a string +func (s *ServerObject) GetString(attribute string) string { + val := s.Get(attribute) + if strVal, isString := val.(string); isString { + return strVal + } + return "" +} + +// ObjectID returns the "object_id" attribute of the ServerObject +func (s *ServerObject) ObjectID() int { + val := s.Get("object_id") + if id, ok := val.(int); ok { + return id + } + return 0 +} + +// Set modifies an attribute value and tracks the change for commit. +func (s *ServerObject) Set(key string, value any) error { + if s.deleted { + return errors.New("cannot set attributes on a deleted object") + } + if _, exists := s.attributes[key]; !exists { + return fmt.Errorf("attribute %q does not exist", key) + } + + // Save the original value on first modification only + if _, tracked := s.oldValues[key]; !tracked { + old := s.attributes[key] + // Deep copy slices to prevent aliasing + if oldSlice, ok := old.([]any); ok { + cp := make([]any, len(oldSlice)) + copy(cp, oldSlice) + s.oldValues[key] = cp + } else { + s.oldValues[key] = old + } + } + + s.attributes[key] = value + return nil +} + +// Delete marks the object for deletion on the next commit. +func (s *ServerObject) Delete() { + s.deleted = true +} + +// CommitState returns the current state: "created", "deleted", "changed", or "consistent". +func (s *ServerObject) CommitState() string { + if s.attributes["object_id"] == nil { + return "created" + } + if s.deleted { + return "deleted" + } + for key, oldVal := range s.oldValues { + newVal := s.attributes[key] + if !jsonEqual(oldVal, newVal) { + return "changed" + } + } + return "consistent" +} + +// 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 + } + s.oldValues = map[string]any{} +} + +// serializeChanges builds the change delta for commit payload. +func (s *ServerObject) serializeChanges() map[string]any { + changes := map[string]any{"object_id": s.Get("object_id")} + + for key, oldVal := range s.oldValues { + newVal := s.attributes[key] + if jsonEqual(oldVal, newVal) { + continue + } + + oldSlice, oldIsSlice := oldVal.([]any) + _, newIsSlice := newVal.([]any) + + if oldIsSlice && newIsSlice { + // Multi-attribute: compute add/remove sets + newSlice := toAnySlice(newVal) + add, remove := sliceDiff(oldSlice, newSlice) + changes[key] = map[string]any{ + "action": "multi", + "add": add, + "remove": remove, + } + } else { + changes[key] = map[string]any{ + "action": "update", + "old": oldVal, + "new": newVal, + } + } + } + + return changes +} + +func (s *ServerObject) confirmChanges() { + s.oldValues = map[string]any{} + if s.deleted { + s.attributes["object_id"] = nil + s.deleted = false + } +} + +// jsonEqual compares two values using JSON serialization for consistency with the Python client. +func jsonEqual(a, b any) bool { + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + return string(aj) == string(bj) +} + +// toAnySlice converts a value known to be a slice to []any. +func toAnySlice(v any) []any { + if s, ok := v.([]any); ok { + return s + } + return nil +} + +// sliceDiff computes elements added to and removed from old to produce new (set semantics). +func sliceDiff(old, cur []any) (add, remove []any) { + oldSet := make(map[string]any, len(old)) + for _, v := range old { + k, _ := json.Marshal(v) + oldSet[string(k)] = v + } + curSet := make(map[string]any, len(cur)) + for _, v := range cur { + k, _ := json.Marshal(v) + curSet[string(k)] = v + } + + for k, v := range curSet { + if _, exists := oldSet[k]; !exists { + add = append(add, v) + } + } + for k, v := range oldSet { + if _, exists := curSet[k]; !exists { + remove = append(remove, v) + } + } + return add, remove +} diff --git a/adminapi/server_object_test.go b/adminapi/server_object_test.go new file mode 100644 index 0000000..028a38b --- /dev/null +++ b/adminapi/server_object_test.go @@ -0,0 +1,124 @@ +package adminapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSet(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{"hostname": "old.local", "object_id": float64(1)}, + oldValues: map[string]any{}, + } + + err := obj.Set("hostname", "new.local") + require.NoError(t, err) + assert.Equal(t, "new.local", obj.GetString("hostname")) + assert.Equal(t, "old.local", obj.oldValues["hostname"]) + + // Second set should not overwrite oldValues + err = obj.Set("hostname", "newer.local") + require.NoError(t, err) + assert.Equal(t, "old.local", obj.oldValues["hostname"]) +} + +func TestSetNonexistent(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{"hostname": "test", "object_id": float64(1)}, + oldValues: map[string]any{}, + } + + err := obj.Set("nonexistent", "value") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestSetOnDeleted(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{"hostname": "test", "object_id": float64(1)}, + oldValues: map[string]any{}, + deleted: true, + } + + err := obj.Set("hostname", "new") + require.Error(t, err) + assert.Contains(t, err.Error(), "deleted") +} + +func TestCommitState(t *testing.T) { + // Consistent: no changes + obj := &ServerObject{ + attributes: map[string]any{"hostname": "test", "object_id": float64(1)}, + oldValues: map[string]any{}, + } + assert.Equal(t, "consistent", obj.CommitState()) + + // Changed: attribute modified + obj.Set("hostname", "changed") + assert.Equal(t, "changed", obj.CommitState()) + + // Deleted + obj2 := &ServerObject{ + attributes: map[string]any{"hostname": "test", "object_id": float64(1)}, + oldValues: map[string]any{}, + deleted: true, + } + assert.Equal(t, "deleted", obj2.CommitState()) + + // Created: no object_id + obj3 := &ServerObject{ + attributes: map[string]any{"hostname": "test", "object_id": nil}, + oldValues: map[string]any{}, + } + assert.Equal(t, "created", obj3.CommitState()) +} + +func TestSerializeChanges(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{"hostname": "new.local", "object_id": float64(42)}, + oldValues: map[string]any{"hostname": "old.local"}, + } + + changes := obj.serializeChanges() + assert.Equal(t, 42, changes["object_id"]) + + hostChange := changes["hostname"].(map[string]any) + assert.Equal(t, "update", hostChange["action"]) + assert.Equal(t, "old.local", hostChange["old"]) + assert.Equal(t, "new.local", hostChange["new"]) +} + +func TestSerializeChangesMulti(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{ + "tags": []any{"web", "new-tag"}, + "object_id": float64(42), + }, + oldValues: map[string]any{ + "tags": []any{"web", "old-tag"}, + }, + } + + changes := obj.serializeChanges() + tagChange := changes["tags"].(map[string]any) + assert.Equal(t, "multi", tagChange["action"]) + assert.Contains(t, tagChange["add"], "new-tag") + assert.Contains(t, tagChange["remove"], "old-tag") +} + +func TestRollback(t *testing.T) { + obj := &ServerObject{ + attributes: map[string]any{"hostname": "original", "object_id": float64(1)}, + oldValues: map[string]any{}, + } + + obj.Set("hostname", "modified") + assert.Equal(t, "modified", obj.GetString("hostname")) + + obj.Rollback() + assert.Equal(t, "original", obj.GetString("hostname")) + assert.Empty(t, obj.oldValues) + assert.Equal(t, "consistent", obj.CommitState()) +} diff --git a/adminapi/api.go b/adminapi/transport.go similarity index 65% rename from adminapi/api.go rename to adminapi/transport.go index 0334bcd..9917a78 100644 --- a/adminapi/api.go +++ b/adminapi/transport.go @@ -4,11 +4,8 @@ import ( "bytes" "compress/gzip" "context" - "crypto/hmac" "crypto/rand" - "crypto/sha1" //nolint:gosec // SHA1 is required by the protocol "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -23,47 +20,9 @@ import ( const ( apiEndpointQuery = "/api/dataset/query" apiEndpointNewObject = "/api/dataset/new_object" + apiEndpointCommit = "/api/dataset/commit" ) -// ServerObjects is a slice of ServerObjects -type ServerObjects []ServerObject - -// ServerObject is a map of key-value attributes of a SA object -type ServerObject struct { - // the actual SA attributes of the object - attributes map[string]any - // todo: place for dirty changes + .Set()/.Commit() etc here -} - -// Get safely retrieves an attribute, converting JSON float64 numbers to int when needed -func (s ServerObject) Get(attribute string) any { - if val, ok := s.attributes[attribute]; ok { - if floatVal, isFloat := val.(float64); isFloat { - return int(floatVal) - } - return val - } - return nil -} - -// GetString safely retrieves an attribute as a string -func (s ServerObject) GetString(attribute string) string { - val := s.Get(attribute) - if strVal, isString := val.(string); isString { - return strVal - } - return "" -} - -// ObjectID returns the "object_id" attribute of the ServerObject -func (s ServerObject) ObjectID() int { - val := s.Get("object_id") - if id, ok := val.(int); ok { - return id - } - return 0 -} - func sendRequest(endpoint string, postData any) (*http.Response, error) { config, err := getConfig() if err != nil { @@ -161,23 +120,3 @@ type gzipReadCloser struct { func (grc *gzipReadCloser) Close() error { return errors.Join(grc.gz.Close(), grc.body.Close()) } - -// calcSecurityToken calculates HMAC-SHA1 of timestamp:data -func calcSecurityToken(authToken []byte, timestamp int64, data []byte) string { - mac := hmac.New(sha1.New, authToken) - mac.Write(calcMessage(timestamp, data)) - - return hex.EncodeToString(mac.Sum(nil)) -} - -// calcMessage efficiently concatenates timestamp:data without redundant allocations -func calcMessage(timestamp int64, data []byte) []byte { - return append(append(strconv.AppendInt(nil, timestamp, 10), ':'), data...) -} - -// calcAppID computes SHA-1 hash of the auth token -func calcAppID(authToken []byte) string { - hash := sha1.Sum(authToken) //nolint:gosec // SHA1 is required by the protocol - - return hex.EncodeToString(hash[:]) -} diff --git a/adminapi/api_test.go b/adminapi/transport_test.go similarity index 68% rename from adminapi/api_test.go rename to adminapi/transport_test.go index f0ec710..a2ffedf 100644 --- a/adminapi/api_test.go +++ b/adminapi/transport_test.go @@ -4,26 +4,15 @@ import ( "io" "net/http" "net/http/httptest" - "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Because getConfig in config.go calls sync.OnceValues, the new values set to -// SERVERADMIN_BASE_URL between test runs is never changed, as getConfig returns -// cached values. -// We use resetConfig() to reinitialize things, forcing getConfig() to return the -// values from the new env variables. -func resetConfig() { - getConfig = sync.OnceValues(loadConfig) -} - func TestFakeServer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { req, _ := io.ReadAll(r.Body) - // todo: assert signature etc...create more useful test cases expectedRequest := `{"filters":{"hostname":{"Any":[{"Regexp":"test.foo.local"},{"Regexp":".*\\.bar.local"}]}},"restrict":["hostname","object_id"]}` assert.Equal(t, expectedRequest, string(req)) @@ -62,58 +51,6 @@ func TestFakeServer(t *testing.T) { assert.Equal(t, 483903, one.Get("object_id")) } -// just some simple example tests, e2e tests might make much more sense here for full coverage -func TestAppId(t *testing.T) { - testCases := []struct { - input []byte - expected string - }{ - { - input: []byte("1234567898"), - expected: "d396f232a5ca1f7a0ad8f1b59975515123780553", - }, - } - - for _, testCase := range testCases { - actual := calcAppID(testCase.input) - assert.Equal(t, testCase.expected, actual) - } -} - -func TestSecurityToken(t *testing.T) { - testCases := []struct { - apiKey []byte - message string - expected string - }{ - { - apiKey: []byte("1234567898"), - message: "", - expected: "4199b91c6f92f3e1d29f88a5f67973ad8aaec5b5", - }, - { - apiKey: []byte("1234567898"), - message: "foobar", - expected: "e17ba31a1a664617653869db8289f92a49213e7b", - }, - } - - now := int64(123456789) - for _, testCase := range testCases { - actual := calcSecurityToken(testCase.apiKey, now, []byte(testCase.message)) - assert.Equal(t, testCase.expected, actual) - } -} - -func BenchmarkCalcSecurityToken(b *testing.B) { - now := int64(123456789) - message := []byte("foobar") - authToken := []byte("1234567898") - for b.Loop() { - calcSecurityToken(authToken, now, message) - } -} - // TestHTTPErrorHandling verifies that HTTP error codes are properly captured and reported func TestHTTPErrorHandling(t *testing.T) { testCases := []struct { diff --git a/examples/update_example.go b/examples/update_example.go new file mode 100644 index 0000000..4a1b0d5 --- /dev/null +++ b/examples/update_example.go @@ -0,0 +1,148 @@ +package main + +import ( + "fmt" + "log" + + "github.com/innogames/serveradmin-go-client/adminapi" +) + +// Example demonstrating how to update server objects +func main() { + // Example 1: Update a single object + singleObjectExample() + + // Example 2: Update multiple objects in batch + multiObjectExample() + + // Example 3: Create a new object + createObjectExample() + + // Example 4: Delete an object + deleteObjectExample() + + // Example 5: Rollback changes + rollbackExample() +} + +func singleObjectExample() { + q, err := adminapi.FromQuery("hostname=webserver01") + if err != nil { + log.Fatal(err) + } + + server, err := q.One() + if err != nil { + log.Fatal(err) + } + + // Modify attributes + server.Set("backup_disabled", true) + server.Set("comment", "Updated via Go client") + + // Commit changes + commitID, err := server.Commit() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Updated server %s (commit %d)\n", server.GetString("hostname"), commitID) +} + +func multiObjectExample() { + q, err := adminapi.FromQuery("environment=production state=online") + if err != nil { + log.Fatal(err) + } + q.SetAttributes([]string{"hostname", "backup_disabled", "object_id"}) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + // Update all servers + for _, server := range servers { + server.Set("backup_disabled", false) + } + + // Commit all changes in a single API call + commitID, err := servers.Commit() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Updated %d servers (commit %d)\n", len(servers), commitID) +} + +func createObjectExample() { + // Create a new VM object + newVM, err := adminapi.NewObject("vm") + if err != nil { + log.Fatal(err) + } + + // Set required attributes + newVM.Set("hostname", "newserver.example.com") + newVM.Set("environment", "development") + newVM.Set("num_cpu", 4) + + // Commit creates the object on the server + commitID, err := newVM.Commit() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Created new VM %s (commit %d)\n", newVM.GetString("hostname"), commitID) +} + +func deleteObjectExample() { + q, err := adminapi.FromQuery("hostname=oldserver.example.com") + if err != nil { + log.Fatal(err) + } + + server, err := q.One() + if err != nil { + log.Fatal(err) + } + + // Mark for deletion + server.Delete() + + // Commit the deletion + commitID, err := server.Commit() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Deleted server (commit %d)\n", commitID) +} + +func rollbackExample() { + q, err := adminapi.FromQuery("hostname=webserver01") + if err != nil { + log.Fatal(err) + } + + server, err := q.One() + if err != nil { + log.Fatal(err) + } + + // Make some changes + originalHostname := server.GetString("hostname") + server.Set("hostname", "modified-name.local") + fmt.Printf("Modified hostname: %s\n", server.GetString("hostname")) + + // Rollback the changes + server.Rollback() + fmt.Printf("After rollback: %s\n", server.GetString("hostname")) + + // Check commit state + fmt.Printf("Commit state: %s\n", server.CommitState()) // Should be "consistent" + + if server.GetString("hostname") != originalHostname { + log.Fatal("Rollback failed!") + } +} diff --git a/go.mod b/go.mod index 65a7b94..22400f1 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module github.com/innogames/serveradmin-go-client go 1.25.0 require ( - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.45.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f1986e1..1522ea2 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index fbd6643..e1dfc03 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ func main() { attributeList := strings.Split(attributes, ",") q.SetAttributes(attributeList) + q.OrderBy(orderBy) servers, err := q.All() if err != nil { @@ -52,14 +53,4 @@ func main() { } fmt.Print("\n") } - - /* examples - server := q.One() - q.Set("backup_disabled", "true") - q.Commit() - - new, err := adminapi.NewServer("vm") - new.Set("hostname", "test") - new.Commit() - */ } From 45068442e12cac52b6a246749f347e15fe606704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Thu, 12 Feb 2026 20:31:50 +0100 Subject: [PATCH 3/5] made linter happy --- .github/workflows/ci.yml | 13 ++------ .golangci.yml | 3 ++ Makefile | 2 +- adminapi/auth.go | 28 ---------------- adminapi/auth_test.go | 59 ---------------------------------- adminapi/config_test.go | 10 ++++++ adminapi/helpers_test.go | 12 ------- adminapi/server_object.go | 4 --- adminapi/server_object_test.go | 12 ------- adminapi/transport.go | 23 +++++++++++++ adminapi/transport_test.go | 52 ++++++++++++++++++++++++++++++ 11 files changed, 91 insertions(+), 127 deletions(-) delete mode 100644 adminapi/auth.go delete mode 100644 adminapi/auth_test.go delete mode 100644 adminapi/helpers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6462c2..f169276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,8 @@ jobs: # Build for macOS ARM64 (Apple Silicon) GOOS=darwin GOARCH=arm64 go build -o bin/serveradmin-go-darwin-arm64 . - format-check: - name: Format Check + linter: + name: Linter Check runs-on: ubuntu-latest steps: - name: Checkout code @@ -69,12 +69,3 @@ jobs: uses: golangci/golangci-lint-action@v8 with: version: latest - - - name: Check go mod tidy - run: | - go mod tidy - if [ -n "$(git status --porcelain)" ]; then - echo "go.mod or go.sum is not tidy. Please run 'go mod tidy'" - git status - exit 1 - fi diff --git a/.golangci.yml b/.golangci.yml index d5f055a..c7c3153 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -55,6 +55,9 @@ linters: - perfsprint - usestdlibvars path: _test\.go + - linters: + - errcheck + path: examples/ paths: - third_party$ - builtin$ diff --git a/Makefile b/Makefile index 30cb2f4..b848153 100644 --- a/Makefile +++ b/Makefile @@ -14,4 +14,4 @@ test-coverage: go tool cover -html=coverage.out -o coverage.html linter: - golangci-lint run + golangci-lint run --fix diff --git a/adminapi/auth.go b/adminapi/auth.go deleted file mode 100644 index 4998460..0000000 --- a/adminapi/auth.go +++ /dev/null @@ -1,28 +0,0 @@ -package adminapi - -import ( - "crypto/hmac" - "crypto/sha1" //nolint:gosec // SHA1 is required by the protocol - "encoding/hex" - "strconv" -) - -// calcSecurityToken calculates HMAC-SHA1 of timestamp:data -func calcSecurityToken(authToken []byte, timestamp int64, data []byte) string { - mac := hmac.New(sha1.New, authToken) - mac.Write(calcMessage(timestamp, data)) - - return hex.EncodeToString(mac.Sum(nil)) -} - -// calcMessage efficiently concatenates timestamp:data without redundant allocations -func calcMessage(timestamp int64, data []byte) []byte { - return append(append(strconv.AppendInt(nil, timestamp, 10), ':'), data...) -} - -// calcAppID computes SHA-1 hash of the auth token -func calcAppID(authToken []byte) string { - hash := sha1.Sum(authToken) //nolint:gosec // SHA1 is required by the protocol - - return hex.EncodeToString(hash[:]) -} diff --git a/adminapi/auth_test.go b/adminapi/auth_test.go deleted file mode 100644 index 3d76579..0000000 --- a/adminapi/auth_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package adminapi - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// just some simple example tests, e2e tests might make much more sense here for full coverage -func TestAppId(t *testing.T) { - testCases := []struct { - input []byte - expected string - }{ - { - input: []byte("1234567898"), - expected: "d396f232a5ca1f7a0ad8f1b59975515123780553", - }, - } - - for _, testCase := range testCases { - actual := calcAppID(testCase.input) - assert.Equal(t, testCase.expected, actual) - } -} - -func TestSecurityToken(t *testing.T) { - testCases := []struct { - apiKey []byte - message string - expected string - }{ - { - apiKey: []byte("1234567898"), - message: "", - expected: "4199b91c6f92f3e1d29f88a5f67973ad8aaec5b5", - }, - { - apiKey: []byte("1234567898"), - message: "foobar", - expected: "e17ba31a1a664617653869db8289f92a49213e7b", - }, - } - - now := int64(123456789) - for _, testCase := range testCases { - actual := calcSecurityToken(testCase.apiKey, now, []byte(testCase.message)) - assert.Equal(t, testCase.expected, actual) - } -} - -func BenchmarkCalcSecurityToken(b *testing.B) { - now := int64(123456789) - message := []byte("foobar") - authToken := []byte("1234567898") - for b.Loop() { - calcSecurityToken(authToken, now, message) - } -} diff --git a/adminapi/config_test.go b/adminapi/config_test.go index f068159..da52814 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -2,12 +2,22 @@ package adminapi import ( "net/http/httptest" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// Because getConfig in config.go calls sync.OnceValues, the new values set to +// SERVERADMIN_BASE_URL between test runs is never changed, as getConfig returns +// cached values. +// We use resetConfig() to reinitialize things, forcing getConfig() to return the +// values from the new env variables. +func resetConfig() { + getConfig = sync.OnceValues(loadConfig) +} + func TestLoadConfig(t *testing.T) { // make a test without SERVERADMIN_BASE_URL set t.Setenv("SERVERADMIN_BASE_URL", "") diff --git a/adminapi/helpers_test.go b/adminapi/helpers_test.go deleted file mode 100644 index dca0981..0000000 --- a/adminapi/helpers_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package adminapi - -import "sync" - -// Because getConfig in config.go calls sync.OnceValues, the new values set to -// SERVERADMIN_BASE_URL between test runs is never changed, as getConfig returns -// cached values. -// We use resetConfig() to reinitialize things, forcing getConfig() to return the -// values from the new env variables. -func resetConfig() { - getConfig = sync.OnceValues(loadConfig) -} diff --git a/adminapi/server_object.go b/adminapi/server_object.go index fa98aad..ee6d026 100644 --- a/adminapi/server_object.go +++ b/adminapi/server_object.go @@ -2,7 +2,6 @@ package adminapi import ( "encoding/json" - "errors" "fmt" ) @@ -47,9 +46,6 @@ func (s *ServerObject) ObjectID() int { // Set modifies an attribute value and tracks the change for commit. func (s *ServerObject) Set(key string, value any) error { - if s.deleted { - return errors.New("cannot set attributes on a deleted object") - } if _, exists := s.attributes[key]; !exists { return fmt.Errorf("attribute %q does not exist", key) } diff --git a/adminapi/server_object_test.go b/adminapi/server_object_test.go index 028a38b..59b7d83 100644 --- a/adminapi/server_object_test.go +++ b/adminapi/server_object_test.go @@ -35,18 +35,6 @@ func TestSetNonexistent(t *testing.T) { assert.Contains(t, err.Error(), "does not exist") } -func TestSetOnDeleted(t *testing.T) { - obj := &ServerObject{ - attributes: map[string]any{"hostname": "test", "object_id": float64(1)}, - oldValues: map[string]any{}, - deleted: true, - } - - err := obj.Set("hostname", "new") - require.Error(t, err) - assert.Contains(t, err.Error(), "deleted") -} - func TestCommitState(t *testing.T) { // Consistent: no changes obj := &ServerObject{ diff --git a/adminapi/transport.go b/adminapi/transport.go index 9917a78..6f61204 100644 --- a/adminapi/transport.go +++ b/adminapi/transport.go @@ -4,8 +4,11 @@ import ( "bytes" "compress/gzip" "context" + "crypto/hmac" "crypto/rand" + "crypto/sha1" //nolint:gosec // SHA1 is required by the protocol "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -120,3 +123,23 @@ type gzipReadCloser struct { func (grc *gzipReadCloser) Close() error { return errors.Join(grc.gz.Close(), grc.body.Close()) } + +// calcSecurityToken calculates HMAC-SHA1 of timestamp:data +func calcSecurityToken(authToken []byte, timestamp int64, data []byte) string { + mac := hmac.New(sha1.New, authToken) + mac.Write(calcMessage(timestamp, data)) + + return hex.EncodeToString(mac.Sum(nil)) +} + +// calcMessage efficiently concatenates timestamp:data without redundant allocations +func calcMessage(timestamp int64, data []byte) []byte { + return append(append(strconv.AppendInt(nil, timestamp, 10), ':'), data...) +} + +// calcAppID computes SHA-1 hash of the auth token +func calcAppID(authToken []byte) string { + hash := sha1.Sum(authToken) //nolint:gosec // SHA1 is required by the protocol + + return hex.EncodeToString(hash[:]) +} diff --git a/adminapi/transport_test.go b/adminapi/transport_test.go index a2ffedf..a74af24 100644 --- a/adminapi/transport_test.go +++ b/adminapi/transport_test.go @@ -110,3 +110,55 @@ func TestHTTPErrorHandling(t *testing.T) { }) } } + +// just some simple example tests, e2e tests might make much more sense here for full coverage +func TestAppId(t *testing.T) { + testCases := []struct { + input []byte + expected string + }{ + { + input: []byte("1234567898"), + expected: "d396f232a5ca1f7a0ad8f1b59975515123780553", + }, + } + + for _, testCase := range testCases { + actual := calcAppID(testCase.input) + assert.Equal(t, testCase.expected, actual) + } +} + +func TestSecurityToken(t *testing.T) { + testCases := []struct { + apiKey []byte + message string + expected string + }{ + { + apiKey: []byte("1234567898"), + message: "", + expected: "4199b91c6f92f3e1d29f88a5f67973ad8aaec5b5", + }, + { + apiKey: []byte("1234567898"), + message: "foobar", + expected: "e17ba31a1a664617653869db8289f92a49213e7b", + }, + } + + now := int64(123456789) + for _, testCase := range testCases { + actual := calcSecurityToken(testCase.apiKey, now, []byte(testCase.message)) + assert.Equal(t, testCase.expected, actual) + } +} + +func BenchmarkCalcSecurityToken(b *testing.B) { + now := int64(123456789) + message := []byte("foobar") + authToken := []byte("1234567898") + for b.Loop() { + calcSecurityToken(authToken, now, message) + } +} From 38447ea86467a9d5d23c8c505f1eb05faa344f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Thu, 12 Feb 2026 21:16:41 +0100 Subject: [PATCH 4/5] add bulk .Set() + .Delete() --- .golangci.yml | 1 + CLAUDE.md | 172 ++++++++++++++++++++++++++++++++++++ adminapi/commit.go | 23 +++++ adminapi/commit_test.go | 135 ++++++++++++++++++++++++++++ adminapi/filters.go | 4 + adminapi/parse_test.go | 4 +- adminapi/query.go | 6 +- adminapi/query_test.go | 34 +++++++ adminapi/transport_test.go | 4 +- examples/query_example.go | 175 +++++++++++++++++++++++++++++++++++++ examples/update_example.go | 63 ++++++------- main.go | 2 +- 12 files changed, 587 insertions(+), 36 deletions(-) create mode 100644 CLAUDE.md create mode 100644 adminapi/query_test.go create mode 100644 examples/query_example.go diff --git a/.golangci.yml b/.golangci.yml index c7c3153..bc6b946 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -57,6 +57,7 @@ linters: path: _test\.go - linters: - errcheck + - unused path: examples/ paths: - third_party$ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e411cb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,172 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Go client library and CLI tool for InnoGames Serveradmin, a configuration management database system. The library provides both string-based and programmatic query interfaces with support for complex filters, and handles create/update/delete operations with change tracking. + +## Development Commands + +```bash +# Build the CLI tool +make build + +# Run all tests +make test + +# Run tests with race detector +make test-race + +# Generate test coverage report (creates coverage.html) +make test-coverage + +# Run linter with auto-fix +make linter + +# Run a specific test +go test -run TestParseQuery ./adminapi + +# Run a specific benchmark +go test -bench BenchmarkParseQuery_Simple ./adminapi +``` + +## Architecture Overview + +### Package Structure + +**adminapi/** - Core library package containing all API client functionality: +- `query.go` - Query building (`Query`, `FromQuery`, `NewQuery`) +- `parse.go` - Query string parser converting "hostname=web*" to Filters +- `filters.go` - Filter functions (Regexp, Any, All, Not, Empty) +- `server_object.go` - ServerObject with change tracking and state management +- `commit.go` - Commit/rollback operations for objects +- `transport.go` - HTTP client with SSH and token authentication +- `config.go` - Configuration loading from environment variables + +**examples/** - Standalone example programs demonstrating library usage + +### Core Data Flow + +1. **Query Creation** → 2. **Fetch** → 3. **Modify** → 4. **Commit** + +``` +FromQuery("hostname=web*") or NewQuery(Filters{...}) + ↓ +Query.All() / Query.One() [transport.go sends HTTP request] + ↓ +ServerObjects with attributes loaded + ↓ +ServerObject.Set(key, value) [tracks oldValues internally] + ↓ +ServerObject.Commit() or ServerObjects.Commit() [sends delta to API] +``` + +### Key Architectural Patterns + +**Change Tracking**: `ServerObject` maintains an `oldValues` map that records original attribute values on first modification. The `serializeChanges()` method computes deltas, sending only modified fields to the API. This mimics the Python client's behavior. + +**Multi-attributes**: Slice-valued attributes use set semantics during commit, computing `add` and `remove` sets rather than replacing the entire slice. See `sliceDiff()` in `server_object.go`. + +**State Machine**: ServerObject has four states returned by `CommitState()`: +- `"created"` - object_id is nil (new object not yet committed) +- `"deleted"` - marked for deletion +- `"changed"` - has modifications in oldValues +- `"consistent"` - no pending changes + +**Authentication**: The client supports two auth methods (checked in order): +1. SSH key signing (via `SERVERADMIN_KEY_PATH` or `SSH_AUTH_SOCK` agent) +2. Security token (via `SERVERADMIN_TOKEN` with HMAC-SHA1 signing) + +Configuration is loaded once via `sync.OnceValues` in `config.go`. + +**Filter System**: Two ways to build queries: +1. String-based: `FromQuery("hostname=regexp(web.*) environment=production")` +2. Programmatic: `NewQuery(Filters{"hostname": Regexp("web.*")})` + +The parser (`parse.go`) handles nested parentheses and converts function names case-insensitively (e.g., "ReGEXP" → "Regexp"). + +## Important Implementation Details + +### Query Interface + +Both `FromQuery` and `NewQuery` return a `Query` struct. Key methods: +- `SetAttributes([]string)` or `SetAttributes(...string)` - specify which attributes to fetch +- `AddFilter(key, value)` - add filters incrementally to existing Query +- `All()` → `ServerObjects` - fetch all matching objects +- `One()` → `*ServerObject` - fetch exactly one (errors if 0 or >1 results) + +### ServerObject Methods + +- `Get(attr)` returns `any` (auto-converts JSON float64 to int) +- `GetString(attr)` returns `string` +- `Set(key, value)` tracks changes; returns error if attribute doesn't exist +- `Delete()` marks for deletion (doesn't actually delete until commit) +- `Rollback()` discards all local changes +- `Commit()` sends changes to API and clears oldValues on success + +### Filter Functions + +Implemented in `filters.go`: +- `Regexp(pattern string)` - regex matching +- `Not(value)` - negation (works with values or other filters) +- `Any(values...)` - OR semantics (match any of) +- `All(values...)` - AND semantics (match all of) +- `Empty()` - checks for empty/nil values + +These can be nested: `Not(Any(Regexp("^test.*"), Regexp("^dev.*")))` + +Additional filters exist in the parser's `allFilters` map (GreaterThan, LessThan, etc.) but lack Go helper functions. These can still be used via `FromQuery` string syntax. + +### Testing Patterns + +Tests use testify/assert and testify/require. The codebase has table-driven tests (see `parse_test.go`). + +When writing tests: +- Use `require.NoError` for setup that must succeed +- Use `assert.Error` for expected failures with descriptive messages +- Table-driven tests should have descriptive `name` fields +- Go 1.25+ uses `b.Loop()` instead of `for i := 0; i < b.N; i++` in benchmarks + +### Linting + +The project uses golangci-lint with an extensive linter configuration (`.golangci.yml`). Key points: +- Formatters gci and gofumpt are enabled (imports grouped, strict formatting) +- Some linters (errcheck, perfsprint) are relaxed for `_test.go` files +- Examples directory has relaxed rules +- Run `make linter` to auto-fix issues before committing +- SHA1 usage is intentional (required by protocol) - use `//nolint:gosec` comments + +## Configuration Requirements + +The client requires these environment variables: + +```bash +# Required +export SERVERADMIN_BASE_URL="https://serveradmin.example.com" + +# One of these auth methods: +export SERVERADMIN_TOKEN="your-token" # Token-based auth +# OR +export SERVERADMIN_KEY_PATH="/path/to/key" # SSH key file +# OR +export SSH_AUTH_SOCK="/path/to/ssh-agent.sock" # SSH agent (auto-detected) +``` + +The client fails fast if `SERVERADMIN_BASE_URL` or auth credentials are missing. + +## Examples Directory + +The `examples/` directory contains standalone programs demonstrating: +- `update_example.go` - Single/batch updates, create, delete, rollback +- `query_example.go` - Query patterns (string vs programmatic, simple vs nested filters) + +These use a shorter import alias pattern: `import api "github.com/innogames/serveradmin-go-client/adminapi"` + +Examples are excluded from strict linting rules and can ignore errors for brevity. + +## Version Compatibility + +- Requires Go 1.24+ (per README, using latest Go 1.25 features like `b.Loop()`) +- API version is hardcoded in `config.go` as `version = "4.9.0"` +- The client maintains compatibility with the Python Serveradmin client's behavior (change tracking, JSON comparison logic) diff --git a/adminapi/commit.go b/adminapi/commit.go index 279aa56..f8cd19a 100644 --- a/adminapi/commit.go +++ b/adminapi/commit.go @@ -2,6 +2,7 @@ package adminapi import ( "encoding/json" + "errors" "fmt" ) @@ -42,6 +43,28 @@ func (s ServerObjects) Rollback() { } } +// Set calls Set(key, value) on each ServerObject in the slice. +// If any Set operation fails, all errors are collected and returned +// as a joined error. This allows identifying all problematic objects +// in a single call rather than failing on the first error. +func (s ServerObjects) Set(key string, value any) error { + var errs []error + for i, obj := range s { + if err := obj.Set(key, value); err != nil { + errs = append(errs, fmt.Errorf("object %d (id=%v): %w", i, obj.Get("object_id"), err)) + } + } + return errors.Join(errs...) +} + +// Delete calls Delete() on each ServerObject in the slice. +// This marks all objects for deletion on the next Commit(). +func (s ServerObjects) Delete() { + for _, obj := range s { + obj.Delete() + } +} + // Commit commits this single object's changes to the server. func (s *ServerObject) Commit() (int, error) { commit := buildCommit(ServerObjects{s}) diff --git a/adminapi/commit_test.go b/adminapi/commit_test.go index 5424ad2..9eb65aa 100644 --- a/adminapi/commit_test.go +++ b/adminapi/commit_test.go @@ -86,3 +86,138 @@ func TestCommitResultSet(t *testing.T) { assert.Len(t, receivedBody.Deleted, 1) assert.Empty(t, receivedBody.Created) } + +func TestServerObjectsSetSuccess(t *testing.T) { + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "server1", "object_id": float64(1)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "server2", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + } + + err := objects.Set("hostname", "updated") + require.NoError(t, err) + + assert.Equal(t, "updated", objects[0].GetString("hostname")) + assert.Equal(t, "updated", objects[1].GetString("hostname")) + assert.Equal(t, "server1", objects[0].oldValues["hostname"]) + assert.Equal(t, "server2", objects[1].oldValues["hostname"]) +} + +func TestServerObjectsSetAllErrors(t *testing.T) { + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "server1", "object_id": float64(1)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "server2", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + } + + err := objects.Set("nonexistent", "value") + require.Error(t, err) + + // 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") +} + +func TestServerObjectsSetPartialErrors(t *testing.T) { + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "server1", "memory": 16, "object_id": float64(1)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "server2", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + } + + // "memory" exists in first object but not second + err := objects.Set("memory", 32) + require.Error(t, err) + + // First object should be updated successfully + assert.Equal(t, 32, objects[0].Get("memory")) + assert.Equal(t, 16, objects[0].oldValues["memory"]) + + // Error should only mention the second object + assert.Contains(t, err.Error(), "object 1") + assert.Contains(t, err.Error(), "id=2") + assert.NotContains(t, err.Error(), "object 0") +} + +func TestServerObjectsSetEmpty(t *testing.T) { + objects := ServerObjects{} + err := objects.Set("hostname", "value") + require.NoError(t, err) // No objects = no errors +} + +func TestServerObjectsDelete(t *testing.T) { + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "server1", "object_id": float64(1)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "server2", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + } + + 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()) +} + +func TestServerObjectsDeleteEmpty(_ *testing.T) { + objects := ServerObjects{} + objects.Delete() // Should not panic +} + +func TestServerObjectsSetWithCommit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"status": "success", "commit_id": 999}`)) + })) + defer server.Close() + + resetConfig() + t.Setenv("SERVERADMIN_TOKEN", "testtoken") + t.Setenv("SERVERADMIN_BASE_URL", server.URL) + + objects := ServerObjects{ + { + attributes: map[string]any{"hostname": "server1", "object_id": float64(1)}, + oldValues: map[string]any{}, + }, + { + attributes: map[string]any{"hostname": "server2", "object_id": float64(2)}, + oldValues: map[string]any{}, + }, + } + + // Set valid attribute + err := objects.Set("hostname", "updated.local") + require.NoError(t, err) + + // Commit should work + commitID, err := objects.Commit() + require.NoError(t, err) + 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()) +} diff --git a/adminapi/filters.go b/adminapi/filters.go index 50ae0c5..8dc54b8 100644 --- a/adminapi/filters.go +++ b/adminapi/filters.go @@ -40,6 +40,10 @@ func Not[V valueOrFilter](filter V) Filter { return createFilter("Not", filter) } +func NotEmpty() Filter { + return Not(Empty()) +} + func Any[V valueOrFilter](values ...V) Filter { return createFilter("Any", values) } diff --git a/adminapi/parse_test.go b/adminapi/parse_test.go index 6da1606..370c61e 100644 --- a/adminapi/parse_test.go +++ b/adminapi/parse_test.go @@ -56,8 +56,8 @@ func TestParseQuery(t *testing.T) { }, { name: "All with mixed types", - query: "meta=all(1 server true)", - want: Filters{"meta": Filter{"All": []any{1, "server", true}}}, + query: "hypervisor=all(1 server true)", + want: Filters{"hypervisor": Filter{"All": []any{1, "server", true}}}, }, { name: "Nested filters", diff --git a/adminapi/query.go b/adminapi/query.go index e060965..cc6c703 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -34,10 +34,14 @@ func NewQuery(filters Filters) Query { } } -func (q *Query) SetAttributes(attributes []string) { +func (q *Query) SetAttributes(attributes ...string) { q.restrictedAttributes = attributes } +func (q *Query) AddAttributes(attributes ...string) { + q.restrictedAttributes = append(q.restrictedAttributes, attributes...) +} + func (q *Query) OrderBy(attribute string) { q.orderBy = attribute } diff --git a/adminapi/query_test.go b/adminapi/query_test.go new file mode 100644 index 0000000..6357f52 --- /dev/null +++ b/adminapi/query_test.go @@ -0,0 +1,34 @@ +package adminapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetAttributes(t *testing.T) { + q := NewQuery(Filters{}) + + // Default attributes + assert.Equal(t, []string{"object_id", "hostname"}, q.restrictedAttributes) + + // SetAttributes replaces defaults + q.SetAttributes("memory") + assert.Equal(t, []string{"memory"}, q.restrictedAttributes) + + // SetAttributes with multiple arguments + q.SetAttributes("hostname", "num_cpu", "memory") + assert.Equal(t, []string{"hostname", "num_cpu", "memory"}, q.restrictedAttributes) +} + +func TestAddAttributes(t *testing.T) { + q := NewQuery(Filters{}) + + // AddAttributes appends to defaults + q.AddAttributes("memory") + assert.Equal(t, []string{"object_id", "hostname", "memory"}, q.restrictedAttributes) + + // AddAttributes with multiple arguments + q.AddAttributes("num_cpu", "state") + assert.Equal(t, []string{"object_id", "hostname", "memory", "num_cpu", "state"}, q.restrictedAttributes) +} diff --git a/adminapi/transport_test.go b/adminapi/transport_test.go index a74af24..90b91f8 100644 --- a/adminapi/transport_test.go +++ b/adminapi/transport_test.go @@ -31,7 +31,7 @@ func TestFakeServer(t *testing.T) { query := NewQuery(Filters{ "hostname": Any(Regexp("test.foo.local"), Regexp(".*\\.bar.local")), }) - query.SetAttributes([]string{"hostname"}) + query.SetAttributes("hostname") servers, err := query.All() require.NoError(t, err) @@ -100,7 +100,7 @@ func TestHTTPErrorHandling(t *testing.T) { query := NewQuery(Filters{ "hostname": Regexp("test.local"), }) - query.SetAttributes([]string{"hostname"}) + query.SetAttributes("hostname") servers, err := query.All() require.Error(t, err) diff --git a/examples/query_example.go b/examples/query_example.go new file mode 100644 index 0000000..5e34f41 --- /dev/null +++ b/examples/query_example.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "log" + + api "github.com/innogames/serveradmin-go-client/adminapi" +) + +func stringQueryExample() { + // Simple string-based query + q, err := api.FromQuery("hostname=webserver01 environment=production") + if err != nil { + log.Fatal(err) + } + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers using string query\n", len(servers)) +} + +func simpleFilterExample() { + // Create query programmatically with simple filters passed directly + q := api.NewQuery(api.Filters{ + "environment": "production", + "state": "online", + "num_cpu": 8, + }) + q.SetAttributes("hostname", "num_cpu", "memory") + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d production servers with 8 CPUs\n", len(servers)) +} + +func regexpFilterExample() { + // Use Regexp filter to match hostnames + q := api.NewQuery(api.Filters{ + "hostname": api.Regexp("^web.*\\.example\\.com$"), + "environment": "production", + }) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d web servers matching pattern\n", len(servers)) +} + +func anyAllFilterExample() { + // Use Any filter to match multiple possible values + q := api.NewQuery(api.Filters{ + "game_world": api.Any(1, 2, 3), + "state": api.Any("online", "maintenance"), + }) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers in game worlds 1, 2, or 3\n", len(servers)) + + // Use All filter to match all conditions + q2 := api.NewQuery(api.Filters{ + "tags": api.All("backup", "critical"), + }) + + servers2, err := q2.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers with both 'backup' and 'critical' tags\n", len(servers2)) +} + +func notFilterExample() { + // Use Not with Empty to find servers with non-empty values + q := api.NewQuery(api.Filters{ + "backup_disabled": false, + "comment": api.NotEmpty(), + }) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers with backup_disabled and comment set\n", len(servers)) + + // Use Not with specific value + q2 := api.NewQuery(api.Filters{}) + q2.AddFilter("environment", api.Not("development")) + + servers2, err := q2.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d non-development servers\n", len(servers2)) +} + +func nestedFilterExample() { + // Complex nested filters: servers that don't match certain patterns + q := api.NewQuery(api.Filters{}) + + // Find servers where hostname is NOT matching any of these patterns + q.AddFilter("hostname", api.Not(api.Any( + api.Regexp("^test.*"), + api.Regexp("^dev.*"), + api.Regexp("^tmp.*"), + ))) + + // Environment must be production or staging, but not empty + q.AddFilter("environment", api.Any("production", "staging")) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers with complex nested filters\n", len(servers)) +} + +func combinedFilterExample() { + // Real-world example: Find suitable servers for migration + q := api.NewQuery(api.Filters{}) + + q.AddFilter("servertype", "server") + q.AddFilter("environment", "production") + q.AddFilter("state", api.Any("online", "deploy_online")) + q.AddFilter("num_cpu", api.Any(4, 8, 16)) + + // Must NOT be marked for decommission + q.AddFilter("decommission", api.Not(true)) + + // Must have a hostname that doesn't start with "legacy" + q.AddFilter("hostname", api.Not(api.Regexp("^legacy.*"))) + + // Must have non-empty project assignment + q.AddFilter("project", api.NotEmpty()) + + // Only fetch required attributes + q.SetAttributes( + "hostname", + "environment", + "num_cpu", + "memory", + "state", + "project", + "object_id", + ) + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d servers suitable for migration:\n", len(servers)) + for _, server := range servers { + fmt.Printf(" - %s: %v CPUs, %v GB RAM, project: %s\n", + server.GetString("hostname"), + server.Get("num_cpu"), + server.Get("memory"), + server.GetString("project"), + ) + } +} diff --git a/examples/update_example.go b/examples/update_example.go index 4a1b0d5..9e5e939 100644 --- a/examples/update_example.go +++ b/examples/update_example.go @@ -4,32 +4,15 @@ import ( "fmt" "log" - "github.com/innogames/serveradmin-go-client/adminapi" + api "github.com/innogames/serveradmin-go-client/adminapi" ) -// Example demonstrating how to update server objects -func main() { - // Example 1: Update a single object - singleObjectExample() - - // Example 2: Update multiple objects in batch - multiObjectExample() - - // Example 3: Create a new object - createObjectExample() - - // Example 4: Delete an object - deleteObjectExample() - - // Example 5: Rollback changes - rollbackExample() -} - func singleObjectExample() { - q, err := adminapi.FromQuery("hostname=webserver01") + q, err := api.FromQuery("hostname=webserver01") if err != nil { log.Fatal(err) } + q.AddAttributes("backup_disabled") server, err := q.One() if err != nil { @@ -38,7 +21,6 @@ func singleObjectExample() { // Modify attributes server.Set("backup_disabled", true) - server.Set("comment", "Updated via Go client") // Commit changes commitID, err := server.Commit() @@ -50,21 +32,19 @@ func singleObjectExample() { } func multiObjectExample() { - q, err := adminapi.FromQuery("environment=production state=online") + q, err := api.FromQuery("environment=production state=online") if err != nil { log.Fatal(err) } - q.SetAttributes([]string{"hostname", "backup_disabled", "object_id"}) + q.SetAttributes("hostname", "backup_disabled") servers, err := q.All() if err != nil { log.Fatal(err) } - // Update all servers - for _, server := range servers { - server.Set("backup_disabled", false) - } + // Update all servers using batch Set() + servers.Set("backup_disabled", false) // Commit all changes in a single API call commitID, err := servers.Commit() @@ -77,7 +57,7 @@ func multiObjectExample() { func createObjectExample() { // Create a new VM object - newVM, err := adminapi.NewObject("vm") + newVM, err := api.NewObject("vm") if err != nil { log.Fatal(err) } @@ -97,7 +77,7 @@ func createObjectExample() { } func deleteObjectExample() { - q, err := adminapi.FromQuery("hostname=oldserver.example.com") + q, err := api.FromQuery("hostname=oldserver.example.com") if err != nil { log.Fatal(err) } @@ -119,8 +99,31 @@ func deleteObjectExample() { fmt.Printf("Deleted server (commit %d)\n", commitID) } +func batchDeleteExample() { + q, err := api.FromQuery("state=decommissioned") + if err != nil { + log.Fatal(err) + } + + servers, err := q.All() + if err != nil { + log.Fatal(err) + } + + // Delete ALL decommissioned servers using batch Delete() + servers.Delete() + + // Commit all deletions in a single API call + commitID, err := servers.Commit() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Deleted %d servers (commit %d)\n", len(servers), commitID) +} + func rollbackExample() { - q, err := adminapi.FromQuery("hostname=webserver01") + q, err := api.FromQuery("hostname=webserver01") if err != nil { log.Fatal(err) } diff --git a/main.go b/main.go index e1dfc03..0c22cc3 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,7 @@ func main() { } attributeList := strings.Split(attributes, ",") - q.SetAttributes(attributeList) + q.SetAttributes(attributeList...) q.OrderBy(orderBy) servers, err := q.All() From deb7d346d27805a9263ed8a4332c5d302aa2f0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Fri, 13 Feb 2026 09:33:31 +0100 Subject: [PATCH 5/5] support MultiAttr + more examples --- .gitignore | 1 + adminapi/commit.go | 1 - adminapi/filters.go | 38 +++++- adminapi/query.go | 10 +- adminapi/query_test.go | 28 +++++ adminapi/server_object.go | 38 ++++-- adminapi/server_object_test.go | 108 ++++++++++++++++++ examples/helpers.go | 10 ++ .../{query_example.go => query_examples.go} | 41 ++----- examples/real.go | 71 ++++++++++++ examples/update_example.go | 64 +++-------- 11 files changed, 319 insertions(+), 91 deletions(-) create mode 100644 examples/helpers.go rename examples/{query_example.go => query_examples.go} (90%) create mode 100644 examples/real.go diff --git a/.gitignore b/.gitignore index 1c08f28..04a465e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.devcontainer vendor diff --git a/adminapi/commit.go b/adminapi/commit.go index f8cd19a..f1b3f48 100644 --- a/adminapi/commit.go +++ b/adminapi/commit.go @@ -68,7 +68,6 @@ func (s ServerObjects) Delete() { // Commit commits this single object's changes to the server. func (s *ServerObject) Commit() (int, error) { commit := buildCommit(ServerObjects{s}) - commitID, err := sendCommit(commit) if err != nil { return 0, err diff --git a/adminapi/filters.go b/adminapi/filters.go index 8dc54b8..f5d6318 100644 --- a/adminapi/filters.go +++ b/adminapi/filters.go @@ -1,7 +1,5 @@ package adminapi -// todo have proper values and more fitting types instead of any - type ( Filters map[string]any Filter map[string]any @@ -56,6 +54,42 @@ func Empty() Filter { return createFilter("Empty", nil) } +func StartsWith(value string) Filter { + return createFilter("StartsWith", value) +} + +func GreaterThan[N int | float64](value N) Filter { + return createFilter("GreaterThan", value) +} + +func GreaterThanOrEquals[N int | float64](value N) Filter { + return createFilter("GreaterThanOrEquals", value) +} + +func LessThan[N int | float64](value N) Filter { + return createFilter("LessThan", value) +} + +func LessThanOrEquals[N int | float64](value N) Filter { + return createFilter("LessThanOrEquals", value) +} + +func Contains[V valueOrFilter](value V) Filter { + return createFilter("Contains", value) +} + +func ContainedBy[V valueOrFilter](value V) Filter { + return createFilter("ContainedBy", value) +} + +func ContainedOnlyBy[V valueOrFilter](value V) Filter { + return createFilter("ContainedOnlyBy", value) +} + +func Overlaps[V valueOrFilter](value V) Filter { + return createFilter("Overlaps", value) +} + func createFilter(filterType string, value any) Filter { return Filter{ filterType: value, diff --git a/adminapi/query.go b/adminapi/query.go index cc6c703..f6f148b 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -34,18 +34,22 @@ func NewQuery(filters Filters) Query { } } +// SetAttributes replaces the list of attributes to fetch from the API func (q *Query) SetAttributes(attributes ...string) { q.restrictedAttributes = attributes } +// AddAttributes appends additional attributes to the list of attributes to fetch func (q *Query) AddAttributes(attributes ...string) { q.restrictedAttributes = append(q.restrictedAttributes, attributes...) } +// OrderBy sets the attribute to sort results by func (q *Query) OrderBy(attribute string) { q.orderBy = attribute } +// AddFilter adds or updates a filter for the specified attribute func (q *Query) AddFilter(attribute string, filter any) { q.filters[attribute] = filter } @@ -139,9 +143,13 @@ func NewObject(serverType string) (*ServerObject, error) { } defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&server.attributes); err != nil { + var response struct { + Result map[string]any `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 diff --git a/adminapi/query_test.go b/adminapi/query_test.go index 6357f52..24fc7bd 100644 --- a/adminapi/query_test.go +++ b/adminapi/query_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSetAttributes(t *testing.T) { @@ -32,3 +33,30 @@ func TestAddAttributes(t *testing.T) { q.AddAttributes("num_cpu", "state") assert.Equal(t, []string{"object_id", "hostname", "memory", "num_cpu", "state"}, q.restrictedAttributes) } + +func TestFilters(t *testing.T) { + q := NewQuery(Filters{ + "hostname": NotEmpty(), + "num_cpu": Regexp(".*GB"), + "hypervisor": StartsWith("datacenter-x-"), + }) + + assert.Equal(t, Filters{ + "hostname": Filter{"Not": Filter{"Empty": interface{}(nil)}}, + "num_cpu": Filter{"Regexp": ".*GB"}, + "hypervisor": Filter{"StartsWith": "datacenter-x-"}, + }, q.filters) +} + +func TestFromQuery(t *testing.T) { + q, err := FromQuery("hostname=not(empty()) num_cpu=regexp(.*GB)") + require.NoError(t, err) + q.AddFilter("instance", 1) + q.OrderBy("num_cpu") + + assert.Equal(t, Filters{ + "hostname": Filter{"Not": Filter{"Empty": []interface{}{}}}, + "num_cpu": Filter{"Regexp": ".*GB"}, + "instance": 1, + }, q.filters) +} diff --git a/adminapi/server_object.go b/adminapi/server_object.go index ee6d026..ae6ad14 100644 --- a/adminapi/server_object.go +++ b/adminapi/server_object.go @@ -3,6 +3,7 @@ package adminapi import ( "encoding/json" "fmt" + "reflect" ) // ServerObjects is a slice of ServerObject pointers @@ -53,8 +54,8 @@ func (s *ServerObject) Set(key string, value any) error { // Save the original value on first modification only if _, tracked := s.oldValues[key]; !tracked { old := s.attributes[key] - // Deep copy slices to prevent aliasing - if oldSlice, ok := old.([]any); ok { + // Deep copy slices to prevent aliasing (handle any slice type) + if oldSlice := toAnySlice(old); oldSlice != nil { cp := make([]any, len(oldSlice)) copy(cp, oldSlice) s.oldValues[key] = cp @@ -108,12 +109,12 @@ func (s *ServerObject) serializeChanges() map[string]any { continue } - oldSlice, oldIsSlice := oldVal.([]any) - _, newIsSlice := newVal.([]any) + // Check if both old and new values are slices (of any type) + oldSlice := toAnySlice(oldVal) + newSlice := toAnySlice(newVal) - if oldIsSlice && newIsSlice { + if oldSlice != nil && newSlice != nil { // Multi-attribute: compute add/remove sets - newSlice := toAnySlice(newVal) add, remove := sliceDiff(oldSlice, newSlice) changes[key] = map[string]any{ "action": "multi", @@ -147,16 +148,37 @@ func jsonEqual(a, b any) bool { return string(aj) == string(bj) } -// toAnySlice converts a value known to be a slice to []any. +// toAnySlice converts any slice type ([]string, []int, []any, etc.) to []any. +// Returns nil if v is not a slice. func toAnySlice(v any) []any { + if v == nil { + return nil + } + + // Fast path for []any if s, ok := v.([]any); ok { return s } - return nil + + // Use reflection for other slice types + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice { + return nil + } + + result := make([]any, rv.Len()) + for i := range rv.Len() { + result[i] = rv.Index(i).Interface() + } + return result } // sliceDiff computes elements added to and removed from old to produce new (set semantics). func sliceDiff(old, cur []any) (add, remove []any) { + // Initialize as empty slices instead of nil so JSON serializes to [] not null + add = []any{} + remove = []any{} + oldSet := make(map[string]any, len(old)) for _, v := range old { k, _ := json.Marshal(v) diff --git a/adminapi/server_object_test.go b/adminapi/server_object_test.go index 59b7d83..3a6b03b 100644 --- a/adminapi/server_object_test.go +++ b/adminapi/server_object_test.go @@ -110,3 +110,111 @@ func TestRollback(t *testing.T) { assert.Empty(t, obj.oldValues) assert.Equal(t, "consistent", obj.CommitState()) } + +func TestSetMultiAttribute_WithStringSlice(t *testing.T) { + // Simulate a fetched object with a multi-attribute + // (JSON decoding produces []any for arrays) + attributes := map[string]any{ + "object_id": float64(12345), + "hostname": "test.example.com", + "dns_txt": []any{"existing", "values"}, + } + + obj := &ServerObject{ + attributes: attributes, + oldValues: map[string]any{}, + } + + // User sets the attribute using []string (common usage) + err := obj.Set("dns_txt", []string{"new", "values"}) + require.NoError(t, err) + + // Verify oldValues captured the original + assert.Equal(t, []any{"existing", "values"}, obj.oldValues["dns_txt"]) + + // Serialize changes + changes := obj.serializeChanges() + + // Should use "multi" action, not "update" + dnsChange, ok := changes["dns_txt"].(map[string]any) + require.True(t, ok, "dns_txt change should be a map") + + assert.Equal(t, "multi", dnsChange["action"], + "Multi-attribute should use 'multi' action even with []string, not 'update'") + + // Verify correct add/remove sets + add := dnsChange["add"].([]any) + remove := dnsChange["remove"].([]any) + + assert.ElementsMatch(t, []any{"new"}, add) + assert.ElementsMatch(t, []any{"existing"}, remove) +} + +func TestSetMultiAttribute_WithIntSlice(t *testing.T) { + attributes := map[string]any{ + "object_id": float64(12345), + "ports": []any{80, 443}, + } + + obj := &ServerObject{ + attributes: attributes, + oldValues: map[string]any{}, + } + + // User passes []int + err := obj.Set("ports", []int{443, 8080}) + require.NoError(t, err) + + changes := obj.serializeChanges() + portsChange := changes["ports"].(map[string]any) + + assert.Equal(t, "multi", portsChange["action"]) + assert.ElementsMatch(t, []any{8080}, portsChange["add"]) + assert.ElementsMatch(t, []any{80}, portsChange["remove"]) +} + +func TestToAnySlice_VariousTypes(t *testing.T) { + tests := []struct { + name string + input any + expected []any + }{ + { + name: "already []any", + input: []any{1, 2, 3}, + expected: []any{1, 2, 3}, + }, + { + name: "[]string", + input: []string{"a", "b", "c"}, + expected: []any{"a", "b", "c"}, + }, + { + name: "[]int", + input: []int{1, 2, 3}, + expected: []any{1, 2, 3}, + }, + { + name: "[]interface{} with mixed types", + input: []interface{}{"str", 42, true}, + expected: []any{"str", 42, true}, + }, + { + name: "not a slice", + input: "string", + expected: nil, + }, + { + name: "nil", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := toAnySlice(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/examples/helpers.go b/examples/helpers.go new file mode 100644 index 0000000..8dfba0b --- /dev/null +++ b/examples/helpers.go @@ -0,0 +1,10 @@ +package main + +import "log" + +// checkErr is a helper function for examples that exits on error +func checkErr(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/query_example.go b/examples/query_examples.go similarity index 90% rename from examples/query_example.go rename to examples/query_examples.go index 5e34f41..d38c6c4 100644 --- a/examples/query_example.go +++ b/examples/query_examples.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "log" api "github.com/innogames/serveradmin-go-client/adminapi" ) @@ -10,14 +9,10 @@ import ( func stringQueryExample() { // Simple string-based query q, err := api.FromQuery("hostname=webserver01 environment=production") - if err != nil { - log.Fatal(err) - } + checkErr(err) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers using string query\n", len(servers)) } @@ -32,9 +27,7 @@ func simpleFilterExample() { q.SetAttributes("hostname", "num_cpu", "memory") servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d production servers with 8 CPUs\n", len(servers)) } @@ -47,9 +40,7 @@ func regexpFilterExample() { }) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d web servers matching pattern\n", len(servers)) } @@ -62,9 +53,7 @@ func anyAllFilterExample() { }) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers in game worlds 1, 2, or 3\n", len(servers)) @@ -74,9 +63,7 @@ func anyAllFilterExample() { }) servers2, err := q2.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers with both 'backup' and 'critical' tags\n", len(servers2)) } @@ -89,9 +76,7 @@ func notFilterExample() { }) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers with backup_disabled and comment set\n", len(servers)) @@ -100,9 +85,7 @@ func notFilterExample() { q2.AddFilter("environment", api.Not("development")) servers2, err := q2.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d non-development servers\n", len(servers2)) } @@ -122,9 +105,7 @@ func nestedFilterExample() { q.AddFilter("environment", api.Any("production", "staging")) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers with complex nested filters\n", len(servers)) } @@ -159,9 +140,7 @@ func combinedFilterExample() { ) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Found %d servers suitable for migration:\n", len(servers)) for _, server := range servers { diff --git a/examples/real.go b/examples/real.go new file mode 100644 index 0000000..7d2a895 --- /dev/null +++ b/examples/real.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + + api "github.com/innogames/serveradmin-go-client/adminapi" +) + +func main() { + var commitID int + + // Step 1: Check if object already exists + fmt.Println("=== Checking for existing public_domain object ===") + q, err := api.FromQuery("hostname=test.foo.com servertype=public_domain") + checkErr(err) + q.AddAttributes("dns_txt") + + publicURL, err := q.One() + if err != nil { + // Object doesn't exist, create it + fmt.Println("=== Object not found, creating new public_domain object ===") + publicURL, err = api.NewObject("public_domain") + checkErr(err) + + // Set required attributes + publicURL.Set("hostname", "test.foo.com") + publicURL.Set("project", "admin") + + // Commit the new object + commitID, err = publicURL.Commit() + checkErr(err) + fmt.Printf("Created public_url %s (commit ID: %d)\n", publicURL.GetString("hostname"), commitID) + + // Re-query to get the server-assigned object_id + q, err = api.FromQuery("hostname=test.foo.com servertype=public_domain") + checkErr(err) + q.AddAttributes("dns_txt") + publicURL, err = q.One() + checkErr(err) + fmt.Printf("Re-queried object_id: %d\n", publicURL.ObjectID()) + } else { + fmt.Printf("Found existing object with object_id: %d\n", publicURL.ObjectID()) + } + + // Step 2: Set dns_txt to "foobar" + fmt.Println("\n=== Setting dns_txt to foobar ===") + publicURL.Set("dns_txt", []string{"foobar"}) + + // Commit the update + commitID, err = publicURL.Commit() + checkErr(err) + fmt.Printf("Set dns_txt to %v (commit ID: %d)\n", publicURL.Get("dns_txt"), commitID) + + // Step 3: Add a random string to dns_txt + fmt.Println("\n=== Adding random string to dns_txt ===") + publicURL.Set("dns_txt", []string{"foobar", "random_value_xyz123"}) + + // Commit the second update + commitID, err = publicURL.Commit() + checkErr(err) + fmt.Printf("Added to dns_txt, now: %v (commit ID: %d)\n", publicURL.Get("dns_txt"), commitID) + + // Step 4: Delete the object + fmt.Println("\n=== Deleting object ===") + publicURL.Delete() + commitID, err = publicURL.Commit() + checkErr(err) + fmt.Printf("Deleted public_url (commit ID: %d)\n", commitID) + + fmt.Println("\n=== Complete ===") +} diff --git a/examples/update_example.go b/examples/update_example.go index 9e5e939..3d1e0c9 100644 --- a/examples/update_example.go +++ b/examples/update_example.go @@ -9,48 +9,36 @@ import ( func singleObjectExample() { q, err := api.FromQuery("hostname=webserver01") - if err != nil { - log.Fatal(err) - } + checkErr(err) q.AddAttributes("backup_disabled") server, err := q.One() - if err != nil { - log.Fatal(err) - } + checkErr(err) // Modify attributes server.Set("backup_disabled", true) // Commit changes commitID, err := server.Commit() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Updated server %s (commit %d)\n", server.GetString("hostname"), commitID) } func multiObjectExample() { q, err := api.FromQuery("environment=production state=online") - if err != nil { - log.Fatal(err) - } + checkErr(err) q.SetAttributes("hostname", "backup_disabled") servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) // Update all servers using batch Set() servers.Set("backup_disabled", false) // Commit all changes in a single API call commitID, err := servers.Commit() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Updated %d servers (commit %d)\n", len(servers), commitID) } @@ -58,9 +46,7 @@ func multiObjectExample() { func createObjectExample() { // Create a new VM object newVM, err := api.NewObject("vm") - if err != nil { - log.Fatal(err) - } + checkErr(err) // Set required attributes newVM.Set("hostname", "newserver.example.com") @@ -69,69 +55,51 @@ func createObjectExample() { // Commit creates the object on the server commitID, err := newVM.Commit() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Created new VM %s (commit %d)\n", newVM.GetString("hostname"), commitID) } func deleteObjectExample() { q, err := api.FromQuery("hostname=oldserver.example.com") - if err != nil { - log.Fatal(err) - } + checkErr(err) server, err := q.One() - if err != nil { - log.Fatal(err) - } + checkErr(err) // Mark for deletion server.Delete() // Commit the deletion commitID, err := server.Commit() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Deleted server (commit %d)\n", commitID) } func batchDeleteExample() { q, err := api.FromQuery("state=decommissioned") - if err != nil { - log.Fatal(err) - } + checkErr(err) servers, err := q.All() - if err != nil { - log.Fatal(err) - } + checkErr(err) // Delete ALL decommissioned servers using batch Delete() servers.Delete() // Commit all deletions in a single API call commitID, err := servers.Commit() - if err != nil { - log.Fatal(err) - } + checkErr(err) fmt.Printf("Deleted %d servers (commit %d)\n", len(servers), commitID) } func rollbackExample() { q, err := api.FromQuery("hostname=webserver01") - if err != nil { - log.Fatal(err) - } + checkErr(err) server, err := q.One() - if err != nil { - log.Fatal(err) - } + checkErr(err) // Make some changes originalHostname := server.GetString("hostname")