diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c735de..f169276 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 @@ -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/.gitignore b/.gitignore index 1c08f28..04a465e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.devcontainer vendor diff --git a/.golangci.yml b/.golangci.yml index d5f055a..bc6b946 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -55,6 +55,10 @@ linters: - perfsprint - usestdlibvars path: _test\.go + - linters: + - errcheck + - unused + path: examples/ paths: - third_party$ - builtin$ 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/Makefile b/Makefile index ccadac4..b848153 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,12 @@ 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 linter: - golangci-lint run + golangci-lint run --fix diff --git a/adminapi/commit.go b/adminapi/commit.go new file mode 100644 index 0000000..f1b3f48 --- /dev/null +++ b/adminapi/commit.go @@ -0,0 +1,118 @@ +package adminapi + +import ( + "encoding/json" + "errors" + "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() + } +} + +// 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}) + 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..9eb65aa --- /dev/null +++ b/adminapi/commit_test.go @@ -0,0 +1,223 @@ +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) +} + +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/config.go b/adminapi/config.go index 56117ef..4ca91b4 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -38,9 +38,9 @@ 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 { + 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 f0d9a54..da52814 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -2,28 +2,40 @@ package adminapi import ( "net/http/httptest" - "os" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLoadConfig(t *testing.T) { - // clear env to not have configs from host here - os.Clearenv() +// 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", "") _, 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") + // 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) @@ -32,7 +44,10 @@ 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("SSH_AUTH_SOCK", "") + t.Setenv("SERVERADMIN_KEY_PATH", "testdata/test.key") + + resetConfig() cfg, err := loadConfig() require.NoError(t, err) @@ -41,7 +56,10 @@ 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("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/filters.go b/adminapi/filters.go index 50ae0c5..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 @@ -40,6 +38,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) } @@ -52,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/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 8a6400c..f6f148b 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -34,14 +34,22 @@ func NewQuery(filters Filters) Query { } } -func (q *Query) SetAttributes(attributes []string) { +// 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 } @@ -67,14 +75,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 +116,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 +127,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 +139,22 @@ 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) + 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 - return server, err + return server, nil } // like {"Filters": {"hostname": {"Regexp": "foo.local.*"}}, "restrict": ["hostname", "object_id"]} diff --git a/adminapi/query_test.go b/adminapi/query_test.go new file mode 100644 index 0000000..24fc7bd --- /dev/null +++ b/adminapi/query_test.go @@ -0,0 +1,62 @@ +package adminapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) +} + +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 new file mode 100644 index 0000000..ae6ad14 --- /dev/null +++ b/adminapi/server_object.go @@ -0,0 +1,204 @@ +package adminapi + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// 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 _, 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 (handle any slice type) + if oldSlice := toAnySlice(old); oldSlice != nil { + 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 + } + + // Check if both old and new values are slices (of any type) + oldSlice := toAnySlice(oldVal) + newSlice := toAnySlice(newVal) + + if oldSlice != nil && newSlice != nil { + // Multi-attribute: compute add/remove sets + 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 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 + } + + // 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) + 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..3a6b03b --- /dev/null +++ b/adminapi/server_object_test.go @@ -0,0 +1,220 @@ +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 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()) +} + +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/adminapi/api.go b/adminapi/transport.go similarity index 76% rename from adminapi/api.go rename to adminapi/transport.go index 6ee3583..6f61204 100644 --- a/adminapi/api.go +++ b/adminapi/transport.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,50 +23,19 @@ 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) any { - val := s.Get(attribute) - if strVal, isString := val.(string); isString { - return strVal - } - return nil -} - -// ObjectID returns the "object_id" attribute of the ServerObject -func (s ServerObject) ObjectID() int { - return s.Get("object_id").(int) -} - func sendRequest(endpoint string, postData any) (*http.Response, error) { config, err := getConfig() if err != nil { 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 +119,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/transport_test.go similarity index 83% rename from adminapi/api_test.go rename to adminapi/transport_test.go index 323ab9f..90b91f8 100644 --- a/adminapi/api_test.go +++ b/adminapi/transport_test.go @@ -4,27 +4,15 @@ import ( "io" "net/http" "net/http/httptest" - "os" - "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)) @@ -37,14 +25,13 @@ 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")), }) - query.SetAttributes([]string{"hostname"}) + query.SetAttributes("hostname") servers, err := query.All() require.NoError(t, err) @@ -55,67 +42,15 @@ 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) 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 { @@ -159,14 +94,13 @@ 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"), }) - query.SetAttributes([]string{"hostname"}) + query.SetAttributes("hostname") servers, err := query.All() require.Error(t, err) @@ -176,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) + } +} 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_examples.go b/examples/query_examples.go new file mode 100644 index 0000000..d38c6c4 --- /dev/null +++ b/examples/query_examples.go @@ -0,0 +1,154 @@ +package main + +import ( + "fmt" + + api "github.com/innogames/serveradmin-go-client/adminapi" +) + +func stringQueryExample() { + // Simple string-based query + q, err := api.FromQuery("hostname=webserver01 environment=production") + checkErr(err) + + servers, err := q.All() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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() + checkErr(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/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 new file mode 100644 index 0000000..3d1e0c9 --- /dev/null +++ b/examples/update_example.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "log" + + api "github.com/innogames/serveradmin-go-client/adminapi" +) + +func singleObjectExample() { + q, err := api.FromQuery("hostname=webserver01") + checkErr(err) + q.AddAttributes("backup_disabled") + + server, err := q.One() + checkErr(err) + + // Modify attributes + server.Set("backup_disabled", true) + + // Commit changes + commitID, err := server.Commit() + checkErr(err) + + fmt.Printf("Updated server %s (commit %d)\n", server.GetString("hostname"), commitID) +} + +func multiObjectExample() { + q, err := api.FromQuery("environment=production state=online") + checkErr(err) + q.SetAttributes("hostname", "backup_disabled") + + servers, err := q.All() + 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() + checkErr(err) + + fmt.Printf("Updated %d servers (commit %d)\n", len(servers), commitID) +} + +func createObjectExample() { + // Create a new VM object + newVM, err := api.NewObject("vm") + checkErr(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() + 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") + checkErr(err) + + server, err := q.One() + checkErr(err) + + // Mark for deletion + server.Delete() + + // Commit the deletion + commitID, err := server.Commit() + checkErr(err) + + fmt.Printf("Deleted server (commit %d)\n", commitID) +} + +func batchDeleteExample() { + q, err := api.FromQuery("state=decommissioned") + checkErr(err) + + servers, err := q.All() + checkErr(err) + + // Delete ALL decommissioned servers using batch Delete() + servers.Delete() + + // Commit all deletions in a single API call + commitID, err := servers.Commit() + checkErr(err) + + fmt.Printf("Deleted %d servers (commit %d)\n", len(servers), commitID) +} + +func rollbackExample() { + q, err := api.FromQuery("hostname=webserver01") + checkErr(err) + + server, err := q.One() + checkErr(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 17560f4..22400f1 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/innogames/serveradmin-go-client -go 1.24.0 +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..0c22cc3 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,8 @@ func main() { } attributeList := strings.Split(attributes, ",") - q.SetAttributes(attributeList) + 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() - */ }