Skip to content

Commit d051706

Browse files
committed
Add shared kubedb provisioning client module
Nested, dependency-free module go.bytebuilders.dev/client/kubedb (go 1.23, stdlib only) providing the KubeDB Platform Helm-editor-mode client shared by the bmchelix and servicenow provisioning adapters: options/model, editor apply/delete, proxy GET (status + secret), available-types, typed APIError, engine registry (Postgres/Qdrant), configurable User-Agent. Includes unit tests. Kept as a nested module so consumers stay lightweight and do not inherit the parent client library's dependency graph. Signed-off-by: Tamal Saha <tamal@appscode.com>
1 parent c01d0e2 commit d051706

5 files changed

Lines changed: 448 additions & 0 deletions

File tree

kubedb/client.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Package kubedb is a thin, dependency-free client for the KubeDB Platform API Server,
2+
// exposing the subset of endpoints needed to provision databases in Helm editor mode.
3+
// It is shared by the BMC Helix and ServiceNow provisioning adapters.
4+
package kubedb
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"crypto/tls"
10+
"encoding/base64"
11+
"encoding/json"
12+
"fmt"
13+
"io"
14+
"net"
15+
"net/http"
16+
"strings"
17+
"time"
18+
)
19+
20+
const defaultUserAgent = "bytebuilders-client-go"
21+
22+
// Client talks to the KubeDB Platform API using a service-account bearer token.
23+
type Client struct {
24+
baseURL string
25+
token string
26+
userAgent string
27+
http *http.Client
28+
}
29+
30+
// NewClient builds a KubeDB Platform API client. userAgent identifies the caller
31+
// (e.g. "bmchelix-adapter"); an empty value falls back to a default.
32+
func NewClient(baseURL, token, userAgent string, timeout time.Duration, insecureSkipTLS bool) *Client {
33+
if userAgent == "" {
34+
userAgent = defaultUserAgent
35+
}
36+
transport := &http.Transport{
37+
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipTLS}, //nolint:gosec // opt-in dev/test only
38+
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
39+
TLSHandshakeTimeout: 10 * time.Second,
40+
MaxIdleConns: 100,
41+
IdleConnTimeout: 90 * time.Second,
42+
}
43+
return &Client{
44+
baseURL: strings.TrimRight(baseURL, "/"),
45+
token: token,
46+
userAgent: userAgent,
47+
http: &http.Client{
48+
Timeout: timeout,
49+
Transport: transport,
50+
},
51+
}
52+
}
53+
54+
// APIError is returned for non-2xx responses from the KubeDB API.
55+
type APIError struct {
56+
Method string
57+
URL string
58+
StatusCode int
59+
Body string
60+
}
61+
62+
func (e *APIError) Error() string {
63+
return fmt.Sprintf("kubedb api %s %s: status %d: %s", e.Method, e.URL, e.StatusCode, truncate(e.Body, 512))
64+
}
65+
66+
// NotFound reports whether the error is a 404 from the KubeDB API.
67+
func (e *APIError) NotFound() bool { return e.StatusCode == http.StatusNotFound }
68+
69+
// GenerateModel calls PUT /helm/options/model, expanding the given options into a
70+
// full editor model (returned opaquely).
71+
func (c *Client) GenerateModel(ctx context.Context, owner, cluster string, req OptionsModelRequest) (EditorModel, error) {
72+
var model json.RawMessage
73+
if err := c.do(ctx, http.MethodPut, c.clusterPath(owner, cluster, "/helm/options/model"), req, &model); err != nil {
74+
return nil, err
75+
}
76+
return EditorModel(model), nil
77+
}
78+
79+
// ApplyEditor calls PUT /helm/editor/, submitting the model as an async apply task.
80+
func (c *Client) ApplyEditor(ctx context.Context, owner, cluster string, model EditorModel) (*TaskResponse, error) {
81+
var task TaskResponse
82+
if err := c.do(ctx, http.MethodPut, c.clusterPath(owner, cluster, "/helm/editor/"), model, &task); err != nil {
83+
return nil, err
84+
}
85+
return &task, nil
86+
}
87+
88+
// DeleteEditor calls DELETE /helm/editor/, removing the release and companion objects.
89+
func (c *Client) DeleteEditor(ctx context.Context, owner, cluster string, meta OptionsMetadata) (*TaskResponse, error) {
90+
var task TaskResponse
91+
if err := c.do(ctx, http.MethodDelete, c.clusterPath(owner, cluster, "/helm/editor/"), DeleteEditorRequest{Metadata: meta}, &task); err != nil {
92+
return nil, err
93+
}
94+
return &task, nil
95+
}
96+
97+
// GetResource reads a namespaced CR through the Kubernetes proxy.
98+
func (c *Client) GetResource(ctx context.Context, owner, cluster, group, version, resource, namespace, name string) (*Unstructured, error) {
99+
suffix := fmt.Sprintf("/proxy/%s/%s/namespaces/%s/%s/%s", group, version, namespace, resource, name)
100+
var obj Unstructured
101+
if err := c.do(ctx, http.MethodGet, c.clusterPath(owner, cluster, suffix), nil, &obj); err != nil {
102+
return nil, err
103+
}
104+
return &obj, nil
105+
}
106+
107+
// GetSecret reads a namespaced core/v1 Secret through the proxy and returns its
108+
// decoded (base64) string values.
109+
func (c *Client) GetSecret(ctx context.Context, owner, cluster, namespace, name string) (map[string]string, error) {
110+
suffix := fmt.Sprintf("/proxy/core/v1/namespaces/%s/secrets/%s", namespace, name)
111+
var s Secret
112+
if err := c.do(ctx, http.MethodGet, c.clusterPath(owner, cluster, suffix), nil, &s); err != nil {
113+
return nil, err
114+
}
115+
out := make(map[string]string, len(s.Data))
116+
for k, v := range s.Data {
117+
if dec, err := base64.StdEncoding.DecodeString(v); err == nil {
118+
out[k] = string(dec)
119+
} else {
120+
out[k] = v
121+
}
122+
}
123+
return out, nil
124+
}
125+
126+
// AvailableTypes returns the raw JSON of GET /available-types for a cluster.
127+
func (c *Client) AvailableTypes(ctx context.Context, owner, cluster string) (json.RawMessage, error) {
128+
var raw json.RawMessage
129+
if err := c.do(ctx, http.MethodGet, c.clusterPath(owner, cluster, "/available-types"), nil, &raw); err != nil {
130+
return nil, err
131+
}
132+
return raw, nil
133+
}
134+
135+
func (c *Client) clusterPath(owner, cluster, suffix string) string {
136+
return fmt.Sprintf("%s/api/v1/clusters/%s/%s%s", c.baseURL, owner, cluster, suffix)
137+
}
138+
139+
func (c *Client) do(ctx context.Context, method, url string, body, out any) error {
140+
var reqBody io.Reader
141+
if body != nil {
142+
b, err := json.Marshal(body)
143+
if err != nil {
144+
return fmt.Errorf("marshal request: %w", err)
145+
}
146+
reqBody = bytes.NewReader(b)
147+
}
148+
149+
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
150+
if err != nil {
151+
return err
152+
}
153+
req.Header.Set("Authorization", "token "+c.token)
154+
req.Header.Set("Accept", "application/json")
155+
req.Header.Set("User-Agent", c.userAgent)
156+
if body != nil {
157+
req.Header.Set("Content-Type", "application/json")
158+
}
159+
160+
resp, err := c.http.Do(req)
161+
if err != nil {
162+
return fmt.Errorf("%s %s: %w", method, url, err)
163+
}
164+
defer resp.Body.Close()
165+
166+
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
167+
if err != nil {
168+
return fmt.Errorf("read response: %w", err)
169+
}
170+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
171+
return &APIError{Method: method, URL: url, StatusCode: resp.StatusCode, Body: string(data)}
172+
}
173+
if out != nil && len(data) > 0 {
174+
if err := json.Unmarshal(data, out); err != nil {
175+
return fmt.Errorf("decode response: %w", err)
176+
}
177+
}
178+
return nil
179+
}
180+
181+
func truncate(s string, n int) string {
182+
if len(s) <= n {
183+
return s
184+
}
185+
return s[:n] + "…"
186+
}

