From 024f81906ad2c1f58327e39309a5753f46575358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Mon, 22 Jun 2026 12:16:16 +0200 Subject: [PATCH 1/2] new "Client" structure --- README.md | 135 ++++++++++++++++++++++++++----------- adminapi/call.go | 16 ++++- adminapi/commit.go | 43 ++++++++++-- adminapi/commit_test.go | 7 +- adminapi/config.go | 94 +++++++++++++++----------- adminapi/config_test.go | 51 ++++++++------ adminapi/create_object.go | 31 +++++++-- adminapi/query.go | 77 ++++++++++++++++----- adminapi/server_object.go | 44 ++++++++++++ adminapi/transport.go | 23 +++---- adminapi/transport_test.go | 10 +-- examples/query_examples.go | 41 ++++++++--- examples/real.go | 10 +-- examples/update_example.go | 19 +++--- main.go | 11 ++- 15 files changed, 440 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index bbd63c3..89b0e93 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,93 @@ 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 the deprecated package-level functions and by +`adminapi.NewClientFromEnv()`. The recommended `NewClient(Config{...})` path +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`. Unlike the legacy environment path, an +ambient `SSH_AUTH_SOCK` can never silently override an explicitly configured +token. + +For deployments that are still configured entirely through environment +variables (for example the CLI), `adminapi.NewClientFromEnv()` builds a `Client` +from the `SERVERADMIN_*` variables. + +#### 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`). + +### Deprecated: package-level functions and the global env client + +The package-level `adminapi.FromQuery` / `NewQuery` / `CallAPI` / `NewObject` +still work: they lazily build a single process-global client from the +`SERVERADMIN_*` environment variables (the historical behavior). They are +**deprecated** in favor of an explicit `Client`, because the global config is +frozen after the first request and cannot serve multiple targets. Note that the +execution methods (`All`, `One`, `Count`, `Commit`) now require a +`context.Context` regardless of which path you use. + ### As a CLI Tool ```bash @@ -94,17 +140,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 (deprecated): SERVERADMIN_KEY_PATH, or a running 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 (deprecated): set SERVERADMIN_TOKEN. ``` ## Examples @@ -112,41 +166,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..ee654ed 100644 --- a/adminapi/call.go +++ b/adminapi/call.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "fmt" ) @@ -22,7 +23,20 @@ type callResponse struct { // CallAPI calls a remote API function on the Serveradmin server. // It takes a function group, function name, and keyword arguments as a map. +// +// Deprecated: use Client.CallAPI so the request uses an explicit, per-instance +// configuration instead of a process-global one built from environment variables. func CallAPI(group, function string, args map[string]any) (any, error) { + client, err := defaultClient() + if err != nil { + return nil, err + } + return client.CallAPI(context.Background(), group, function, args) +} + +// 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 (c *Client) CallAPI(ctx context.Context, group, function string, args map[string]any) (any, error) { req := callRequest{ Group: group, Name: function, @@ -30,7 +44,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/commit.go b/adminapi/commit.go index b3697a7..323a8db 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,26 @@ func (s *ServerObject) Commit() (int, error) { return commitID, nil } +// resolveClient returns the object's bound client, falling back to the lazily +// built environment-based default client for the deprecated package-level API. +func (s *ServerObject) resolveClient() (*Client, error) { + if s.client != nil { + return s.client, nil + } + return defaultClient() +} + +// resolveObjectsClient returns the first non-nil client among the objects, +// falling back to the environment-based default client. +func resolveObjectsClient(objects ServerObjects) (*Client, error) { + for _, obj := range objects { + if obj.client != nil { + return obj.client, nil + } + } + return defaultClient() +} + func buildCommit(objects ServerObjects) commitRequest { commit := commitRequest{ Created: []Attributes{}, @@ -100,8 +131,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..eef7d38 100644 --- a/adminapi/commit_test.go +++ b/adminapi/commit_test.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "io" "net/http" @@ -32,7 +33,7 @@ func TestCommitSingle(t *testing.T) { oldValues: Attributes{"hostname": "old.local"}, } - commitID, err := obj.Commit() + commitID, err := obj.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 123, commitID) @@ -78,7 +79,7 @@ func TestCommitResultSet(t *testing.T) { }, } - commitID, err := objects.Commit() + commitID, err := objects.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 456, commitID) @@ -213,7 +214,7 @@ func TestServerObjectsSetWithCommit(t *testing.T) { require.NoError(t, err) // Commit should work - commitID, err := objects.Commit() + 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..e247715 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "os" - "strings" "sync" "golang.org/x/crypto/ssh" @@ -18,63 +17,80 @@ const ( userAgent = "Adminapi Go Client " + version ) -type config struct { - baseURL string - apiVersion string - authToken []byte - sshSigner ssh.Signer -} +// defaultClient lazily builds a Client from environment variables. It backs the +// deprecated package-level functions (FromQuery, NewQuery, CallAPI, NewObject) +// so existing env-based callers keep working. The client is immutable once +// built, so no mutable config remains in the request path. +var defaultClient = sync.OnceValues(buildDefaultClient) -// getConfig returns the configuration for the API client. Loading config only once -var getConfig = sync.OnceValues(loadConfig) +func buildDefaultClient() (*Client, error) { + return NewClientFromEnv() +} -// loadConfig returns the configuration for the API client -var loadConfig = func() (config, error) { - cfg := config{ - apiVersion: version, +// 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) +} + +// 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) + signer, err := agentSigner(authSock) if err != nil { - return cfg, fmt.Errorf("failed to connect to SSH agent: %w", err) - } - signers, err := agent.NewClient(sock).Signers() - 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) { + sock, err := net.Dial("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..af05c06 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -9,19 +9,18 @@ import ( "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. +// The deprecated package-level API resolves its configuration through +// defaultClient, which caches the env-based Client via sync.OnceValues. Tests +// that change SERVERADMIN_* env vars must call resetConfig() first so the next +// access rebuilds the client from the new environment. func resetConfig() { - getConfig = sync.OnceValues(loadConfig) + defaultClient = sync.OnceValues(buildDefaultClient) } -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 +34,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..7e6ec6c 100644 --- a/adminapi/create_object.go +++ b/adminapi/create_object.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "fmt" "net/url" @@ -9,12 +10,34 @@ import ( // 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". +// +// Deprecated: use Client.NewObject so the request uses an explicit, per-instance +// configuration instead of a process-global one built from environment variables. func NewObject(serverType string, attributes Attributes) (*ServerObject, error) { + // Validate before resolving the env-based client so a missing hostname is + // reported regardless of whether configuration is present (matches the + // historical behavior of this function). + if !attributes.Has("hostname") { + return nil, fmt.Errorf("attributes must include %q: %w", "hostname", ErrUnknownAttribute) + } + + client, err := defaultClient() + if err != nil { + return nil, err + } + return client.NewObject(context.Background(), serverType, attributes) +} + +// 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 +46,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 +71,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/query.go b/adminapi/query.go index e5acffc..29958a9 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "fmt" "slices" @@ -8,6 +9,7 @@ import ( // 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 +26,49 @@ func (a Attributes) Has(key string) bool { return ok } -// FromQuery creates a new Query object from a query string +// FromQuery creates a new Query object from a query string. +// +// Deprecated: use Client.FromQuery so the request uses an explicit, per-instance +// configuration instead of a process-global one built from environment variables. func FromQuery(query string) (Query, error) { - filters, err := ParseQuery(query) - if err != nil { - return Query{}, fmt.Errorf("parsing query %s: %w", query, err) - } - - return NewQuery(filters), nil + return newQueryFromString(nil, query) } -// NewQuery initialize a new query which loads data from SA if needed +// NewQuery initializes a new query which loads data from SA if needed. +// +// Deprecated: use Client.NewQuery so the request uses an explicit, per-instance +// configuration instead of a process-global one built from environment variables. func NewQuery(filters Filters) Query { + return newQuery(nil, filters) +} + +// 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) +} + +// NewQuery initializes a new query bound to this client. +func (c *Client) NewQuery(filters Filters) Query { + return newQuery(c, filters) +} + +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 +90,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 +100,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 +111,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 +127,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 +148,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 +159,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 +174,15 @@ func (q *Query) load() error { return nil } +// resolveClient returns the query's bound client, falling back to the lazily +// built environment-based default client for the deprecated package-level API. +func (q *Query) resolveClient() (*Client, error) { + if q.client != nil { + return q.client, nil + } + return defaultClient() +} + // like {"Filters": {"hostname": {"Regexp": "foo.local.*"}}, "restrict": ["hostname", "object_id"]} type queryRequest struct { Filters map[string]any `json:"filters"` 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..e1b2810 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" @@ -33,11 +34,12 @@ func TestFakeServer(t *testing.T) { }) 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 +52,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")) } @@ -106,7 +108,7 @@ func TestHTTPErrorHandling(t *testing.T) { }) 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..89901cd 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") 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)) @@ -26,7 +51,7 @@ func simpleFilterExample() { }) 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)) @@ -39,7 +64,7 @@ func regexpFilterExample() { "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)) @@ -52,7 +77,7 @@ func anyAnyFilterExample() { "state": api.Any("online", "maintenance"), }) - servers, err := q.All() + servers, err := q.All(context.Background()) checkErr(err) log.Printf("Found %d servers:", len(servers)) } @@ -71,7 +96,7 @@ 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)) @@ -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)) @@ -124,7 +149,7 @@ func multiAttrExample() { q, err := api.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..7dd992a 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" @@ -14,6 +15,7 @@ func checkErr(err error) { } func main() { + ctx := context.Background() var commitID int // Step 1: Check if object already exists @@ -22,7 +24,7 @@ func main() { 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 ===") @@ -45,7 +47,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 +58,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..0241dc2 100644 --- a/examples/update_example.go +++ b/examples/update_example.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" api "github.com/innogames/serveradmin-go-client/adminapi" @@ -11,7 +12,7 @@ func singleObjectExample() { checkErr(err) q.AddAttributes("backup_disabled", "tags") - server, err := q.One() + server, err := q.One(context.Background()) checkErr(err) // Modify attributes @@ -23,7 +24,7 @@ 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) @@ -34,14 +35,14 @@ func multiObjectExample() { 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) @@ -64,14 +65,14 @@ func deleteObjectExample() { q, err := api.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) @@ -81,14 +82,14 @@ func batchDeleteExample() { q, err := api.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) @@ -106,7 +107,7 @@ func rollbackExample() { q, err := api.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) From 586dc86b29b496509d8adc555142aadf1ad5323a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Mon, 22 Jun 2026 12:32:27 +0200 Subject: [PATCH 2/2] remove deprecated client structure --- README.md | 34 +++-- adminapi/call.go | 13 -- adminapi/call_test.go | 25 ++-- adminapi/client.go | 91 ++++++++++++++ adminapi/client_test.go | 219 +++++++++++++++++++++++++++++++++ adminapi/commit.go | 15 ++- adminapi/commit_test.go | 21 ++-- adminapi/config.go | 15 +-- adminapi/config_test.go | 9 -- adminapi/create_object.go | 21 ---- adminapi/create_object_test.go | 34 ++--- adminapi/query.go | 26 +--- adminapi/query_test.go | 10 +- adminapi/transport_test.go | 12 +- examples/query_examples.go | 14 +-- examples/real.go | 11 +- examples/update_example.go | 14 +-- 17 files changed, 405 insertions(+), 179 deletions(-) create mode 100644 adminapi/client.go create mode 100644 adminapi/client_test.go diff --git a/README.md b/README.md index 89b0e93..d7897c6 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,8 @@ 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 the deprecated package-level functions and by -`adminapi.NewClientFromEnv()`. The recommended `NewClient(Config{...})` path -reads no environment variables. +These variables are read only by `adminapi.NewClientFromEnv()`. The primary +`NewClient(Config{...})` constructor reads no environment variables. ## Usage @@ -89,13 +88,18 @@ func main() { ``` Authentication is selected **explicitly** from `Config`, in the order -`SSHSigner` → `KeyPath` → `Token`. Unlike the legacy environment path, an -ambient `SSH_AUTH_SOCK` can never silently override an explicitly configured +`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 still configured entirely through environment -variables (for example the CLI), `adminapi.NewClientFromEnv()` builds a `Client` -from the `SERVERADMIN_*` variables. +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 @@ -103,16 +107,6 @@ from the `SERVERADMIN_*` variables. preserve numeric type, use the typed getters: `GetInt`, `GetFloat`, `GetBool` (alongside the existing `GetString` and `GetMulti`). -### Deprecated: package-level functions and the global env client - -The package-level `adminapi.FromQuery` / `NewQuery` / `CallAPI` / `NewObject` -still work: they lazily build a single process-global client from the -`SERVERADMIN_*` environment variables (the historical behavior). They are -**deprecated** in favor of an explicit `Client`, because the global config is -frozen after the first request and cannot serve multiple targets. Note that the -execution methods (`All`, `One`, `Count`, `Commit`) now require a -`context.Context` regardless of which path you use. - ### As a CLI Tool ```bash @@ -146,7 +140,7 @@ client, _ := adminapi.NewClient(adminapi.Config{ KeyPath: "/path/to/id_ed25519", // or SSHSigner: }) -// Env path (deprecated): SERVERADMIN_KEY_PATH, or a running SSH agent via SSH_AUTH_SOCK. +// Env path via NewClientFromEnv(): SERVERADMIN_KEY_PATH, or an SSH agent via SSH_AUTH_SOCK. ``` ### Security Token Authentication @@ -158,7 +152,7 @@ client, _ := adminapi.NewClient(adminapi.Config{ Token: "your-token", }) -// Env path (deprecated): set SERVERADMIN_TOKEN. +// Env path via NewClientFromEnv(): set SERVERADMIN_TOKEN. ``` ## Examples diff --git a/adminapi/call.go b/adminapi/call.go index ee654ed..c34adbd 100644 --- a/adminapi/call.go +++ b/adminapi/call.go @@ -21,19 +21,6 @@ type callResponse struct { Message string `json:"message"` } -// CallAPI calls a remote API function on the Serveradmin server. -// It takes a function group, function name, and keyword arguments as a map. -// -// Deprecated: use Client.CallAPI so the request uses an explicit, per-instance -// configuration instead of a process-global one built from environment variables. -func CallAPI(group, function string, args map[string]any) (any, error) { - client, err := defaultClient() - if err != nil { - return nil, err - } - return client.CallAPI(context.Background(), group, function, args) -} - // 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 (c *Client) CallAPI(ctx context.Context, group, function string, args map[string]any) (any, error) { 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 323a8db..9957f31 100644 --- a/adminapi/commit.go +++ b/adminapi/commit.go @@ -88,24 +88,23 @@ func (s *ServerObject) Commit(ctx context.Context) (int, error) { return commitID, nil } -// resolveClient returns the object's bound client, falling back to the lazily -// built environment-based default client for the deprecated package-level API. +// resolveClient returns the object's bound client. func (s *ServerObject) resolveClient() (*Client, error) { - if s.client != nil { - return s.client, nil + 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 defaultClient() + return s.client, nil } -// resolveObjectsClient returns the first non-nil client among the objects, -// falling back to the environment-based default client. +// 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 defaultClient() + return nil, errors.New("no object is bound to a client; obtain them via a Client query") } func buildCommit(objects ServerObjects) commitRequest { diff --git a/adminapi/commit_test.go b/adminapi/commit_test.go index eef7d38..dee18bd 100644 --- a/adminapi/commit_test.go +++ b/adminapi/commit_test.go @@ -24,11 +24,10 @@ 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"}, } @@ -59,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{ { @@ -79,6 +76,10 @@ func TestCommitResultSet(t *testing.T) { }, } + for _, o := range objects { + o.client = client + } + commitID, err := objects.Commit(context.Background()) require.NoError(t, err) assert.Equal(t, 456, commitID) @@ -194,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{ { @@ -214,6 +213,10 @@ func TestServerObjectsSetWithCommit(t *testing.T) { require.NoError(t, err) // Commit should work + 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 e247715..35c4cb4 100644 --- a/adminapi/config.go +++ b/adminapi/config.go @@ -1,12 +1,12 @@ package adminapi import ( + "context" "crypto/rand" "errors" "fmt" "net" "os" - "sync" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -17,16 +17,6 @@ const ( userAgent = "Adminapi Go Client " + version ) -// defaultClient lazily builds a Client from environment variables. It backs the -// deprecated package-level functions (FromQuery, NewQuery, CallAPI, NewObject) -// so existing env-based callers keep working. The client is immutable once -// built, so no mutable config remains in the request path. -var defaultClient = sync.OnceValues(buildDefaultClient) - -func buildDefaultClient() (*Client, error) { - return NewClientFromEnv() -} - // 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 @@ -79,7 +69,8 @@ func configFromEnv() (Config, error) { // 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) { - sock, err := net.Dial("unix", authSock) + 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) } diff --git a/adminapi/config_test.go b/adminapi/config_test.go index af05c06..ea839a9 100644 --- a/adminapi/config_test.go +++ b/adminapi/config_test.go @@ -2,21 +2,12 @@ package adminapi import ( "net/http/httptest" - "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// The deprecated package-level API resolves its configuration through -// defaultClient, which caches the env-based Client via sync.OnceValues. Tests -// that change SERVERADMIN_* env vars must call resetConfig() first so the next -// access rebuilds the client from the new environment. -func resetConfig() { - defaultClient = sync.OnceValues(buildDefaultClient) -} - func TestConfigFromEnv(t *testing.T) { // without SERVERADMIN_BASE_URL set t.Setenv("SERVERADMIN_BASE_URL", "") diff --git a/adminapi/create_object.go b/adminapi/create_object.go index 7e6ec6c..0075a89 100644 --- a/adminapi/create_object.go +++ b/adminapi/create_object.go @@ -7,27 +7,6 @@ import ( "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". -// -// Deprecated: use Client.NewObject so the request uses an explicit, per-instance -// configuration instead of a process-global one built from environment variables. -func NewObject(serverType string, attributes Attributes) (*ServerObject, error) { - // Validate before resolving the env-based client so a missing hostname is - // reported regardless of whether configuration is present (matches the - // historical behavior of this function). - if !attributes.Has("hostname") { - return nil, fmt.Errorf("attributes must include %q: %w", "hostname", ErrUnknownAttribute) - } - - client, err := defaultClient() - if err != nil { - return nil, err - } - return client.NewObject(context.Background(), serverType, attributes) -} - // 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". 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 29958a9..6e011da 100644 --- a/adminapi/query.go +++ b/adminapi/query.go @@ -3,6 +3,7 @@ package adminapi import ( "context" "encoding/json" + "errors" "fmt" "slices" ) @@ -26,22 +27,6 @@ func (a Attributes) Has(key string) bool { return ok } -// FromQuery creates a new Query object from a query string. -// -// Deprecated: use Client.FromQuery so the request uses an explicit, per-instance -// configuration instead of a process-global one built from environment variables. -func FromQuery(query string) (Query, error) { - return newQueryFromString(nil, query) -} - -// NewQuery initializes a new query which loads data from SA if needed. -// -// Deprecated: use Client.NewQuery so the request uses an explicit, per-instance -// configuration instead of a process-global one built from environment variables. -func NewQuery(filters Filters) Query { - return newQuery(nil, filters) -} - // 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) @@ -174,13 +159,12 @@ func (q *Query) load(ctx context.Context) error { return nil } -// resolveClient returns the query's bound client, falling back to the lazily -// built environment-based default client for the deprecated package-level API. +// resolveClient returns the query's bound client. func (q *Query) resolveClient() (*Client, error) { - if q.client != nil { - return q.client, nil + if q.client == nil { + return nil, errors.New("query is not bound to a client; use Client.NewQuery or Client.FromQuery") } - return defaultClient() + return q.client, nil } // like {"Filters": {"hostname": {"Regexp": "foo.local.*"}}, "restrict": ["hostname", "object_id"]} 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/transport_test.go b/adminapi/transport_test.go index e1b2810..e308697 100644 --- a/adminapi/transport_test.go +++ b/adminapi/transport_test.go @@ -25,11 +25,9 @@ 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") @@ -99,11 +97,9 @@ 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") diff --git a/examples/query_examples.go b/examples/query_examples.go index 89901cd..8d1dcf8 100644 --- a/examples/query_examples.go +++ b/examples/query_examples.go @@ -33,7 +33,7 @@ func clientExample() { 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(context.Background()) @@ -44,7 +44,7 @@ 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), @@ -59,7 +59,7 @@ 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", }) @@ -72,7 +72,7 @@ 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"), }) @@ -84,7 +84,7 @@ func anyAnyFilterExample() { 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( @@ -103,7 +103,7 @@ func nestedFilterExample() { } func combinedFilterExample() { - q := api.NewQuery(api.Filters{}) + q := client.NewQuery(api.Filters{}) q.AddFilter("servertype", "server") q.AddFilter("environment", "production") @@ -146,7 +146,7 @@ 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(context.Background()) diff --git a/examples/real.go b/examples/real.go index 7dd992a..ac99a59 100644 --- a/examples/real.go +++ b/examples/real.go @@ -14,13 +14,20 @@ 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") @@ -28,7 +35,7 @@ func main() { 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{}, diff --git a/examples/update_example.go b/examples/update_example.go index 0241dc2..4821fcc 100644 --- a/examples/update_example.go +++ b/examples/update_example.go @@ -8,7 +8,7 @@ import ( ) func singleObjectExample() { - q, err := api.FromQuery("hostname=webserver01") + q, err := client.FromQuery("hostname=webserver01") checkErr(err) q.AddAttributes("backup_disabled", "tags") @@ -31,7 +31,7 @@ func singleObjectExample() { } 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") @@ -51,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, @@ -62,7 +62,7 @@ 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(context.Background()) @@ -79,7 +79,7 @@ func deleteObjectExample() { } func batchDeleteExample() { - q, err := api.FromQuery("servertype=domain state=retired") + q, err := client.FromQuery("servertype=domain state=retired") checkErr(err) servers, err := q.All(context.Background()) @@ -97,14 +97,14 @@ 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(context.Background())