Skip to content

Commit 61318ce

Browse files
authored
Merge pull request #3 from innogames/commit
implement Commit() + Delete() + MultiAttr
2 parents 839fd89 + deb7d34 commit 61318ce

24 files changed

+1551
-182
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ jobs:
1818
with:
1919
go-version: stable
2020

21-
- name: Run tests
22-
run: go test ./...
21+
- name: Run tests with race detector
22+
run: make test-race
23+
24+
- name: Run test coverage
25+
run: make test-coverage
2326

2427
build:
2528
name: Build
@@ -33,9 +36,6 @@ jobs:
3336
with:
3437
go-version: stable
3538

36-
- name: Install dependencies
37-
run: go mod download
38-
3939
- name: Build for multiple platforms
4040
run: |
4141
mkdir -p bin
@@ -54,8 +54,8 @@ jobs:
5454
# Build for macOS ARM64 (Apple Silicon)
5555
GOOS=darwin GOARCH=arm64 go build -o bin/serveradmin-go-darwin-arm64 .
5656
57-
format-check:
58-
name: Format Check
57+
linter:
58+
name: Linter Check
5959
runs-on: ubuntu-latest
6060
steps:
6161
- name: Checkout code
@@ -69,12 +69,3 @@ jobs:
6969
uses: golangci/golangci-lint-action@v8
7070
with:
7171
version: latest
72-
73-
- name: Check go mod tidy
74-
run: |
75-
go mod tidy
76-
if [ -n "$(git status --porcelain)" ]; then
77-
echo "go.mod or go.sum is not tidy. Please run 'go mod tidy'"
78-
git status
79-
exit 1
80-
fi

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.idea
2+
.devcontainer
23

34
vendor
45

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ linters:
5555
- perfsprint
5656
- usestdlibvars
5757
path: _test\.go
58+
- linters:
59+
- errcheck
60+
- unused
61+
path: examples/
5862
paths:
5963
- third_party$
6064
- builtin$