kubedb/client_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package kubedb
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"errors"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
"time"
12+
)
13+
14+
const testUA = "unit-test-agent"
15+
16+
func newTestClient(t *testing.T, h http.Handler) *Client {
17+
t.Helper()
18+
srv := httptest.NewServer(h)
19+
t.Cleanup(srv.Close)
20+
return NewClient(srv.URL, "tok", testUA, 5*time.Second, false)
21+
}
22+
23+
func TestNewClient_DefaultUserAgent(t *testing.T) {
24+
c := NewClient("https://example.com", "tok", "", time.Second, false)
25+
if c.userAgent != defaultUserAgent {
26+
t.Fatalf("userAgent = %q, want default %q", c.userAgent, defaultUserAgent)
27+
}
28+
}
29+
30+
func TestGetResource_ParsesStatusAndSendsHeaders(t *testing.T) {
31+
mux := http.NewServeMux()
32+
mux.HandleFunc("GET /api/v1/clusters/acme/prod/proxy/kubedb.com/v1/namespaces/team-a/postgreses/db1",
33+
func(w http.ResponseWriter, r *http.Request) {
34+
if got := r.Header.Get("Authorization"); got != "token tok" {
35+
t.Errorf("Authorization = %q, want %q", got, "token tok")
36+
}
37+
if got := r.Header.Get("User-Agent"); got != testUA {
38+
t.Errorf("User-Agent = %q, want %q", got, testUA)
39+
}
40+
_, _ = w.Write([]byte(`{"status":{"phase":"Ready"}}`))
41+
})
42+
43+
c := newTestClient(t, mux)
44+
obj, err := c.GetResource(context.Background(), "acme", "prod", "kubedb.com", "v1", "postgreses", "team-a", "db1")
45+
if err != nil {
46+
t.Fatalf("GetResource: %v", err)
47+
}
48+
if obj.Status.Phase != "Ready" {
49+
t.Fatalf("phase = %q, want Ready", obj.Status.Phase)
50+
}
51+
}
52+
53+
func TestGetResource_NotFound(t *testing.T) {
54+
mux := http.NewServeMux()
55+
mux.HandleFunc("GET /api/v1/clusters/acme/prod/proxy/kubedb.com/v1/namespaces/team-a/postgreses/missing",
56+
func(w http.ResponseWriter, _ *http.Request) {
57+
w.WriteHeader(http.StatusNotFound)
58+
_, _ = w.Write([]byte(`{"message":"not found"}`))
59+
})
60+
61+
c := newTestClient(t, mux)
62+
_, err := c.GetResource(context.Background(), "acme", "prod", "kubedb.com", "v1", "postgreses", "team-a", "missing")
63+
var apiErr *APIError
64+
if !errors.As(err, &apiErr) || !apiErr.NotFound() {
65+
t.Fatalf("want APIError NotFound, got %v", err)
66+
}
67+
}
68+
69+
func TestGetSecret_DecodesBase64(t *testing.T) {
70+
enc := base64.StdEncoding.EncodeToString
71+
mux := http.NewServeMux()
72+
mux.HandleFunc("GET /api/v1/clusters/acme/prod/proxy/core/v1/namespaces/team-a/secrets/db1-auth",
73+
func(w http.ResponseWriter, _ *http.Request) {
74+
_, _ = w.Write([]byte(`{"data":{"username":"` + enc([]byte("pg-user")) + `","password":"` + enc([]byte("p@ss")) + `"}}`))
75+
})
76+
77+
c := newTestClient(t, mux)
78+
creds, err := c.GetSecret(context.Background(), "acme", "prod", "team-a", "db1-auth")
79+
if err != nil {
80+
t.Fatalf("GetSecret: %v", err)
81+
}
82+
if creds["username"] != "pg-user" || creds["password"] != "p@ss" {
83+
t.Fatalf("decoded creds = %v", creds)
84+
}
85+
}
86+
87+
func TestGenerateModel_APIErrorStatus(t *testing.T) {
88+
mux := http.NewServeMux()
89+
mux.HandleFunc("PUT /api/v1/clusters/acme/prod/helm/options/model",
90+
func(w http.ResponseWriter, _ *http.Request) {
91+
w.WriteHeader(http.StatusUnprocessableEntity)
92+
_, _ = w.Write([]byte(`{"message":"bad options"}`))
93+
})
94+
95+
c := newTestClient(t, mux)
96+
_, err := c.GenerateModel(context.Background(), "acme", "prod", OptionsModelRequest{})
97+
var apiErr *APIError
98+
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnprocessableEntity {
99+
t.Fatalf("want 422 APIError, got %v", err)
100+
}
101+
}
102+
103+
func TestApplyEditor_SendsModelAndReturnsTask(t *testing.T) {
104+
mux := http.NewServeMux()
105+
mux.HandleFunc("PUT /api/v1/clusters/acme/prod/helm/editor/",
106+
func(w http.ResponseWriter, r *http.Request) {
107+
body, _ := io.ReadAll(r.Body)
108+
if string(body) != `{"k":"v"}` {
109+
t.Errorf("editor body = %q, want the model verbatim", string(body))
110+
}
111+
_, _ = w.Write([]byte(`{"id":"task-1"}`))
112+
})
113+
114+
c := newTestClient(t, mux)
115+
task, err := c.ApplyEditor(context.Background(), "acme", "prod", EditorModel(`{"k":"v"}`))
116+
if err != nil {
117+
t.Fatalf("ApplyEditor: %v", err)
118+
}
119+
if task.ID != "task-1" {
120+
t.Fatalf("task id = %q", task.ID)
121+
}
122+
}

