diff --git a/README.md b/README.md index bbd63c3..d7897c6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Serveradmin is a central server database management system used by InnoGames. Th - Retrieve server attributes and metadata - Authenticate using SSH keys or security tokens - Use as both a library and command-line tool -- Soon: Create and modify server objects +- Create, modify, and delete server objects with change tracking ## Installation @@ -26,47 +26,87 @@ The client requires configuration to connect to your Serveradmin instance. Creat ```bash export SERVERADMIN_BASE_URL="https://your-serveradmin-instance.com" -export SERVERADMIN_AUTH_TOKEN="your-auth-token" -or have a SSH_AUTH_SOCKET available +export SERVERADMIN_TOKEN="your-auth-token" +# or set SERVERADMIN_KEY_PATH to an SSH private key, or have SSH_AUTH_SOCK available ``` +These variables are read only by `adminapi.NewClientFromEnv()`. The primary +`NewClient(Config{...})` constructor reads no environment variables. + ## Usage -### As a Go Library +### As a Go Library (recommended: explicit Client) + +The recommended entry point is an explicit, per-instance `Client` built with +`NewClient(Config{...})`. A `Client` reads no environment variables, holds its +own `*http.Client`, and is safe for concurrent use — so a single process can +serve several targets with different URLs/credentials at once. Every network +call takes a `context.Context`, giving the caller control over cancellation and +timeouts. ```go package main import ( + "context" "fmt" + "time" + "github.com/innogames/serveradmin-go-client/adminapi" ) func main() { - // Create a query - query, err := adminapi.FromQuery("hostname=web*") + // Construct a client with explicit configuration — no env reads, no globals. + client, err := adminapi.NewClient(adminapi.Config{ + BaseURL: "https://your-serveradmin-instance.com", + Token: "your-token", // or: SSHSigner / KeyPath for SSH auth + Timeout: 10 * time.Second, + }) if err != nil { panic(err) } - // Set attributes to retrieve - query.SetAttributes([]string{"hostname", "ip", "environment"}) + ctx := context.Background() - // Execute query - servers, err := query.All() + // Build a query bound to this client. + query, err := client.FromQuery("hostname=web*") + if err != nil { + panic(err) + } + query.SetAttributes("hostname", "intern_ip", "environment") + + // Execute the query. + servers, err := query.All(ctx) if err != nil { panic(err) } - // Process results for _, server := range servers { - hostname := server.Get("hostname") - ip := server.Get("ip") - fmt.Printf("Server: %s (%s)\n", hostname, ip) + fmt.Printf("Server: %s (%s)\n", server.GetString("hostname"), server.GetString("intern_ip")) } } ``` +Authentication is selected **explicitly** from `Config`, in the order +`SSHSigner` → `KeyPath` → `Token`. There is no ambient environment precedence, so +an inherited `SSH_AUTH_SOCK` can never silently override an explicitly configured +token. + +For deployments that are configured entirely through environment variables (for +example the CLI), `adminapi.NewClientFromEnv()` builds a `Client` from the +`SERVERADMIN_*` variables, applying the precedence +`SERVERADMIN_KEY_PATH` → `SSH_AUTH_SOCK` → `SERVERADMIN_TOKEN`. + +All entry points hang off a `Client` (`client.NewQuery`, `client.FromQuery`, +`client.NewObject`, `client.CallAPI`) and every network call +(`All`, `One`, `Count`, `Commit`) takes a `context.Context`. + +#### Typed attribute getters + +`Get` returns `any` and converts JSON numbers to `int` (lossy). When you need to +preserve numeric type, use the typed getters: `GetInt`, `GetFloat`, `GetBool` +(alongside the existing `GetString` and `GetMulti`). + ### As a CLI Tool ```bash @@ -94,17 +134,25 @@ The client supports Serveradmin's query language for filtering servers: ### SSH Key Authentication (Recommended) ```go -// The client will automatically use SSH keys from: -// - SSH agent -// - ~/.ssh/id_rsa (or other default keys) -// - Path specified in SERVERADMIN_SSH_KEY_PATH +// Explicit client: provide a pre-built signer or a key file path. +client, _ := adminapi.NewClient(adminapi.Config{ + BaseURL: "https://your-serveradmin-instance.com", + KeyPath: "/path/to/id_ed25519", // or SSHSigner: +}) + +// Env path via NewClientFromEnv(): SERVERADMIN_KEY_PATH, or an SSH agent via SSH_AUTH_SOCK. ``` ### Security Token Authentication ```go -// Set SERVERADMIN_AUTH_TOKEN environment variable -// or configure in your config file +// Explicit client. +client, _ := adminapi.NewClient(adminapi.Config{ + BaseURL: "https://your-serveradmin-instance.com", + Token: "your-token", +}) + +// Env path via NewClientFromEnv(): set SERVERADMIN_TOKEN. ``` ## Examples @@ -112,41 +160,42 @@ The client supports Serveradmin's query language for filtering servers: ### Creating a New Server ```go -// Create a new VM server -newServer, err := adminapi.NewServer("vm") +// NewObject fetches defaults, applies attributes, commits, and re-queries to +// populate object_id — all bound to the client and the provided context. +newServer, err := client.NewObject(ctx, "vm", adminapi.Attributes{ + "hostname": "newwebserver", + "environment": "staging", +}) if err != nil { panic(err) } - -// Set attributes -newServer.Set("hostname", "newwebserver") -newServer.Set("environment", "staging") -newServer.Set("ip", "192.168.1.100") - -// Commit to Serveradmin -err = newServer.Commit() +fmt.Printf("Created %s (object_id %d)\n", newServer.GetString("hostname"), newServer.ObjectID()) ``` ### Modifying Existing Servers ```go -// Find and modify a server -query, _ := adminapi.FromQuery("hostname=webserver01") -server := query.One() +// Find and modify a server. +query, _ := client.FromQuery("hostname=webserver01") +server, err := query.One(ctx) +if err != nil { + panic(err) +} -// Update attributes -server.Set("backup_disabled", "true") -server.Set("maintenance_mode", "true") +// Update attributes. +server.Set("backup_disabled", true) -// Commit changes -server.Commit() +// Commit changes. +if _, err := server.Commit(ctx); err != nil { + panic(err) +} ``` ### Calling API Functions ```go -// Call a remote API function by group and function name -result, err := adminapi.CallAPI("ip", "get_free", map[string]any{"network": "internal"}) +// Call a remote API function by group and function name. +result, err := client.CallAPI(ctx, "ip", "get_free", map[string]any{"network": "internal"}) if err != nil { panic(err) } diff --git a/adminapi/call.go b/adminapi/call.go index 4aea539..c34adbd 100644 --- a/adminapi/call.go +++ b/adminapi/call.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "fmt" ) @@ -20,9 +21,9 @@ type callResponse struct { Message string `json:"message"` } -// CallAPI calls a remote API function on the Serveradmin server. +// CallAPI calls a remote API function on the Serveradmin server using this client. // It takes a function group, function name, and keyword arguments as a map. -func CallAPI(group, function string, args map[string]any) (any, error) { +func (c *Client) CallAPI(ctx context.Context, group, function string, args map[string]any) (any, error) { req := callRequest{ Group: group, Name: function, @@ -30,7 +31,7 @@ func CallAPI(group, function string, args map[string]any) (any, error) { Kwargs: args, } - resp, err := sendRequest(apiEndpointCall, req) + resp, err := c.sendRequest(ctx, apiEndpointCall, req) if err != nil { return nil, err } diff --git a/adminapi/call_test.go b/adminapi/call_test.go index 528b069..1e33b89 100644 --- a/adminapi/call_test.go +++ b/adminapi/call_test.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "io" "net/http" @@ -23,11 +24,9 @@ func TestCallAPISuccess(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - result, err := CallAPI("ip", "get_free", map[string]any{"network": "internal"}) + result, err := client.CallAPI(context.Background(), "ip", "get_free", map[string]any{"network": "internal"}) require.NoError(t, err) assert.Equal(t, "10.0.0.1", result) @@ -45,11 +44,9 @@ func TestCallAPIError(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - result, err := CallAPI("ip", "nonexistent", map[string]any{}) + result, err := client.CallAPI(context.Background(), "ip", "nonexistent", map[string]any{}) assert.Nil(t, result) require.Error(t, err) assert.Contains(t, err.Error(), "ip.nonexistent") @@ -63,11 +60,9 @@ func TestCallAPIComplexReturnValue(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - result, err := CallAPI("ip", "get_details", map[string]any{"ip": "10.0.0.1"}) + result, err := client.CallAPI(context.Background(), "ip", "get_details", map[string]any{"ip": "10.0.0.1"}) require.NoError(t, err) resultMap, ok := result.(map[string]any) @@ -88,11 +83,9 @@ func TestCallAPINilArgs(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - result, err := CallAPI("system", "ping", nil) + result, err := client.CallAPI(context.Background(), "system", "ping", nil) require.NoError(t, err) assert.Nil(t, result) diff --git a/adminapi/client.go b/adminapi/client.go new file mode 100644 index 0000000..71a37a6 --- /dev/null +++ b/adminapi/client.go @@ -0,0 +1,91 @@ +package adminapi + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// Config holds the explicit, per-instance configuration for a Client. +// +// Authentication is selected explicitly from the fields below, in this order: +// SSHSigner, then KeyPath, then Token. No environment variables are consulted, +// so an ambient SSH_AUTH_SOCK can never override an explicitly configured token. +type Config struct { + // BaseURL is the Serveradmin base URL (required). A trailing "/api" is trimmed. + BaseURL string + + // Token enables security-token authentication (HMAC-SHA1). + Token string + + // SSHSigner enables SSH-signature authentication using a pre-built signer. + // This takes precedence over KeyPath and Token. + SSHSigner ssh.Signer + + // KeyPath is the path to a private key file used for SSH-signature + // authentication. Used only when SSHSigner is nil. + KeyPath string + + // HTTPClient is the HTTP client used for all requests. If nil, a dedicated + // client is created using Timeout. + HTTPClient *http.Client + + // Timeout is applied to the generated HTTP client. Ignored when HTTPClient + // is provided. A zero value means no timeout. + Timeout time.Duration +} + +// Client is a per-instance Serveradmin API client. It carries its own +// configuration and *http.Client and is safe for concurrent use: all fields are +// set once at construction and never mutated afterwards. +type Client struct { + baseURL string + authToken []byte + sshSigner ssh.Signer + httpClient *http.Client +} + +// NewClient builds a Client from an explicit Config. It performs no environment +// reads and keeps no global state, so multiple clients with different base URLs +// and credentials can coexist and be used concurrently in the same process. +func NewClient(cfg Config) (*Client, error) { + if cfg.BaseURL == "" { + return nil, errors.New("config: BaseURL is required") + } + + c := &Client{ + baseURL: strings.TrimSuffix(cfg.BaseURL, "/api"), + } + + switch { + case cfg.SSHSigner != nil: + c.sshSigner = cfg.SSHSigner + case cfg.KeyPath != "": + keyBytes, err := os.ReadFile(cfg.KeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read private key from %s: %w", cfg.KeyPath, err) + } + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + c.sshSigner = signer + case cfg.Token != "": + c.authToken = []byte(cfg.Token) + default: + return nil, errors.New("config: no authentication method configured: set Token, SSHSigner or KeyPath") + } + + if cfg.HTTPClient != nil { + c.httpClient = cfg.HTTPClient + } else { + c.httpClient = &http.Client{Timeout: cfg.Timeout} + } + + return c, nil +} diff --git a/adminapi/client_test.go b/adminapi/client_test.go new file mode 100644 index 0000000..0a1eebd --- /dev/null +++ b/adminapi/client_test.go @@ -0,0 +1,219 @@ +package adminapi + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +// mustClient builds a token-authenticated Client pointing at baseURL, failing +// the test if construction fails. +func mustClient(t *testing.T, baseURL string) *Client { + t.Helper() + c, err := NewClient(Config{BaseURL: baseURL, Token: "test-token"}) + require.NoError(t, err) + return c +} + +func TestNewClientValidation(t *testing.T) { + t.Run("missing BaseURL", func(t *testing.T) { + _, err := NewClient(Config{Token: "tok"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseURL is required") + }) + + t.Run("no auth method", func(t *testing.T) { + _, err := NewClient(Config{BaseURL: "https://example.com"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no authentication method configured") + }) + + t.Run("token auth", func(t *testing.T) { + c, err := NewClient(Config{BaseURL: "https://example.com", Token: "tok"}) + require.NoError(t, err) + assert.Equal(t, "tok", string(c.authToken)) + assert.Nil(t, c.sshSigner) + }) + + t.Run("key path auth", func(t *testing.T) { + c, err := NewClient(Config{BaseURL: "https://example.com", KeyPath: "testdata/test.key"}) + require.NoError(t, err) + assert.NotNil(t, c.sshSigner) + assert.Empty(t, c.authToken) + }) + + t.Run("explicit signer auth", func(t *testing.T) { + keyBytes, err := os.ReadFile("testdata/test.key") + require.NoError(t, err) + signer, err := ssh.ParsePrivateKey(keyBytes) + require.NoError(t, err) + + c, err := NewClient(Config{BaseURL: "https://example.com", SSHSigner: signer}) + require.NoError(t, err) + assert.Equal(t, signer, c.sshSigner) + }) + + t.Run("signer takes precedence over token", func(t *testing.T) { + keyBytes, err := os.ReadFile("testdata/test.key") + require.NoError(t, err) + signer, err := ssh.ParsePrivateKey(keyBytes) + require.NoError(t, err) + + c, err := NewClient(Config{BaseURL: "https://example.com", SSHSigner: signer, Token: "tok"}) + require.NoError(t, err) + assert.NotNil(t, c.sshSigner) + assert.Empty(t, c.authToken, "token must be ignored when a signer is set") + }) + + t.Run("trims /api suffix", func(t *testing.T) { + c, err := NewClient(Config{BaseURL: "https://example.com/api", Token: "tok"}) + require.NoError(t, err) + assert.Equal(t, "https://example.com", c.baseURL) + }) + + t.Run("custom http client honored", func(t *testing.T) { + custom := &http.Client{Timeout: 7 * time.Second} + c, err := NewClient(Config{BaseURL: "https://example.com", Token: "tok", HTTPClient: custom}) + require.NoError(t, err) + assert.Same(t, custom, c.httpClient) + }) + + t.Run("timeout applied to generated client", func(t *testing.T) { + c, err := NewClient(Config{BaseURL: "https://example.com", Token: "tok", Timeout: 3 * time.Second}) + require.NoError(t, err) + assert.Equal(t, 3*time.Second, c.httpClient.Timeout) + }) +} + +// TestClientSendsOwnAuthHeaders verifies a token client signs requests with its +// own token and never consults global/env configuration. +func TestClientSendsOwnAuthHeaders(t *testing.T) { + var gotAppID, gotToken, gotUserAgent, gotTimestamp string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAppID = r.Header.Get("X-Application") + gotToken = r.Header.Get("X-SecurityToken") + gotUserAgent = r.Header.Get("User-Agent") + gotTimestamp = r.Header.Get("X-Timestamp") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success","result":[{"object_id":1,"hostname":"a.local"}]}`)) + })) + defer server.Close() + + client, err := NewClient(Config{BaseURL: server.URL, Token: "secret-token"}) + require.NoError(t, err) + + q := client.NewQuery(Filters{"hostname": "a.local"}) + servers, err := q.All(context.Background()) + require.NoError(t, err) + require.Len(t, servers, 1) + + assert.Equal(t, calcAppID([]byte("secret-token")), gotAppID) + assert.NotEmpty(t, gotToken) + assert.Equal(t, userAgent, gotUserAgent) + assert.NotEmpty(t, gotTimestamp) +} + +// TestTwoClientsParallel is the acceptance test: a single process holds two +// clients with different BaseURL/Token and queries both concurrently. Each +// server must only ever see its own token's application id and return its own +// data. Run with -race to confirm there is no shared mutable state. +func TestTwoClientsParallel(t *testing.T) { + newTarget := func(hostname, token string) (*httptest.Server, *Client) { + wantAppID := calcAppID([]byte(token)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Every request to this server must carry this server's token. + assert.Equal(t, wantAppID, r.Header.Get("X-Application")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success","result":[{"object_id":1,"hostname":"` + hostname + `"}]}`)) + })) + client, err := NewClient(Config{BaseURL: srv.URL, Token: token}) + require.NoError(t, err) + return srv, client + } + + srvA, clientA := newTarget("a.example.com", "token-a") + defer srvA.Close() + srvB, clientB := newTarget("b.example.com", "token-b") + defer srvB.Close() + + const iterations = 25 + var wg sync.WaitGroup + run := func(client *Client, wantHostname string) { + defer wg.Done() + for range iterations { + q := client.NewQuery(Filters{"hostname": wantHostname}) + servers, err := q.All(context.Background()) + if assert.NoError(t, err) && assert.Len(t, servers, 1) { + assert.Equal(t, wantHostname, servers[0].GetString("hostname")) + } + } + } + + wg.Add(2) + go run(clientA, "a.example.com") + go run(clientB, "b.example.com") + wg.Wait() +} + +func TestClientContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success","result":[]}`)) + })) + defer server.Close() + + client, err := NewClient(Config{BaseURL: server.URL, Token: "tok"}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before issuing the request + + q := client.NewQuery(Filters{"hostname": "a.local"}) + _, err = q.All(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestTypedGetters(t *testing.T) { + obj := &ServerObject{ + attributes: Attributes{ + "num_cpu": float64(4), // integers arrive as float64 from JSON + "load_avg": float64(1.5), // genuine float + "int_field": 7, // already an int + "enabled": true, + "disabled": false, + "hostname": "web01", + "missing_int": nil, + }, + oldValues: Attributes{}, + } + + // GetInt truncates floats and handles native ints. + assert.Equal(t, 4, obj.GetInt("num_cpu")) + assert.Equal(t, 1, obj.GetInt("load_avg")) + assert.Equal(t, 7, obj.GetInt("int_field")) + assert.Equal(t, 0, obj.GetInt("hostname")) + assert.Equal(t, 0, obj.GetInt("absent")) + + // GetFloat preserves the fractional part that Get/GetInt would discard. + assert.InEpsilon(t, 1.5, obj.GetFloat("load_avg"), 1e-9) + assert.InEpsilon(t, 4.0, obj.GetFloat("num_cpu"), 1e-9) + assert.InEpsilon(t, 7.0, obj.GetFloat("int_field"), 1e-9) + assert.InDelta(t, 0.0, obj.GetFloat("hostname"), 1e-9) + + // GetBool type-asserts. + assert.True(t, obj.GetBool("enabled")) + assert.False(t, obj.GetBool("disabled")) + assert.False(t, obj.GetBool("missing_int")) + + // Get still performs the legacy lossy float64->int conversion. + assert.Equal(t, 1, obj.Get("load_avg")) +} diff --git a/adminapi/commit.go b/adminapi/commit.go index b3697a7..9957f31 100644 --- a/adminapi/commit.go +++ b/adminapi/commit.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "errors" "fmt" @@ -21,10 +22,15 @@ type commitResponse struct { } // Commit commits all changed, created, and deleted objects in a single API call. -func (s ServerObjects) Commit() (int, error) { +func (s ServerObjects) Commit(ctx context.Context) (int, error) { + client, err := resolveObjectsClient(s) + if err != nil { + return 0, err + } + commit := buildCommit(s) - commitID, err := sendCommit(commit) + commitID, err := client.sendCommit(ctx, commit) if err != nil { return 0, err } @@ -66,9 +72,14 @@ func (s ServerObjects) Delete() { } // Commit commits this single object's changes to the server. -func (s *ServerObject) Commit() (int, error) { +func (s *ServerObject) Commit(ctx context.Context) (int, error) { + client, err := s.resolveClient() + if err != nil { + return 0, err + } + commit := buildCommit(ServerObjects{s}) - commitID, err := sendCommit(commit) + commitID, err := client.sendCommit(ctx, commit) if err != nil { return 0, err } @@ -77,6 +88,25 @@ func (s *ServerObject) Commit() (int, error) { return commitID, nil } +// resolveClient returns the object's bound client. +func (s *ServerObject) resolveClient() (*Client, error) { + if s.client == nil { + return nil, errors.New("object is not bound to a client; obtain it via a Client query or Client.NewObject") + } + return s.client, nil +} + +// resolveObjectsClient returns the client bound to the objects. All objects in a +// set are expected to originate from the same client. +func resolveObjectsClient(objects ServerObjects) (*Client, error) { + for _, obj := range objects { + if obj.client != nil { + return obj.client, nil + } + } + return nil, errors.New("no object is bound to a client; obtain them via a Client query") +} + func buildCommit(objects ServerObjects) commitRequest { commit := commitRequest{ Created: []Attributes{}, @@ -100,8 +130,8 @@ func buildCommit(objects ServerObjects) commitRequest { return commit } -func sendCommit(commit commitRequest) (int, error) { - resp, err := sendRequest(apiEndpointCommit, commit) +func (c *Client) sendCommit(ctx context.Context, commit commitRequest) (int, error) { + resp, err := c.sendRequest(ctx, apiEndpointCommit, commit) if err != nil { return 0, err } diff --git a/adminapi/commit_test.go b/adminapi/commit_test.go index 0d10082..dee18bd 100644 --- a/adminapi/commit_test.go +++ b/adminapi/commit_test.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "io" "net/http" @@ -23,16 +24,15 @@ func TestCommitSingle(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) obj := &ServerObject{ + client: client, attributes: Attributes{"hostname": "new.local", "object_id": float64(42)}, oldValues: Attributes{"hostname": "old.local"}, } - commitID, err := obj.Commit() + commitID, err := obj.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 123, commitID) @@ -58,9 +58,7 @@ func TestCommitResultSet(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) objects := ServerObjects{ { @@ -78,7 +76,11 @@ func TestCommitResultSet(t *testing.T) { }, } - commitID, err := objects.Commit() + for _, o := range objects { + o.client = client + } + + commitID, err := objects.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 456, commitID) @@ -193,9 +195,7 @@ func TestServerObjectsSetWithCommit(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "testtoken") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) objects := ServerObjects{ { @@ -213,7 +213,11 @@ func TestServerObjectsSetWithCommit(t *testing.T) { require.NoError(t, err) // Commit should work - commitID, err := objects.Commit() + for _, o := range objects { + o.client = client + } + + commitID, err := objects.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 999, commitID) diff --git a/adminapi/config.go b/adminapi/config.go index 4ca91b4..35c4cb4 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -1,13 +1,12 @@ package adminapi import ( + "context" "crypto/rand" "errors" "fmt" "net" "os" - "strings" - "sync" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -18,63 +17,71 @@ const ( userAgent = "Adminapi Go Client " + version ) -type config struct { - baseURL string - apiVersion string - authToken []byte - sshSigner ssh.Signer +// NewClientFromEnv builds a Client from the SERVERADMIN_* environment variables, +// applying the legacy auth precedence SERVERADMIN_KEY_PATH > SSH_AUTH_SOCK > +// SERVERADMIN_TOKEN. It is a convenience for env-configured deployments (such as +// the CLI); prefer NewClient with an explicit Config when you control the +// configuration, especially in multi-tenant processes. +func NewClientFromEnv() (*Client, error) { + cfg, err := configFromEnv() + if err != nil { + return nil, err + } + return NewClient(cfg) } -// getConfig returns the configuration for the API client. Loading config only once -var getConfig = sync.OnceValues(loadConfig) - -// loadConfig returns the configuration for the API client -var loadConfig = func() (config, error) { - cfg := config{ - apiVersion: version, - } +// configFromEnv builds a Config from the SERVERADMIN_* environment variables. +// +// This is the only place that applies the legacy ambient auth precedence: +// SERVERADMIN_KEY_PATH > SSH_AUTH_SOCK > SERVERADMIN_TOKEN. The SSH agent +// (SSH_AUTH_SOCK) is resolved here into a concrete ssh.Signer, as NewClient +// itself does not consult the agent. +func configFromEnv() (Config, error) { + cfg := Config{} baseURL := os.Getenv("SERVERADMIN_BASE_URL") if baseURL == "" { return cfg, errors.New("env var SERVERADMIN_BASE_URL not set") } - cfg.baseURL = strings.TrimSuffix(baseURL, "/api") + cfg.BaseURL = baseURL 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) - } - signer, err := ssh.ParsePrivateKey(keyBytes) - if err != nil { - return cfg, fmt.Errorf("failed to parse private key: %w", err) - } - cfg.sshSigner = signer + cfg.KeyPath = privateKeyPath } else if authSock, ok := os.LookupEnv("SSH_AUTH_SOCK"); ok && authSock != "" { - sock, err := net.Dial("unix", authSock) - if err != nil { - return cfg, fmt.Errorf("failed to connect to SSH agent: %w", err) - } - signers, err := agent.NewClient(sock).Signers() + signer, err := agentSigner(authSock) if err != nil { - return cfg, fmt.Errorf("failed to get SSH agent signers: %w", err) - } - for _, signer := range signers { - _, err := signer.Sign(rand.Reader, []byte("test")) - if err == nil { - cfg.sshSigner = signer - break - } + return cfg, err } + cfg.SSHSigner = signer } - if cfg.sshSigner == nil { - cfg.authToken = []byte(os.Getenv("SERVERADMIN_TOKEN")) + if cfg.KeyPath == "" && cfg.SSHSigner == nil { + cfg.Token = os.Getenv("SERVERADMIN_TOKEN") } - if len(cfg.authToken) == 0 && cfg.sshSigner == nil { + if cfg.Token == "" && cfg.KeyPath == "" && cfg.SSHSigner == nil { return cfg, errors.New("no authentication method found: set SERVERADMIN_TOKEN/SERVERADMIN_KEY_PATH/SSH_AUTH_SOCK") } return cfg, nil } + +// agentSigner connects to the SSH agent at authSock and returns the first signer +// that can produce a signature. +func agentSigner(authSock string) (ssh.Signer, error) { + var dialer net.Dialer + sock, err := dialer.DialContext(context.Background(), "unix", authSock) + if err != nil { + return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) + } + signers, err := agent.NewClient(sock).Signers() + if err != nil { + return nil, fmt.Errorf("failed to get SSH agent signers: %w", err) + } + for _, signer := range signers { + if _, err := signer.Sign(rand.Reader, []byte("test")); err == nil { + return signer, nil + } + } + return nil, errors.New("no usable signer found in SSH agent") +} diff --git a/adminapi/config_test.go b/adminapi/config_test.go index da52814..ea839a9 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -2,26 +2,16 @@ 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 +func TestConfigFromEnv(t *testing.T) { + // without SERVERADMIN_BASE_URL set t.Setenv("SERVERADMIN_BASE_URL", "") - _, err := loadConfig() + _, err := configFromEnv() require.Error(t, err, "env var SERVERADMIN_BASE_URL not set") // spawn mocked serveradmin server @@ -35,33 +25,43 @@ func TestLoadConfig(t *testing.T) { t.Setenv("SERVERADMIN_KEY_PATH", "") t.Setenv("SERVERADMIN_TOKEN", "jolo") - resetConfig() - cfg, err := loadConfig() + cfg, err := configFromEnv() + require.NoError(t, err) + assert.Nil(t, cfg.SSHSigner) + assert.Empty(t, cfg.KeyPath) + assert.Equal(t, "jolo", cfg.Token) + client, err := NewClient(cfg) require.NoError(t, err) - assert.Nil(t, cfg.sshSigner) - assert.Equal(t, "jolo", string(cfg.authToken)) + assert.Nil(t, client.sshSigner) + assert.Equal(t, "jolo", string(client.authToken)) }) 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() + cfg, err := configFromEnv() + require.NoError(t, err) + assert.Equal(t, "testdata/test.key", cfg.KeyPath) + assert.Empty(t, cfg.Token) + client, err := NewClient(cfg) require.NoError(t, err) - assert.NotNil(t, cfg) - assert.Empty(t, cfg.authToken) + assert.NotNil(t, client.sshSigner) + assert.Empty(t, client.authToken) }) - t.Run("load invalid private Key", func(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() + cfg, err := configFromEnv() + require.NoError(t, err) - assert.Error(t, err, "failed to read private key from testdata/nope.key: open testdata/nope.key: no such file or directory") + // The file is read and parsed by NewClient, so the error surfaces there. + _, err = NewClient(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read private key from testdata/nope.key") }) } diff --git a/adminapi/create_object.go b/adminapi/create_object.go index 1c77359..0075a89 100644 --- a/adminapi/create_object.go +++ b/adminapi/create_object.go @@ -1,20 +1,22 @@ package adminapi import ( + "context" "encoding/json" "fmt" "net/url" ) -// NewObject creates a new server object with the given attributes, commits it, -// and returns the fully populated object with a server-assigned object_id. -// The attributes map must include "hostname". -func NewObject(serverType string, attributes Attributes) (*ServerObject, error) { +// NewObject creates a new server object with the given attributes using this +// client, commits it, and returns the fully populated object with a +// server-assigned object_id. The attributes map must include "hostname". +func (c *Client) NewObject(ctx context.Context, serverType string, attributes Attributes) (*ServerObject, error) { if !attributes.Has("hostname") { return nil, fmt.Errorf("attributes must include %q: %w", "hostname", ErrUnknownAttribute) } server := &ServerObject{ + client: c, oldValues: Attributes{}, } @@ -23,7 +25,7 @@ func NewObject(serverType string, attributes Attributes) (*ServerObject, error) params.Add("servertype", serverType) fullURL := apiEndpointNewObject + "?" + params.Encode() - resp, err := sendRequest(fullURL, nil) + resp, err := c.sendRequest(ctx, fullURL, nil) if err != nil { return nil, err } @@ -48,13 +50,13 @@ func NewObject(serverType string, attributes Attributes) (*ServerObject, error) } // Commit the new object - if _, err := server.Commit(); err != nil { + if _, err := server.Commit(ctx); err != nil { return nil, fmt.Errorf("committing new object: %w", err) } // Re-query to get the server-assigned object_id - q := NewQuery(Filters{"hostname": attributes["hostname"]}) - created, err := q.One() + q := c.NewQuery(Filters{"hostname": attributes["hostname"]}) + created, err := q.One(ctx) if err != nil { return nil, fmt.Errorf("re-querying created object: %w", err) } diff --git a/adminapi/create_object_test.go b/adminapi/create_object_test.go index f326aae..4ceed36 100644 --- a/adminapi/create_object_test.go +++ b/adminapi/create_object_test.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -94,11 +95,9 @@ func TestNewObject(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "test-token-1234") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - obj, err := NewObject(tt.serverType, tt.attributes) + obj, err := client.NewObject(context.Background(), tt.serverType, tt.attributes) require.NoError(t, err) require.NotNil(t, obj) @@ -120,7 +119,8 @@ func TestNewObject(t *testing.T) { } func TestNewObject_MissingHostname(t *testing.T) { - obj, err := NewObject("vm", Attributes{"environment": "dev"}) + client := mustClient(t, "https://example.com") + obj, err := client.NewObject(context.Background(), "vm", Attributes{"environment": "dev"}) require.Error(t, err) assert.Nil(t, obj) @@ -141,11 +141,9 @@ func TestNewObject_UnknownAttribute(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "test-token-1234") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - obj, err := NewObject("vm", Attributes{ + obj, err := client.NewObject(context.Background(), "vm", Attributes{ "hostname": "test.local", "nonexistent_field": "value", }) @@ -162,11 +160,9 @@ func TestNewObject_HTTPError(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "test-token-1234") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - obj, err := NewObject("invalid-type", Attributes{"hostname": "test.local"}) + obj, err := client.NewObject(context.Background(), "invalid-type", Attributes{"hostname": "test.local"}) require.Error(t, err) assert.Nil(t, obj) @@ -193,11 +189,9 @@ func TestNewObject_CommitFailure(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "test-token-1234") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - obj, err := NewObject("vm", Attributes{"hostname": "test.local"}) + obj, err := client.NewObject(context.Background(), "vm", Attributes{"hostname": "test.local"}) require.Error(t, err) assert.Nil(t, obj) @@ -231,11 +225,9 @@ func TestNewObject_CommitPayload(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "test-token-1234") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - _, err := NewObject("vm", Attributes{ + _, err := client.NewObject(context.Background(), "vm", Attributes{ "hostname": "test.local", "project": "admin", }) diff --git a/adminapi/query.go b/adminapi/query.go index e5acffc..6e011da 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -1,13 +1,16 @@ package adminapi import ( + "context" "encoding/json" + "errors" "fmt" "slices" ) // Query is a struct to build a query to the SA API type Query struct { + client *Client filters Filters restrictedAttributes []string orderBy string @@ -24,24 +27,33 @@ func (a Attributes) Has(key string) bool { return ok } -// FromQuery creates a new Query object from a query string -func FromQuery(query string) (Query, error) { - filters, err := ParseQuery(query) - if err != nil { - return Query{}, fmt.Errorf("parsing query %s: %w", query, err) - } +// FromQuery creates a new Query object from a query string, bound to this client. +func (c *Client) FromQuery(query string) (Query, error) { + return newQueryFromString(c, query) +} - return NewQuery(filters), nil +// NewQuery initializes a new query bound to this client. +func (c *Client) NewQuery(filters Filters) Query { + return newQuery(c, filters) } -// NewQuery initialize a new query which loads data from SA if needed -func NewQuery(filters Filters) Query { +func newQuery(client *Client, filters Filters) Query { return Query{ + client: client, filters: filters, restrictedAttributes: []string{"object_id", "hostname"}, } } +func newQueryFromString(client *Client, query string) (Query, error) { + filters, err := ParseQuery(query) + if err != nil { + return Query{}, fmt.Errorf("parsing query %s: %w", query, err) + } + + return newQuery(client, filters), nil +} + // SetAttributes replaces the list of attributes to fetch from the API func (q *Query) SetAttributes(attributes ...string) { q.restrictedAttributes = attributes @@ -63,8 +75,8 @@ func (q *Query) AddFilter(attribute string, filter any) { } // Count matching SA objects -func (q *Query) Count() (int, error) { - err := q.load() +func (q *Query) Count(ctx context.Context) (int, error) { + err := q.load(ctx) if err != nil { return 0, err } @@ -73,8 +85,8 @@ func (q *Query) Count() (int, error) { } // All returns all matching SA objects -func (q *Query) All() (ServerObjects, error) { - err := q.load() +func (q *Query) All(ctx context.Context) (ServerObjects, error) { + err := q.load(ctx) if err != nil { return nil, err } @@ -84,8 +96,8 @@ 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. // Returns ErrNoResults if no objects match, or a wrapped ErrMultipleResults if more than one matches. -func (q *Query) One() (*ServerObject, error) { - err := q.load() +func (q *Query) One(ctx context.Context) (*ServerObject, error) { + err := q.load(ctx) if err != nil { return nil, err } @@ -100,11 +112,16 @@ func (q *Query) One() (*ServerObject, error) { } } -func (q *Query) load() error { +func (q *Query) load(ctx context.Context) error { if q.loaded { return nil } + client, err := q.resolveClient() + if err != nil { + return err + } + // always add "object_id" as attribute as we need it to modify the object if !slices.Contains(q.restrictedAttributes, "object_id") { q.restrictedAttributes = append(q.restrictedAttributes, "object_id") @@ -116,7 +133,7 @@ func (q *Query) load() error { OrderBy: q.orderBy, // todo fix serverside ordering in API or do it on client side } - resp, err := sendRequest(apiEndpointQuery, request) + resp, err := client.sendRequest(ctx, apiEndpointQuery, request) if err != nil { return fmt.Errorf("querying %s: %w", apiEndpointQuery, err) } @@ -127,10 +144,12 @@ func (q *Query) load() error { return fmt.Errorf("decoding query response: %w", err) } - // map attribute map into ServerObject objects + // map attribute map into ServerObject objects, stamping the client so later + // Commit calls reuse the same configuration. q.serverObjects = make(ServerObjects, len(respServer.Result)) for idx, object := range respServer.Result { q.serverObjects[idx] = &ServerObject{ + client: client, attributes: object, oldValues: Attributes{}, } @@ -140,6 +159,14 @@ func (q *Query) load() error { return nil } +// resolveClient returns the query's bound client. +func (q *Query) resolveClient() (*Client, error) { + if q.client == nil { + return nil, errors.New("query is not bound to a client; use Client.NewQuery or Client.FromQuery") + } + return q.client, nil +} + // like {"Filters": {"hostname": {"Regexp": "foo.local.*"}}, "restrict": ["hostname", "object_id"]} type queryRequest struct { Filters map[string]any `json:"filters"` diff --git a/adminapi/query_test.go b/adminapi/query_test.go index 7f7e503..06e7e85 100644 --- a/adminapi/query_test.go +++ b/adminapi/query_test.go @@ -8,7 +8,7 @@ import ( ) func TestSetAttributes(t *testing.T) { - q := NewQuery(Filters{}) + q := mustClient(t, "https://example.com").NewQuery(Filters{}) // Default attributes assert.Equal(t, []string{"object_id", "hostname"}, q.restrictedAttributes) @@ -23,7 +23,7 @@ func TestSetAttributes(t *testing.T) { } func TestAddAttributes(t *testing.T) { - q := NewQuery(Filters{}) + q := mustClient(t, "https://example.com").NewQuery(Filters{}) // AddAttributes appends to defaults q.AddAttributes("memory") @@ -35,7 +35,7 @@ func TestAddAttributes(t *testing.T) { } func TestFilters(t *testing.T) { - q := NewQuery(Filters{ + q := mustClient(t, "https://example.com").NewQuery(Filters{ "hostname": NotEmpty(), "num_cpu": Regexp(".*GB"), "hypervisor": StartsWith("datacenter-x-"), @@ -49,7 +49,7 @@ func TestFilters(t *testing.T) { } func TestFromQuery(t *testing.T) { - q, err := FromQuery("hostname=not(empty()) num_cpu=regexp(.*GB)") + q, err := mustClient(t, "https://example.com").FromQuery("hostname=not(empty()) num_cpu=regexp(.*GB)") require.NoError(t, err) q.AddFilter("instance", 1) q.OrderBy("num_cpu") @@ -62,7 +62,7 @@ func TestFromQuery(t *testing.T) { } func TestFromQueryWithError(t *testing.T) { - q, err := FromQuery("hostname=not(empty(") + q, err := mustClient(t, "https://example.com").FromQuery("hostname=not(empty(") require.Error(t, err) assert.Contains(t, err.Error(), "unmatched ( found") diff --git a/adminapi/server_object.go b/adminapi/server_object.go index 835d32f..531ebfc 100644 --- a/adminapi/server_object.go +++ b/adminapi/server_object.go @@ -11,6 +11,7 @@ type ServerObjects []*ServerObject // ServerObject is a map of key-value attributes of a SA object type ServerObject struct { + client *Client // client used to commit this object; nil falls back to the env default attributes Attributes oldValues Attributes // tracks original values before first modification deleted bool @@ -36,6 +37,49 @@ func (s *ServerObject) GetString(attribute string) string { return "" } +// GetInt safely retrieves an attribute as an int. JSON numbers decode as +// float64 and are truncated; an existing int or json.Number is also handled. +// Returns 0 if the attribute is missing or not numeric. +func (s *ServerObject) GetInt(attribute string) int { + switch v := s.attributes[attribute].(type) { + case float64: + return int(v) + case int: + return v + case json.Number: + if i, err := v.Int64(); err == nil { + return int(i) + } + } + return 0 +} + +// GetFloat safely retrieves an attribute as a float64 without the lossy +// float64->int conversion performed by Get. Returns 0 if the attribute is +// missing or not numeric. +func (s *ServerObject) GetFloat(attribute string) float64 { + switch v := s.attributes[attribute].(type) { + case float64: + return v + case int: + return float64(v) + case json.Number: + if f, err := v.Float64(); err == nil { + return f + } + } + return 0 +} + +// GetBool safely retrieves an attribute as a bool. Returns false if the +// attribute is missing or not a bool. +func (s *ServerObject) GetBool(attribute string) bool { + if v, ok := s.attributes[attribute].(bool); ok { + return v + } + return false +} + // GetMulti safely retrieves a multi-valued attribute as a MultiAttr. // Returns an empty MultiAttr if the attribute is missing, nil, or not a slice of strings. func (s *ServerObject) GetMulti(attribute string) MultiAttr { diff --git a/adminapi/transport.go b/adminapi/transport.go index e4dc689..ddc6444 100644 --- a/adminapi/transport.go +++ b/adminapi/transport.go @@ -24,17 +24,12 @@ const ( apiEndpointCommit = "/api/dataset/commit" ) -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) - } - +func (c *Client) sendRequest(ctx context.Context, endpoint string, postData any) (*http.Response, error) { 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)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, bytes.NewBuffer(postStr)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -44,24 +39,24 @@ func sendRequest(endpoint string, postData any) (*http.Response, error) { req.Header.Set("X-Timestamp", strconv.FormatInt(now, 10)) req.Header.Set("User-Agent", userAgent) - if config.sshSigner != nil { + if c.sshSigner != nil { // sign with private key or SSH agent messageToSign := calcMessage(now, postStr) - signature, sigErr := config.sshSigner.Sign(rand.Reader, messageToSign) + signature, sigErr := c.sshSigner.Sign(rand.Reader, messageToSign) if sigErr != nil { return nil, fmt.Errorf("failed to sign request: %w", sigErr) } - publicKey := base64.StdEncoding.EncodeToString(config.sshSigner.PublicKey().Marshal()) + publicKey := base64.StdEncoding.EncodeToString(c.sshSigner.PublicKey().Marshal()) sshSignature := base64.StdEncoding.EncodeToString(ssh.Marshal(signature)) req.Header.Set("X-PublicKeys", publicKey) req.Header.Set("X-Signatures", sshSignature) - } else if len(config.authToken) > 0 { - req.Header.Set("X-SecurityToken", calcSecurityToken(config.authToken, now, postStr)) - req.Header.Set("X-Application", calcAppID(config.authToken)) + } else if len(c.authToken) > 0 { + req.Header.Set("X-SecurityToken", calcSecurityToken(c.authToken, now, postStr)) + req.Header.Set("X-Application", calcAppID(c.authToken)) } - resp, err := http.DefaultClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("sending request to %s: %w", endpoint, err) } diff --git a/adminapi/transport_test.go b/adminapi/transport_test.go index 2c2150f..e308697 100644 --- a/adminapi/transport_test.go +++ b/adminapi/transport_test.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "io" "net/http" "net/http/httptest" @@ -24,20 +25,19 @@ func TestFakeServer(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "1234567890") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - query := NewQuery(Filters{ + query := client.NewQuery(Filters{ "hostname": Any(Regexp("test.foo.local"), Regexp(".*\\.bar.local")), }) query.SetAttributes("hostname") - servers, err := query.All() + ctx := context.Background() + servers, err := query.All(ctx) require.NoError(t, err) assert.Len(t, servers, 1) - count, err := query.Count() + count, err := query.Count(ctx) require.NoError(t, err) assert.Equal(t, 1, count) @@ -50,7 +50,7 @@ func TestFakeServer(t *testing.T) { assert.Nil(t, object.Get("nope")) assert.Empty(t, object.GetString("nope")) - one, err := query.One() + one, err := query.One(ctx) require.NoError(t, err) assert.Equal(t, 483903, one.Get("object_id")) } @@ -97,16 +97,14 @@ func TestHTTPErrorHandling(t *testing.T) { })) defer server.Close() - resetConfig() - t.Setenv("SERVERADMIN_TOKEN", "1234567890") - t.Setenv("SERVERADMIN_BASE_URL", server.URL) + client := mustClient(t, server.URL) - query := NewQuery(Filters{ + query := client.NewQuery(Filters{ "hostname": Regexp("test.local"), }) query.SetAttributes("hostname") - servers, err := query.All() + servers, err := query.All(context.Background()) require.Error(t, err) assert.Nil(t, servers) assert.Contains(t, err.Error(), tc.expectedError) diff --git a/examples/query_examples.go b/examples/query_examples.go index 7b733e5..8d1dcf8 100644 --- a/examples/query_examples.go +++ b/examples/query_examples.go @@ -1,17 +1,42 @@ package main import ( + "context" "log" + "time" api "github.com/innogames/serveradmin-go-client/adminapi" ) +// clientExample shows the recommended entry point: an explicit, per-instance +// Client constructed from a Config. No environment variables are read, and the +// client is safe for concurrent use, so a single process can hold several +// clients pointing at different targets. +func clientExample() { + client, err := api.NewClient(api.Config{ + BaseURL: "https://serveradmin.example.com", + Token: "your-token", + Timeout: 10 * time.Second, + }) + checkErr(err) + + ctx := context.Background() + + q, err := client.FromQuery("hostname=webserver01 environment=production") + checkErr(err) + + servers, err := q.All(ctx) + checkErr(err) + + log.Printf("Found %d servers using the client API\n", len(servers)) +} + func stringQueryExample() { // Simple string-based query - q, err := api.FromQuery("hostname=webserver01 environment=production") + q, err := client.FromQuery("hostname=webserver01 environment=production") checkErr(err) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d servers using string query\n", len(servers)) @@ -19,14 +44,14 @@ func stringQueryExample() { func simpleFilterExample() { // Create query programmatically with simple filters passed directly - q := api.NewQuery(api.Filters{ + q := client.NewQuery(api.Filters{ "environment": "production", "state": "online", "num_cpu": api.LessThanOrEquals(4), }) q.SetAttributes("hostname", "num_cpu", "memory") - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d production servers with 8 CPUs\n", len(servers)) @@ -34,12 +59,12 @@ func simpleFilterExample() { func regexpFilterExample() { // Use Regexp filter to match hostnames - q := api.NewQuery(api.Filters{ + q := client.NewQuery(api.Filters{ "hostname": api.Regexp("^web.*\\.example\\.com$"), "environment": "production", }) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d web servers matching pattern\n", len(servers)) @@ -47,19 +72,19 @@ func regexpFilterExample() { func anyAnyFilterExample() { // Use Any filter to match multiple possible values - q := api.NewQuery(api.Filters{ + q := client.NewQuery(api.Filters{ "game_world": api.GreaterThan(1), "state": api.Any("online", "maintenance"), }) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d servers:", len(servers)) } func nestedFilterExample() { // Complex nested filters: servers that don't match certain patterns - q := api.NewQuery(api.Filters{}) + q := client.NewQuery(api.Filters{}) // Find servers where hostname is NOT matching any of these patterns q.AddFilter("hostname", api.Not(api.Any( @@ -71,14 +96,14 @@ func nestedFilterExample() { // Environment must be production or staging, but not empty q.AddFilter("environment", api.Any("production", "staging")) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d servers with complex nested filters\n", len(servers)) } func combinedFilterExample() { - q := api.NewQuery(api.Filters{}) + q := client.NewQuery(api.Filters{}) q.AddFilter("servertype", "server") q.AddFilter("environment", "production") @@ -105,7 +130,7 @@ func combinedFilterExample() { "object_id", ) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d servers suitable for migration:\n", len(servers)) @@ -121,10 +146,10 @@ func combinedFilterExample() { func multiAttrExample() { // Fetch a server with multi-valued attributes - q, err := api.FromQuery("hostname=webserver01") + q, err := client.FromQuery("hostname=webserver01") checkErr(err) - server, err := q.One() + server, err := q.One(context.Background()) checkErr(err) // Get tags as MultiAttr @@ -140,7 +165,7 @@ func multiAttrExample() { // Set back to ServerObject and commit checkErr(server.Set("tags", []string(tags))) - commitID, err := server.Commit() + commitID, err := server.Commit(context.Background()) checkErr(err) log.Printf("Updated tags for %s (commit %d)\n", server.GetString("hostname"), commitID) diff --git a/examples/real.go b/examples/real.go index 27c9977..ac99a59 100644 --- a/examples/real.go +++ b/examples/real.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" api "github.com/innogames/serveradmin-go-client/adminapi" @@ -13,20 +14,28 @@ func checkErr(err error) { } } +// client is a shared example client. Replace BaseURL/Token with your own, or use +// api.NewClientFromEnv() to configure it from the SERVERADMIN_* environment. +var client, _ = api.NewClient(api.Config{ + BaseURL: "https://serveradmin.example.com", + Token: "your-token", +}) + func main() { + ctx := context.Background() var commitID int // Step 1: Check if object already exists log.Println("=== Checking for existing public_domain object ===") - q, err := api.FromQuery("hostname=test.foo.com servertype=public_domain") + q, err := client.FromQuery("hostname=test.foo.com servertype=public_domain") checkErr(err) q.AddAttributes("dns_txt") - publicURL, err := q.One() + publicURL, err := q.One(ctx) if err != nil { // Object doesn't exist, create it log.Println("=== Object not found, creating new public_domain object ===") - publicURL, err = api.NewObject("public_domain", api.Attributes{ + publicURL, err = client.NewObject(ctx, "public_domain", api.Attributes{ "hostname": "test.foo.com", "project": "admin", "dns_txt": api.MultiAttr{}, @@ -45,7 +54,7 @@ func main() { publicURL.Set("dns_txt", dnsTxt) // Commit the update - commitID, err = publicURL.Commit() + commitID, err = publicURL.Commit(ctx) checkErr(err) log.Printf("Set dns_txt to %v (commit ID: %d)\n", publicURL.Get("dns_txt"), commitID) @@ -56,14 +65,14 @@ func main() { publicURL.Set("dns_txt", dnsTxt) // Commit the second update - commitID, err = publicURL.Commit() + commitID, err = publicURL.Commit(ctx) checkErr(err) log.Printf("Added to dns_txt, now: %v (commit ID: %d)\n", publicURL.Get("dns_txt"), commitID) // Step 4: Delete the object log.Println("\n=== Deleting object ===") publicURL.Delete() - commitID, err = publicURL.Commit() + commitID, err = publicURL.Commit(ctx) checkErr(err) log.Printf("Deleted public_url (commit ID: %d)\n", commitID) diff --git a/examples/update_example.go b/examples/update_example.go index 850f74e..4821fcc 100644 --- a/examples/update_example.go +++ b/examples/update_example.go @@ -1,17 +1,18 @@ package main import ( + "context" "fmt" api "github.com/innogames/serveradmin-go-client/adminapi" ) func singleObjectExample() { - q, err := api.FromQuery("hostname=webserver01") + q, err := client.FromQuery("hostname=webserver01") checkErr(err) q.AddAttributes("backup_disabled", "tags") - server, err := q.One() + server, err := q.One(context.Background()) checkErr(err) // Modify attributes @@ -23,25 +24,25 @@ func singleObjectExample() { tags.Delete("old-tag") // Commit changes - commitID, err := server.Commit() + commitID, err := server.Commit(context.Background()) checkErr(err) fmt.Printf("Updated server %s (commit %d)\n", server.GetString("hostname"), commitID) } func multiObjectExample() { - q, err := api.FromQuery("environment=production state=online") + q, err := client.FromQuery("environment=production state=online") checkErr(err) q.SetAttributes("hostname", "backup_disabled") - servers, err := q.All() + servers, err := q.All(context.Background()) 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() + commitID, err := servers.Commit(context.Background()) checkErr(err) fmt.Printf("Updated %d servers (commit %d)\n", len(servers), commitID) @@ -50,7 +51,7 @@ func multiObjectExample() { func createObjectExample() { // Create a new VM object — NewObject fetches defaults, sets attributes, commits, // and re-queries to populate object_id in a single call. - newVM, err := api.NewObject("vm", api.Attributes{ + newVM, err := client.NewObject(context.Background(), "vm", api.Attributes{ "hostname": "newserver.example.com", "environment": "development", "num_cpu": 4, @@ -61,34 +62,34 @@ func createObjectExample() { } func deleteObjectExample() { - q, err := api.FromQuery("hostname=oldserver.example.com") + q, err := client.FromQuery("hostname=oldserver.example.com") checkErr(err) - server, err := q.One() + server, err := q.One(context.Background()) checkErr(err) // Mark for deletion server.Delete() // Commit the deletion - commitID, err := server.Commit() + commitID, err := server.Commit(context.Background()) checkErr(err) fmt.Printf("Deleted server (commit %d)\n", commitID) } func batchDeleteExample() { - q, err := api.FromQuery("servertype=domain state=retired") + q, err := client.FromQuery("servertype=domain state=retired") checkErr(err) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) // Delete ALL retired domains using batch Delete() servers.Delete() // Commit all deletions in a single API call - commitID, err := servers.Commit() + commitID, err := servers.Commit(context.Background()) checkErr(err) fmt.Printf("Deleted %d servers (commit %d)\n", len(servers), commitID) @@ -96,17 +97,17 @@ func batchDeleteExample() { func callAPIExample() { // Call a remote API function - result, err := api.CallAPI("ip", "get_free", map[string]any{"network": "internal"}) + result, err := client.CallAPI(context.Background(), "ip", "get_free", map[string]any{"network": "internal"}) checkErr(err) fmt.Printf("Free IP: %s\n", result) } func rollbackExample() { - q, err := api.FromQuery("hostname=webserver01") + q, err := client.FromQuery("hostname=webserver01") checkErr(err) - server, err := q.One() + server, err := q.One(context.Background()) checkErr(err) // Make some changes diff --git a/main.go b/main.go index 0c22cc3..19e685d 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -26,7 +27,13 @@ func main() { os.Exit(1) } - q, err := adminapi.FromQuery(query) + client, err := adminapi.NewClientFromEnv() + if err != nil { + fmt.Println("Error configuring client:", err) + os.Exit(1) + } + + q, err := client.FromQuery(query) if err != nil { fmt.Println("Error parsing query:", err) os.Exit(1) @@ -36,7 +43,7 @@ func main() { q.SetAttributes(attributeList...) q.OrderBy(orderBy) - servers, err := q.All() + servers, err := q.All(context.Background()) if err != nil { fmt.Println(err) os.Exit(1)