CLAUDE.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
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.
8+
9+
## Development Commands
10+
11+
```bash
12+
# Build the CLI tool
13+
make build
14+
15+
# Run all tests
16+
make test
17+
18+
# Run tests with race detector
19+
make test-race
20+
21+
# Generate test coverage report (creates coverage.html)
22+
make test-coverage
23+
24+
# Run linter with auto-fix
25+
make linter
26+
27+
# Run a specific test
28+
go test -run TestParseQuery ./adminapi
29+
30+
# Run a specific benchmark
31+
go test -bench BenchmarkParseQuery_Simple ./adminapi
32+
```
33+
34+
## Architecture Overview
35+
36+
### Package Structure
37+
38+
**adminapi/** - Core library package containing all API client functionality:
39+
- `query.go` - Query building (`Query`, `FromQuery`, `NewQuery`)
40+
- `parse.go` - Query string parser converting "hostname=web*" to Filters
41+
- `filters.go` - Filter functions (Regexp, Any, All, Not, Empty)
42+
- `server_object.go` - ServerObject with change tracking and state management
43+
- `commit.go` - Commit/rollback operations for objects
44+
- `transport.go` - HTTP client with SSH and token authentication
45+
- `config.go` - Configuration loading from environment variables
46+
47+
**examples/** - Standalone example programs demonstrating library usage
48+
49+
### Core Data Flow
50+
51+
1. **Query Creation** → 2. **Fetch** → 3. **Modify** → 4. **Commit**
52+
53+
```
54+
FromQuery("hostname=web*") or NewQuery(Filters{...})
55+
56+
Query.All() / Query.One() [transport.go sends HTTP request]
57+
58+
ServerObjects with attributes loaded
59+
60+
ServerObject.Set(key, value) [tracks oldValues internally]
61+
62+
ServerObject.Commit() or ServerObjects.Commit() [sends delta to API]
63+
```
64+
65+
### Key Architectural Patterns
66+
67+
**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.
68+
69+
**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`.
70+
71+
**State Machine**: ServerObject has four states returned by `CommitState()`:
72+
- `"created"` - object_id is nil (new object not yet committed)
73+
- `"deleted"` - marked for deletion
74+
- `"changed"` - has modifications in oldValues
75+
- `"consistent"` - no pending changes
76+
77+
**Authentication**: The client supports two auth methods (checked in order):
78+
1. SSH key signing (via `SERVERADMIN_KEY_PATH` or `SSH_AUTH_SOCK` agent)
79+
2. Security token (via `SERVERADMIN_TOKEN` with HMAC-SHA1 signing)
80+
81+
Configuration is loaded once via `sync.OnceValues` in `config.go`.
82+
83+
**Filter System**: Two ways to build queries:
84+
1. String-based: `FromQuery("hostname=regexp(web.*) environment=production")`
85+
2. Programmatic: `NewQuery(Filters{"hostname": Regexp("web.*")})`
86+
87+
The parser (`parse.go`) handles nested parentheses and converts function names case-insensitively (e.g., "ReGEXP" → "Regexp").
88+
89+
## Important Implementation Details
90+
91+
### Query Interface
92+
93+
Both `FromQuery` and `NewQuery` return a `Query` struct. Key methods:
94+
- `SetAttributes([]string)` or `SetAttributes(...string)` - specify which attributes to fetch
95+
- `AddFilter(key, value)` - add filters incrementally to existing Query
96+
- `All()``ServerObjects` - fetch all matching objects
97+
- `One()``*ServerObject` - fetch exactly one (errors if 0 or >1 results)
98+
99+
### ServerObject Methods
100+
101+
- `Get(attr)` returns `any` (auto-converts JSON float64 to int)
102+
- `GetString(attr)` returns `string`
103+
- `Set(key, value)` tracks changes; returns error if attribute doesn't exist
104+
- `Delete()` marks for deletion (doesn't actually delete until commit)
105+
- `Rollback()` discards all local changes
106+
- `Commit()` sends changes to API and clears oldValues on success
107+
108+
### Filter Functions
109+
110+
Implemented in `filters.go`:
111+
- `Regexp(pattern string)` - regex matching
112+
- `Not(value)` - negation (works with values or other filters)
113+
- `Any(values...)` - OR semantics (match any of)
114+
- `All(values...)` - AND semantics (match all of)
115+
- `Empty()` - checks for empty/nil values
116+
117+
These can be nested: `Not(Any(Regexp("^test.*"), Regexp("^dev.*")))`
118+
119+
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.
120+
121+
### Testing Patterns
122+
123+
Tests use testify/assert and testify/require. The codebase has table-driven tests (see `parse_test.go`).
124+
125+
When writing tests:
126+
- Use `require.NoError` for setup that must succeed
127+
- Use `assert.Error` for expected failures with descriptive messages
128+
- Table-driven tests should have descriptive `name` fields
129+
- Go 1.25+ uses `b.Loop()` instead of `for i := 0; i < b.N; i++` in benchmarks
130+
131+
### Linting
132+
133+
The project uses golangci-lint with an extensive linter configuration (`.golangci.yml`). Key points:
134+
- Formatters gci and gofumpt are enabled (imports grouped, strict formatting)
135+
- Some linters (errcheck, perfsprint) are relaxed for `_test.go` files
136+
- Examples directory has relaxed rules
137+
- Run `make linter` to auto-fix issues before committing
138+
- SHA1 usage is intentional (required by protocol) - use `//nolint:gosec` comments
139+
140+
## Configuration Requirements
141+
142+
The client requires these environment variables:
143+
144+
```bash
145+
# Required
146+
export SERVERADMIN_BASE_URL="https://serveradmin.example.com"
147+
148+
# One of these auth methods:
149+
export SERVERADMIN_TOKEN="your-token" # Token-based auth
150+
# OR
151+
export SERVERADMIN_KEY_PATH="/path/to/key" # SSH key file
152+
# OR
153+
export SSH_AUTH_SOCK="/path/to/ssh-agent.sock" # SSH agent (auto-detected)
154+
```
155+
156+
The client fails fast if `SERVERADMIN_BASE_URL` or auth credentials are missing.
157+
158+
## Examples Directory
159+
160+
The `examples/` directory contains standalone programs demonstrating:
161+
- `update_example.go` - Single/batch updates, create, delete, rollback
162+
- `query_example.go` - Query patterns (string vs programmatic, simple vs nested filters)
163+
164+
These use a shorter import alias pattern: `import api "github.com/innogames/serveradmin-go-client/adminapi"`
165+
166+
Examples are excluded from strict linting rules and can ignore errors for brevity.
167+
168+
## Version Compatibility
169+
170+
- Requires Go 1.24+ (per README, using latest Go 1.25 features like `b.Loop()`)
171+
- API version is hardcoded in `config.go` as `version = "4.9.0"`
172+
- The client maintains compatibility with the Python Serveradmin client's behavior (change tracking, JSON comparison logic)

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ build:
66
test:
77
go test ./...
88

9+
test-race:
10+
go test -race ./...
11+
912
test-coverage:
1013
go test -v ./... -coverprofile=coverage.out
1114
go tool cover -html=coverage.out -o coverage.html
1215

1316
linter:
14-
golangci-lint run
17+
golangci-lint run --fix

adminapi/commit.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package adminapi
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
)
8+
9+
// commitRequest is the payload sent to /api/dataset/commit
10+
type commitRequest struct {
11+
Created []map[string]any `json:"created"`
12+
Changed []map[string]any `json:"changed"`
13+
Deleted []any `json:"deleted"`
14+
}
15+
16+
type commitResponse struct {
17+
Status string `json:"status"`
18+
CommitID int `json:"commit_id"`
19+
Type string `json:"type"`
20+
Message string `json:"message"`
21+
}
22+
23+
// Commit commits all changed, created, and deleted objects in a single API call.
24+
func (s ServerObjects) Commit() (int, error) {
25+
commit := buildCommit(s)
26+
27+
commitID, err := sendCommit(commit)
28+
if err != nil {
29+
return 0, err
30+
}
31+
32+
for _, obj := range s {
33+
obj.confirmChanges()
34+
}
35+
36+
return commitID, nil
37+
}
38+
39+
// Rollback reverts all objects to their original state.
40+
func (s ServerObjects) Rollback() {
41+
for _, obj := range s {
42+
obj.Rollback()
43+
}
44+
}
45+
46+
// Set calls Set(key, value) on each ServerObject in the slice.
47+
// If any Set operation fails, all errors are collected and returned
48+
// as a joined error. This allows identifying all problematic objects
49+
// in a single call rather than failing on the first error.
50+
func (s ServerObjects) Set(key string, value any) error {
51+
var errs []error
52+
for i, obj := range s {
53+
if err := obj.Set(key, value); err != nil {
54+
errs = append(errs, fmt.Errorf("object %d (id=%v): %w", i, obj.Get("object_id"), err))
55+
}
56+
}
57+
return errors.Join(errs...)
58+
}
59+
60+
// Delete calls Delete() on each ServerObject in the slice.
61+
// This marks all objects for deletion on the next Commit().
62+
func (s ServerObjects) Delete() {
63+
for _, obj := range s {
64+
obj.Delete()
65+
}
66+
}
67+
68+
// Commit commits this single object's changes to the server.
69+
func (s *ServerObject) Commit() (int, error) {
70+
commit := buildCommit(ServerObjects{s})
71+
commitID, err := sendCommit(commit)
72+
if err != nil {
73+
return 0, err
74+
}
75+
76+
s.confirmChanges()
77+
return commitID, nil
78+
}
79+
80+
func buildCommit(objects ServerObjects) commitRequest {
81+
commit := commitRequest{
82+
Created: []map[string]any{},
83+
Changed: []map[string]any{},
84+
Deleted: []any{},
85+
}
86+
87+
for _, obj := range objects {
88+
switch obj.CommitState() {
89+
case "created":
90+
commit.Created = append(commit.Created, obj.attributes)
91+
case "changed":
92+
commit.Changed = append(commit.Changed, obj.serializeChanges())
93+
case "deleted":
94+
commit.Deleted = append(commit.Deleted, obj.Get("object_id"))
95+
}
96+
}
97+
98+
return commit
99+
}
100+
101+
func sendCommit(commit commitRequest) (int, error) {
102+
resp, err := sendRequest(apiEndpointCommit, commit)
103+
if err != nil {
104+
return 0, err
105+
}
106+
defer resp.Body.Close()
107+
108+
var result commitResponse
109+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
110+
return 0, fmt.Errorf("failed to decode commit response: %w", err)
111+
}
112+
113+
if result.Status == "error" {
114+
return 0, fmt.Errorf("commit failed: %s", result.Message)
115+
}
116+
117+
return result.CommitID, nil
118+
}

0 commit comments

Comments
 (0)