kubedb/engines.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package kubedb
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// Engine describes the KubeDB coordinates for a single database kind. Values here
10+
// mirror the per-engine appendix of the integration guide; the group/version is the
11+
// deployment-specific part and should be confirmed against `available-types`.
12+
type Engine struct {
13+
// Name is the canonical lower-case engine name, e.g. "postgres".
14+
Name string
15+
// Group/Version/Kind/Resource identify the primary CR for the proxy path.
16+
Group string
17+
Version string
18+
Kind string
19+
Resource string
20+
// Ports are the engine's default service ports (informational, for connection info).
21+
Ports []int
22+
// AuthType hints how connection credentials are shaped: "password" or "apikey".
23+
AuthType string
24+
}
25+
26+
var engines = map[string]Engine{
27+
"postgres": {
28+
Name: "postgres",
29+
Group: "kubedb.com",
30+
Version: "v1",
31+
Kind: "Postgres",
32+
Resource: "postgreses",
33+
Ports: []int{5432},
34+
AuthType: "password",
35+
},
36+
"qdrant": {
37+
Name: "qdrant",
38+
Group: "kubedb.com",
39+
Version: "v1alpha2",
40+
Kind: "Qdrant",
41+
Resource: "qdrants",
42+
Ports: []int{6333, 6334},
43+
AuthType: "apikey",
44+
},
45+
}
46+
47+
// Lookup returns the Engine for a name (case-insensitive) or an error listing
48+
// the supported engines.
49+
func Lookup(name string) (Engine, error) {
50+
e, ok := engines[strings.ToLower(strings.TrimSpace(name))]
51+
if !ok {
52+
return Engine{}, fmt.Errorf("unsupported engine %q (supported: %s)", name, strings.Join(SupportedEngines(), ", "))
53+
}
54+
return e, nil
55+
}
56+
57+
// SupportedEngines returns the sorted list of engine names the client knows about.
58+
func SupportedEngines() []string {
59+
out := make([]string, 0, len(engines))
60+
for k := range engines {
61+
out = append(out, k)
62+
}
63+
sort.Strings(out)
64+
return out
65+
}

kubedb/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module go.bytebuilders.dev/client/kubedb
2+
3+
go 1.23

0 commit comments

Comments
 (0)