diff --git a/README.md b/README.md index 0afdb054..1ffcb252 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Introduction -The Replicated SDK (software development kit) is a service that allows you to embed key Replicated features alongside your application. +The Replicated SDK (software development kit) is a service that allows you to embed key Replicated features alongside your application. -[Replicated SDK Product Documentation](https://docs.replicated.com/vendor/replicated-sdk-overview) +[Replicated SDK Product Documentation](https://docs.replicated.com/vendor/replicated-sdk-overview) \ No newline at end of file diff --git a/pkg/replicatedclient/app.go b/pkg/replicatedclient/app.go new file mode 100644 index 00000000..d001a846 --- /dev/null +++ b/pkg/replicatedclient/app.go @@ -0,0 +1,69 @@ +package replicatedclient + +import ( + "context" + "net/http" + "net/url" +) + +// GetAppInfo returns the current application information. +func (c *Client) GetAppInfo(ctx context.Context) (*AppInfo, error) { + var info AppInfo + if err := c.doGet(ctx, "/api/v1/app/info", &info); err != nil { + return nil, err + } + return &info, nil +} + +// GetAppStatus returns the current application status. +func (c *Client) GetAppStatus(ctx context.Context) (*AppStatusResponse, error) { + var status AppStatusResponse + if err := c.doGet(ctx, "/api/v1/app/status", &status); err != nil { + return nil, err + } + return &status, nil +} + +// GetAppUpdates returns the list of available upstream updates. +func (c *Client) GetAppUpdates(ctx context.Context) ([]ChannelRelease, error) { + var updates []ChannelRelease + if err := c.doGet(ctx, "/api/v1/app/updates", &updates); err != nil { + return nil, err + } + return updates, nil +} + +// GetAppHistory returns the deployment history (Helm releases). +func (c *Client) GetAppHistory(ctx context.Context) (*AppHistoryResponse, error) { + var history AppHistoryResponse + if err := c.doGet(ctx, "/api/v1/app/history", &history); err != nil { + return nil, err + } + return &history, nil +} + +// SendCustomAppMetrics sends (overwrites) custom application metrics. +// Data values must be scalars (string, number, bool). +func (c *Client) SendCustomAppMetrics(ctx context.Context, data CustomAppMetricsData) error { + req := SendCustomAppMetricsRequest{Data: data} + return c.doSend(ctx, http.MethodPost, "/api/v1/app/custom-metrics", req) +} + +// UpdateCustomAppMetrics merges custom application metrics with existing values. +// Data values must be scalars (string, number, bool). +func (c *Client) UpdateCustomAppMetrics(ctx context.Context, data CustomAppMetricsData) error { + req := SendCustomAppMetricsRequest{Data: data} + return c.doSend(ctx, http.MethodPatch, "/api/v1/app/custom-metrics", req) +} + +// DeleteCustomAppMetricsKey deletes a specific custom metrics key. +func (c *Client) DeleteCustomAppMetricsKey(ctx context.Context, key string) error { + path := "/api/v1/app/custom-metrics/" + url.PathEscape(key) + return c.doSend(ctx, http.MethodDelete, path, nil) +} + +// SendAppInstanceTags sends instance tags for the application. +func (c *Client) SendAppInstanceTags(ctx context.Context, data InstanceTagData) error { + req := SendAppInstanceTagsRequest{Data: data} + return c.doSend(ctx, http.MethodPost, "/api/v1/app/instance-tags", req) +} diff --git a/pkg/replicatedclient/client.go b/pkg/replicatedclient/client.go new file mode 100644 index 00000000..8437abe4 --- /dev/null +++ b/pkg/replicatedclient/client.go @@ -0,0 +1,136 @@ +package replicatedclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Client is an HTTP client for the Replicated SDK API. +type Client struct { + baseURL string + httpClient *http.Client + licenseID string +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient sets a custom http.Client for requests. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.httpClient = hc + } +} + +// WithLicenseID sets the license ID sent in the Authorization header. +func WithLicenseID(id string) Option { + return func(c *Client) { + c.licenseID = id + } +} + +// New creates a new Replicated SDK client. +// baseURL should include the scheme and host, e.g. "http://localhost:3000". +func New(baseURL string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// APIError is returned when the server responds with a non-2xx status code. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("replicated-sdk: HTTP %d: %s", e.StatusCode, e.Body) +} + +// doRequest builds and executes an HTTP request, returning the response. +// The caller is responsible for closing the response body. +func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + url := c.baseURL + path + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("replicated-sdk: create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.licenseID != "" { + req.Header.Set("Authorization", c.licenseID) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("replicated-sdk: execute request: %w", err) + } + + return resp, nil +} + +// doGet performs a GET request and decodes the JSON response into dest. +func (c *Client) doGet(ctx context.Context, path string, dest interface{}) error { + resp, err := c.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return readAPIError(resp) + } + + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("replicated-sdk: decode response: %w", err) + } + } + return nil +} + +// doSend performs a request with a JSON body and checks for a successful status. +func (c *Client) doSend(ctx context.Context, method, path string, payload interface{}) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("replicated-sdk: encode request: %w", err) + } + body = bytes.NewReader(data) + } + + resp, err := c.doRequest(ctx, method, path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return readAPIError(resp) + } + + return nil +} + +// readAPIError reads the response body and returns an *APIError. +func readAPIError(resp *http.Response) error { + bodyBytes, _ := io.ReadAll(resp.Body) + return &APIError{ + StatusCode: resp.StatusCode, + Body: strings.TrimSpace(string(bodyBytes)), + } +} diff --git a/pkg/replicatedclient/client_test.go b/pkg/replicatedclient/client_test.go new file mode 100644 index 00000000..3f89a24e --- /dev/null +++ b/pkg/replicatedclient/client_test.go @@ -0,0 +1,543 @@ +package replicatedclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNew(t *testing.T) { + c := New("http://localhost:3000") + if c.baseURL != "http://localhost:3000" { + t.Fatalf("expected baseURL http://localhost:3000, got %s", c.baseURL) + } + if c.licenseID != "" { + t.Fatal("expected empty licenseID") + } +} + +func TestNew_TrailingSlash(t *testing.T) { + c := New("http://localhost:3000/") + if c.baseURL != "http://localhost:3000" { + t.Fatalf("expected trailing slash stripped, got %s", c.baseURL) + } +} + +func TestWithLicenseID(t *testing.T) { + c := New("http://localhost:3000", WithLicenseID("test-license-id")) + if c.licenseID != "test-license-id" { + t.Fatalf("expected licenseID test-license-id, got %s", c.licenseID) + } +} + +func TestWithHTTPClient(t *testing.T) { + custom := &http.Client{} + c := New("http://localhost:3000", WithHTTPClient(custom)) + if c.httpClient != custom { + t.Fatal("expected custom http client") + } +} + +func TestAuthorizationHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthzResponse{Version: "1.0.0"}) + })) + defer srv.Close() + + c := New(srv.URL, WithLicenseID("my-license")) + _, err := c.Healthz(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotAuth != "my-license" { + t.Fatalf("expected Authorization header 'my-license', got %q", gotAuth) + } +} + +func TestAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("something went wrong")) + })) + defer srv.Close() + + c := New(srv.URL) + _, err := c.Healthz(context.Background()) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 500 { + t.Fatalf("expected status 500, got %d", apiErr.StatusCode) + } + if apiErr.Body != "something went wrong" { + t.Fatalf("expected body 'something went wrong', got %q", apiErr.Body) + } +} + +// --- Healthz --- + +func TestHealthz(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + t.Fatalf("expected path /healthz, got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthzResponse{Version: "1.2.3"}) + })) + defer srv.Close() + + c := New(srv.URL) + resp, err := c.Healthz(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Version != "1.2.3" { + t.Fatalf("expected version 1.2.3, got %s", resp.Version) + } +} + +// --- License --- + +func TestGetLicenseInfo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/license/info" { + t.Fatalf("expected path /api/v1/license/info, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(LicenseInfo{ + LicenseID: "lic-123", + AppSlug: "my-app", + CustomerName: "Acme Corp", + LicenseType: "prod", + }) + })) + defer srv.Close() + + c := New(srv.URL) + info, err := c.GetLicenseInfo(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.LicenseID != "lic-123" { + t.Fatalf("expected licenseID lic-123, got %s", info.LicenseID) + } + if info.CustomerName != "Acme Corp" { + t.Fatalf("expected customerName Acme Corp, got %s", info.CustomerName) + } +} + +func TestGetLicenseFields(t *testing.T) { + expected := LicenseFields{ + "seat_count": { + Name: "seat_count", + Title: "Seat Count", + Value: float64(50), + ValueType: "Integer", + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/license/fields" { + t.Fatalf("expected path /api/v1/license/fields, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(expected) + })) + defer srv.Close() + + c := New(srv.URL) + fields, err := c.GetLicenseFields(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(fields)) + } + if fields["seat_count"].Title != "Seat Count" { + t.Fatalf("expected title Seat Count, got %s", fields["seat_count"].Title) + } +} + +func TestGetLicenseField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/license/fields/seat_count" { + t.Fatalf("expected path /api/v1/license/fields/seat_count, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(LicenseField{ + Name: "seat_count", + Title: "Seat Count", + Value: float64(50), + ValueType: "Integer", + }) + })) + defer srv.Close() + + c := New(srv.URL) + field, err := c.GetLicenseField(context.Background(), "seat_count") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if field.Name != "seat_count" { + t.Fatalf("expected name seat_count, got %s", field.Name) + } +} + +func TestGetLicenseField_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`"license field \"missing\" not found"`)) + })) + defer srv.Close() + + c := New(srv.URL) + _, err := c.GetLicenseField(context.Background(), "missing") + if err == nil { + t.Fatal("expected error for 404") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Fatalf("expected 404, got %d", apiErr.StatusCode) + } +} + +// --- App --- + +func TestGetAppInfo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/info" { + t.Fatalf("expected path /api/v1/app/info, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(AppInfo{ + InstanceID: "inst-1", + AppSlug: "my-app", + AppName: "My App", + AppStatus: StateReady, + ChannelName: "Stable", + }) + })) + defer srv.Close() + + c := New(srv.URL) + info, err := c.GetAppInfo(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.InstanceID != "inst-1" { + t.Fatalf("expected instanceID inst-1, got %s", info.InstanceID) + } + if info.AppStatus != StateReady { + t.Fatalf("expected status ready, got %s", info.AppStatus) + } +} + +func TestGetAppStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/status" { + t.Fatalf("expected path /api/v1/app/status, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(AppStatusResponse{ + AppStatus: AppStatus{ + AppSlug: "my-app", + State: StateReady, + ResourceStates: []ResourceState{ + {Kind: "Deployment", Name: "web", Namespace: "default", State: StateReady}, + }, + }, + }) + })) + defer srv.Close() + + c := New(srv.URL) + status, err := c.GetAppStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.AppStatus.State != StateReady { + t.Fatalf("expected state ready, got %s", status.AppStatus.State) + } + if len(status.AppStatus.ResourceStates) != 1 { + t.Fatalf("expected 1 resource state, got %d", len(status.AppStatus.ResourceStates)) + } +} + +func TestGetAppUpdates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/updates" { + t.Fatalf("expected path /api/v1/app/updates, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]ChannelRelease{ + {VersionLabel: "2.0.0", ReleaseNotes: "Major update"}, + }) + })) + defer srv.Close() + + c := New(srv.URL) + updates, err := c.GetAppUpdates(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(updates) != 1 { + t.Fatalf("expected 1 update, got %d", len(updates)) + } + if updates[0].VersionLabel != "2.0.0" { + t.Fatalf("expected versionLabel 2.0.0, got %s", updates[0].VersionLabel) + } +} + +func TestGetAppHistory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/history" { + t.Fatalf("expected path /api/v1/app/history, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(AppHistoryResponse{ + Releases: []Release{ + {VersionLabel: "1.0.0", DeployedAt: "2025-01-01T00:00:00Z"}, + }, + }) + })) + defer srv.Close() + + c := New(srv.URL) + history, err := c.GetAppHistory(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(history.Releases) != 1 { + t.Fatalf("expected 1 release, got %d", len(history.Releases)) + } + if history.Releases[0].VersionLabel != "1.0.0" { + t.Fatalf("expected versionLabel 1.0.0, got %s", history.Releases[0].VersionLabel) + } +} + +func TestSendCustomAppMetrics(t *testing.T) { + var gotMethod string + var gotBody SendCustomAppMetricsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/custom-metrics" { + t.Fatalf("expected path /api/v1/app/custom-metrics, got %s", r.URL.Path) + } + gotMethod = r.Method + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("") + })) + defer srv.Close() + + c := New(srv.URL) + err := c.SendCustomAppMetrics(context.Background(), CustomAppMetricsData{ + "active_users": float64(42), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", gotMethod) + } + if gotBody.Data["active_users"] != float64(42) { + t.Fatalf("expected active_users=42, got %v", gotBody.Data["active_users"]) + } +} + +func TestUpdateCustomAppMetrics(t *testing.T) { + var gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("") + })) + defer srv.Close() + + c := New(srv.URL) + err := c.UpdateCustomAppMetrics(context.Background(), CustomAppMetricsData{ + "active_users": float64(50), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPatch { + t.Fatalf("expected PATCH, got %s", gotMethod) + } +} + +func TestDeleteCustomAppMetricsKey(t *testing.T) { + var gotPath string + var gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := New(srv.URL) + err := c.DeleteCustomAppMetricsKey(context.Background(), "old_metric") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("expected DELETE, got %s", gotMethod) + } + if gotPath != "/api/v1/app/custom-metrics/old_metric" { + t.Fatalf("expected path /api/v1/app/custom-metrics/old_metric, got %s", gotPath) + } +} + +func TestSendAppInstanceTags(t *testing.T) { + var gotBody SendAppInstanceTagsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/app/instance-tags" { + t.Fatalf("expected path /api/v1/app/instance-tags, got %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("") + })) + defer srv.Close() + + c := New(srv.URL) + err := c.SendAppInstanceTags(context.Background(), InstanceTagData{ + Tags: map[string]string{"env": "production"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody.Data.Tags["env"] != "production" { + t.Fatalf("expected env=production, got %s", gotBody.Data.Tags["env"]) + } +} + +// --- Integration --- + +func TestGetIntegrationStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/integration/status" { + t.Fatalf("expected path /api/v1/integration/status, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(IntegrationStatusResponse{IsEnabled: true}) + })) + defer srv.Close() + + c := New(srv.URL) + status, err := c.GetIntegrationStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !status.IsEnabled { + t.Fatal("expected integration to be enabled") + } +} + +func TestPostIntegrationMockData(t *testing.T) { + var gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/integration/mock-data" { + t.Fatalf("expected path /api/v1/integration/mock-data, got %s", r.URL.Path) + } + gotMethod = r.Method + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := New(srv.URL) + mockData := map[string]interface{}{ + "appStatus": "ready", + } + err := c.PostIntegrationMockData(context.Background(), mockData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", gotMethod) + } +} + +func TestGetIntegrationMockData(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/integration/mock-data" { + t.Fatalf("expected path /api/v1/integration/mock-data, got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"appStatus":"ready","version":"v1"}`)) + })) + defer srv.Close() + + c := New(srv.URL) + data, err := c.GetIntegrationMockData(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal raw json: %v", err) + } + if parsed["appStatus"] != "ready" { + t.Fatalf("expected appStatus=ready, got %v", parsed["appStatus"]) + } +} + +func TestGetIntegrationMockData_Forbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + c := New(srv.URL) + _, err := c.GetIntegrationMockData(context.Background()) + if err == nil { + t.Fatal("expected error for 403") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 403 { + t.Fatalf("expected 403, got %d", apiErr.StatusCode) + } +} + +// --- Context Cancellation --- + +func TestContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthzResponse{Version: "1.0.0"}) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + c := New(srv.URL) + _, err := c.Healthz(ctx) + if err == nil { + t.Fatal("expected error for cancelled context") + } +} diff --git a/pkg/replicatedclient/health.go b/pkg/replicatedclient/health.go new file mode 100644 index 00000000..d335096a --- /dev/null +++ b/pkg/replicatedclient/health.go @@ -0,0 +1,12 @@ +package replicatedclient + +import "context" + +// Healthz returns the health check response including the server version. +func (c *Client) Healthz(ctx context.Context) (*HealthzResponse, error) { + var resp HealthzResponse + if err := c.doGet(ctx, "/healthz", &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/pkg/replicatedclient/integration.go b/pkg/replicatedclient/integration.go new file mode 100644 index 00000000..b03f0ec7 --- /dev/null +++ b/pkg/replicatedclient/integration.go @@ -0,0 +1,59 @@ +package replicatedclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// PostIntegrationMockData creates or updates mock data for integration mode. +// body should be a JSON-serializable value matching the mock data schema (v1 or v2). +func (c *Client) PostIntegrationMockData(ctx context.Context, body interface{}) error { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("replicated-sdk: encode mock data: %w", err) + } + + resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/integration/mock-data", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return readAPIError(resp) + } + return nil +} + +// GetIntegrationMockData retrieves the current mock data as raw JSON. +// The caller can unmarshal the result into the appropriate mock data version struct. +func (c *Client) GetIntegrationMockData(ctx context.Context) (json.RawMessage, error) { + resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/integration/mock-data", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, readAPIError(resp) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("replicated-sdk: read response body: %w", err) + } + return json.RawMessage(data), nil +} + +// GetIntegrationStatus returns whether integration mode is enabled. +func (c *Client) GetIntegrationStatus(ctx context.Context) (*IntegrationStatusResponse, error) { + var status IntegrationStatusResponse + if err := c.doGet(ctx, "/api/v1/integration/status", &status); err != nil { + return nil, err + } + return &status, nil +} diff --git a/pkg/replicatedclient/license.go b/pkg/replicatedclient/license.go new file mode 100644 index 00000000..6c7bf66e --- /dev/null +++ b/pkg/replicatedclient/license.go @@ -0,0 +1,34 @@ +package replicatedclient + +import ( + "context" + "net/url" +) + +// GetLicenseInfo returns the current license information. +func (c *Client) GetLicenseInfo(ctx context.Context) (*LicenseInfo, error) { + var info LicenseInfo + if err := c.doGet(ctx, "/api/v1/license/info", &info); err != nil { + return nil, err + } + return &info, nil +} + +// GetLicenseFields returns all custom license fields. +func (c *Client) GetLicenseFields(ctx context.Context) (LicenseFields, error) { + var fields LicenseFields + if err := c.doGet(ctx, "/api/v1/license/fields", &fields); err != nil { + return nil, err + } + return fields, nil +} + +// GetLicenseField returns a specific custom license field by name. +func (c *Client) GetLicenseField(ctx context.Context, fieldName string) (*LicenseField, error) { + var field LicenseField + path := "/api/v1/license/fields/" + url.PathEscape(fieldName) + if err := c.doGet(ctx, path, &field); err != nil { + return nil, err + } + return &field, nil +} diff --git a/pkg/replicatedclient/types.go b/pkg/replicatedclient/types.go new file mode 100644 index 00000000..4e219ecc --- /dev/null +++ b/pkg/replicatedclient/types.go @@ -0,0 +1,149 @@ +package replicatedclient + +import "time" + +// State represents the application state. +type State string + +const ( + StateReady State = "ready" + StateUpdating State = "updating" + StateDegraded State = "degraded" + StateUnavailable State = "unavailable" + StateMissing State = "missing" +) + +// AppInfo is the response from GET /api/v1/app/info. +type AppInfo struct { + InstanceID string `json:"instanceID"` + AppSlug string `json:"appSlug"` + AppName string `json:"appName"` + AppStatus State `json:"appStatus"` + HelmChartURL string `json:"helmChartURL,omitempty"` + CurrentRelease Release `json:"currentRelease"` + ChannelID string `json:"channelID"` + ChannelName string `json:"channelName"` + ChannelSequence int64 `json:"channelSequence"` + ReleaseSequence int64 `json:"releaseSequence"` +} + +// Release describes a deployed or available application release. +type Release struct { + VersionLabel string `json:"versionLabel"` + ReleaseNotes string `json:"releaseNotes"` + CreatedAt string `json:"createdAt"` + DeployedAt string `json:"deployedAt"` + HelmReleaseName string `json:"helmReleaseName,omitempty"` + HelmReleaseRevision int `json:"helmReleaseRevision,omitempty"` + HelmReleaseNamespace string `json:"helmReleaseNamespace,omitempty"` +} + +// ResourceState describes the state of a single Kubernetes resource. +type ResourceState struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` + State State `json:"state"` +} + +// AppStatus contains the full application status with resource-level detail. +type AppStatus struct { + AppSlug string `json:"appSlug"` + ResourceStates []ResourceState `json:"resourceStates"` + UpdatedAt time.Time `json:"updatedAt"` + State State `json:"state"` + Sequence int64 `json:"sequence"` +} + +// AppStatusResponse is the response from GET /api/v1/app/status. +type AppStatusResponse struct { + AppStatus AppStatus `json:"appStatus"` +} + +// AppHistoryResponse is the response from GET /api/v1/app/history. +type AppHistoryResponse struct { + Releases []Release `json:"releases"` +} + +// ChannelRelease describes an available upstream release. +type ChannelRelease struct { + VersionLabel string `json:"versionLabel"` + CreatedAt string `json:"createdAt"` + ReleaseNotes string `json:"releaseNotes"` +} + +// LicenseInfo is the response from GET /api/v1/license/info. +type LicenseInfo struct { + LicenseID string `json:"licenseID"` + AppSlug string `json:"appSlug"` + ChannelName string `json:"channelName"` + CustomerID string `json:"customerID"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + LicenseType string `json:"licenseType"` + ChannelID string `json:"channelID"` + LicenseSequence int64 `json:"licenseSequence"` + IsAirgapSupported bool `json:"isAirgapSupported"` + IsGitOpsSupported bool `json:"isGitOpsSupported"` + IsIdentityServiceSupported bool `json:"isIdentityServiceSupported"` + IsGeoaxisSupported bool `json:"isGeoaxisSupported"` + IsSnapshotSupported bool `json:"isSnapshotSupported"` + IsSupportBundleUploadSupported bool `json:"isSupportBundleUploadSupported"` + IsSemverRequired bool `json:"isSemverRequired"` + Endpoint string `json:"endpoint"` + Entitlements interface{} `json:"entitlements,omitempty"` +} + +// LicenseFieldSignature contains version-specific signatures for a license field. +type LicenseFieldSignature struct { + V1 string `json:"v1,omitempty"` + V2 string `json:"v2,omitempty"` +} + +// LicenseField describes a single custom license field. +type LicenseField struct { + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Value interface{} `json:"value,omitempty"` + ValueType string `json:"valueType,omitempty"` + IsHidden bool `json:"isHidden,omitempty"` + Signature LicenseFieldSignature `json:"signature,omitempty"` +} + +// LicenseFields is a map of field name to LicenseField. +type LicenseFields map[string]LicenseField + +// CustomAppMetricsData holds custom application metrics (scalar values only). +type CustomAppMetricsData map[string]interface{} + +// SendCustomAppMetricsRequest is the request body for POST/PATCH /api/v1/app/custom-metrics. +type SendCustomAppMetricsRequest struct { + Data CustomAppMetricsData `json:"data"` +} + +// InstanceTagData holds instance tag information. +type InstanceTagData struct { + Force bool `json:"force"` + Tags map[string]string `json:"tags"` +} + +// SendAppInstanceTagsRequest is the request body for POST /api/v1/app/instance-tags. +type SendAppInstanceTagsRequest struct { + Data InstanceTagData `json:"data"` +} + +// IntegrationStatusResponse is the response from GET /api/v1/integration/status. +type IntegrationStatusResponse struct { + IsEnabled bool `json:"isEnabled"` +} + +// HealthzResponse is the response from GET /healthz. +type HealthzResponse struct { + Version string `json:"version"` +} + +// ErrorResponse is the standard error response returned by the API. +type ErrorResponse struct { + Error string `json:"error,omitempty"` +} diff --git a/pkg/replicatedclient/upstream.go b/pkg/replicatedclient/upstream.go new file mode 100644 index 00000000..5eb1b81c --- /dev/null +++ b/pkg/replicatedclient/upstream.go @@ -0,0 +1,104 @@ +package replicatedclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const defaultUpstreamEndpoint = "https://replicated.app" + +// UpstreamClient talks directly to the Replicated upstream API (replicated.app) +// without requiring a local SDK service. +type UpstreamClient struct { + endpoint string + licenseID string + httpClient *http.Client +} + +// UpstreamOption configures an UpstreamClient. +type UpstreamOption func(*UpstreamClient) + +// WithEndpoint overrides the default upstream endpoint (https://replicated.app). +func WithEndpoint(endpoint string) UpstreamOption { + return func(c *UpstreamClient) { + c.endpoint = strings.TrimRight(endpoint, "/") + } +} + +// WithUpstreamHTTPClient sets a custom http.Client for upstream requests. +func WithUpstreamHTTPClient(hc *http.Client) UpstreamOption { + return func(c *UpstreamClient) { + c.httpClient = hc + } +} + +// NewUpstream creates an UpstreamClient that talks directly to the Replicated API. +// The licenseID is used for Basic Auth (licenseID:licenseID). +func NewUpstream(licenseID string, opts ...UpstreamOption) *UpstreamClient { + c := &UpstreamClient{ + endpoint: defaultUpstreamEndpoint, + licenseID: licenseID, + httpClient: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// doGet performs an authenticated GET request and decodes the JSON response into dest. +func (c *UpstreamClient) doGet(ctx context.Context, path string, dest interface{}) error { + reqURL := c.endpoint + path + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return fmt.Errorf("replicated-sdk upstream: create request: %w", err) + } + + req.SetBasicAuth(c.licenseID, c.licenseID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("replicated-sdk upstream: execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return &APIError{ + StatusCode: resp.StatusCode, + Body: strings.TrimSpace(string(bodyBytes)), + } + } + + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("replicated-sdk upstream: decode response: %w", err) + } + } + return nil +} + +// GetLicenseFields returns all custom license fields from the upstream API. +func (c *UpstreamClient) GetLicenseFields(ctx context.Context) (LicenseFields, error) { + var fields LicenseFields + if err := c.doGet(ctx, "/license/fields", &fields); err != nil { + return nil, err + } + return fields, nil +} + +// GetLicenseField returns a specific custom license field by name from the upstream API. +func (c *UpstreamClient) GetLicenseField(ctx context.Context, fieldName string) (*LicenseField, error) { + var field LicenseField + path := "/license/field/" + url.PathEscape(fieldName) + if err := c.doGet(ctx, path, &field); err != nil { + return nil, err + } + return &field, nil +} diff --git a/pkg/replicatedclient/upstream_test.go b/pkg/replicatedclient/upstream_test.go new file mode 100644 index 00000000..1e156969 --- /dev/null +++ b/pkg/replicatedclient/upstream_test.go @@ -0,0 +1,182 @@ +package replicatedclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewUpstream(t *testing.T) { + c := NewUpstream("lic-123") + if c.endpoint != "https://replicated.app" { + t.Fatalf("expected default endpoint, got %s", c.endpoint) + } + if c.licenseID != "lic-123" { + t.Fatalf("expected licenseID lic-123, got %s", c.licenseID) + } +} + +func TestNewUpstream_WithEndpoint(t *testing.T) { + c := NewUpstream("lic-123", WithEndpoint("https://custom.replicated.app/")) + if c.endpoint != "https://custom.replicated.app" { + t.Fatalf("expected trailing slash stripped, got %s", c.endpoint) + } +} + +func TestNewUpstream_WithHTTPClient(t *testing.T) { + custom := &http.Client{} + c := NewUpstream("lic-123", WithUpstreamHTTPClient(custom)) + if c.httpClient != custom { + t.Fatal("expected custom http client") + } +} + +func TestUpstream_BasicAuth(t *testing.T) { + var gotUser, gotPass string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, _ = r.BasicAuth() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(LicenseFields{}) + })) + defer srv.Close() + + c := NewUpstream("my-license", WithEndpoint(srv.URL)) + _, err := c.GetLicenseFields(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotUser != "my-license" { + t.Fatalf("expected basic auth user 'my-license', got %q", gotUser) + } + if gotPass != "my-license" { + t.Fatalf("expected basic auth pass 'my-license', got %q", gotPass) + } +} + +// --- License Fields --- + +func TestUpstream_GetLicenseFields(t *testing.T) { + expected := LicenseFields{ + "seat_count": { + Name: "seat_count", + Title: "Seat Count", + Value: float64(50), + ValueType: "Integer", + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/license/fields" { + t.Fatalf("expected path /license/fields, got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(expected) + })) + defer srv.Close() + + c := NewUpstream("lic-123", WithEndpoint(srv.URL)) + fields, err := c.GetLicenseFields(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(fields)) + } + if fields["seat_count"].Title != "Seat Count" { + t.Fatalf("expected title Seat Count, got %s", fields["seat_count"].Title) + } +} + +func TestUpstream_GetLicenseField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/license/field/seat_count" { + t.Fatalf("expected path /license/field/seat_count, got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(LicenseField{ + Name: "seat_count", + Title: "Seat Count", + Value: float64(50), + ValueType: "Integer", + }) + })) + defer srv.Close() + + c := NewUpstream("lic-123", WithEndpoint(srv.URL)) + field, err := c.GetLicenseField(context.Background(), "seat_count") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if field.Name != "seat_count" { + t.Fatalf("expected name seat_count, got %s", field.Name) + } + if field.Value != float64(50) { + t.Fatalf("expected value 50, got %v", field.Value) + } +} + +func TestUpstream_GetLicenseField_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer srv.Close() + + c := NewUpstream("lic-123", WithEndpoint(srv.URL)) + _, err := c.GetLicenseField(context.Background(), "missing") + if err == nil { + t.Fatal("expected error for 404") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Fatalf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestUpstream_GetLicenseFields_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("invalid license")) + })) + defer srv.Close() + + c := NewUpstream("bad-license", WithEndpoint(srv.URL)) + _, err := c.GetLicenseFields(context.Background()) + if err == nil { + t.Fatal("expected error for 401") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 401 { + t.Fatalf("expected 401, got %d", apiErr.StatusCode) + } +} + +func TestUpstream_ContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(LicenseFields{}) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + c := NewUpstream("lic-123", WithEndpoint(srv.URL)) + _, err := c.GetLicenseFields(ctx) + if err == nil { + t.Fatal("expected error for cancelled context") + } +}