Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

CLI for [Arc](https://github.com/Basekick-Labs/arc) — operator-facing client for Arc time-series databases.

> **Status:** v0.2.0-dev (PR2). Manages connection profiles, runs SQL queries, and writes line protocol with table / JSON / CSV / Arrow IPC output. `import` / `db` / `auth` / `cluster` subcommands ship in follow-up PRs.
> **Status:** v0.3.0-dev (PR3). Manages connection profiles, runs SQL queries, writes line protocol, and administers databases + measurements. `import` / `auth` / `cluster` subcommands ship in follow-up PRs.

## Why

Expand Down Expand Up @@ -124,6 +124,34 @@ echo "cpu v=1 1700000000" | arcctl write --precision s

`--precision` accepts `ns`, `us`, `ms`, or `s` (anything else is rejected client-side before the request goes out). The body is streamed end-to-end — `cat huge.lp | arcctl write` never buffers the whole payload in memory.

## Database & measurement admin

```bash
# List every database the active token can see
arcctl db list

# Inspect one database (info + its measurements)
arcctl db show production

# Create an empty database (server validates name: alphanumeric + `_-`,
# max 64 chars, "system" / "internal" / "_internal" are reserved)
arcctl db create metrics

# Drop a database and ALL its files. Prompts for y/N; pass --yes to
# skip in scripts. The server requires delete.enabled=true in arc.toml
# AND an admin token — if either is missing the server's error message
# surfaces verbatim ("Set delete.enabled=true in arc.toml to enable.").
arcctl db drop old_metrics
arcctl db drop --yes ci_scratch # no prompt

# List measurements inside a database (same data shown by `db show`,
# different default view)
arcctl measurement list --database metrics
arcctl measurement list -c prod --database logs -o json
```

`db list`, `db show`, and `measurement list` all support `-o table|json|csv` (no `-o arrow` — these endpoints return JSON, not Arrow IPC).

## TLS

For HTTPS endpoints, certificate verification is on by default. To skip verification (lab / self-signed certs only), use either:
Expand All @@ -139,7 +167,7 @@ This repo is being built in [phased PRs](https://github.com/Basekick-Labs/arcctl

- ~~**PR1** — scaffold, `config` subcommand tree, multi-connection store~~ ✅ shipped
- ~~**PR2** — `arcctl query`, `arcctl write`, output formats: table/json/csv/arrow~~ ✅ shipped
- **PR3** — `arcctl db {list,create,drop,show}`, `arcctl measurement list`
- ~~**PR3** — `arcctl db {list,show,create,drop}`, `arcctl measurement list`~~ ✅ shipped
- **PR4** — `arcctl import {csv,lp,parquet,msgpack}`
- **PR5** — `arcctl auth {token,whoami}`
- **PR6** — `arcctl cluster {status,nodes}`, `arcctl compaction`, `arcctl retention`
Expand Down
28 changes: 25 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ func New(cfg Config) (*Client, error) {
// Useful for `-v` verbose output.
func (c *Client) Endpoint() string { return c.cfg.Endpoint }

// DefaultDatabase returns the database the client falls back to when
// a per-call override is empty. May itself be empty (in which case
// Arc applies its server-side default, "default"). Exposed so the
// command layer can implement "use the connection's default DB"
// semantics for endpoints whose URL is database-scoped (e.g.
// /api/v1/databases/:name/measurements) where the server can't
// apply a fallback for us.
func (c *Client) DefaultDatabase() string { return c.cfg.Database }

// resolveDatabase picks the per-call override if set, else the
// client's default. Both can be empty (Arc falls back to "default").
func (c *Client) resolveDatabase(override string) string {
Expand All @@ -114,13 +123,26 @@ func (c *Client) resolveDatabase(override string) string {
return c.cfg.Database
}

// setCommonHeaders writes Authorization + (optional) x-arc-database
// onto a *http.Request. Used by every Do* call so the headers are set
// in exactly one place.
// setCommonHeaders writes Authorization, x-arc-database (per the
// resolveDatabase rules), and User-Agent onto a *http.Request. Use
// for per-database endpoints (query, write).
func (c *Client) setCommonHeaders(req *http.Request, database string) {
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
if db := c.resolveDatabase(database); db != "" {
req.Header.Set(HeaderDatabase, db)
}
req.Header.Set("User-Agent", "arcctl")
}

// setCrossDBHeaders writes Authorization + User-Agent but NEVER sends
// x-arc-database. Use for endpoints whose URL is itself the database
// selector (e.g. /api/v1/databases, /api/v1/databases/:name/...) or
// that are inherently cross-database (the list endpoints).
//
// Distinct from setCommonHeaders so the no-DB case is intentional, not
// accidental — calling setCommonHeaders(req, "") on a client with a
// non-empty default Database would silently include the wrong header.
func (c *Client) setCrossDBHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("User-Agent", "arcctl")
}
21 changes: 21 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,27 @@ func TestQueryJSON_DatabaseOverride(t *testing.T) {
}
}

func TestQueryJSON_FallsBackToClientDefault(t *testing.T) {
// Pinning the contract: when per-call override is empty AND the
// client has a configured default DB, the request DOES send the
// x-arc-database header with that default. Cross-DB callers MUST
// use setCrossDBHeaders instead — see database.go.
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("x-arc-database")
_, _ = io.WriteString(w, `{"columns":[],"data":[],"row_count":0}`)
}))
defer srv.Close()

c := freshClient(t, srv, "metrics") // client default = "metrics"
if _, err := c.QueryJSON(context.Background(), "SELECT 1", ""); err != nil {
t.Fatalf("QueryJSON: %v", err)
}
if got != "metrics" {
t.Errorf("expected x-arc-database=metrics (client default), got %q", got)
}
}

func TestQueryJSON_NoDatabaseHeaderWhenBothEmpty(t *testing.T) {
// Empty client default + empty per-call -> header should be absent
// so Arc applies its server-side default ("default").
Expand Down
223 changes: 223 additions & 0 deletions internal/client/database.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)

// DatabaseInfo is one database in the response from /api/v1/databases.
// Mirrors the server struct in arc/internal/api/databases.go.
type DatabaseInfo struct {
Name string `json:"name"`
MeasurementCount int `json:"measurement_count"`
CreatedAt string `json:"created_at,omitempty"`
}

// DatabaseListResponse is the response body of GET /api/v1/databases.
type DatabaseListResponse struct {
Databases []DatabaseInfo `json:"databases"`
Count int `json:"count"`
}

// DatabaseMeasurement is one measurement inside a database.
type DatabaseMeasurement struct {
Name string `json:"name"`
FileCount int `json:"file_count,omitempty"`
}

// MeasurementListResponse is the response body of
// GET /api/v1/databases/:name/measurements.
type MeasurementListResponse struct {
Database string `json:"database"`
Measurements []DatabaseMeasurement `json:"measurements"`
Count int `json:"count"`
}

// createDatabaseRequest is the on-the-wire body shape for
// POST /api/v1/databases.
type createDatabaseRequest struct {
Name string `json:"name"`
}

// ListDatabases returns every database the caller can see, with
// measurement counts pre-computed by the server.
func (c *Client) ListDatabases(ctx context.Context) (*DatabaseListResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.Endpoint+"/api/v1/databases", nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
// Cross-database operation; the x-arc-database header is never
// applicable here, so we use setCrossDBHeaders to make the
// absence explicit.
c.setCrossDBHeaders(req)

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("list databases: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, decodeWriteError(resp.StatusCode, body)
}
var out DatabaseListResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}

// GetDatabase returns metadata for one database. Returns a
// recognisable "not found" error for HTTP 404 so the command layer can
// distinguish "missing" from "broken."
func (c *Client) GetDatabase(ctx context.Context, name string) (*DatabaseInfo, error) {
if name == "" {
return nil, fmt.Errorf("database name is required")
}
u := c.cfg.Endpoint + "/api/v1/databases/" + url.PathEscape(name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
c.setCrossDBHeaders(req)

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("get database: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, decodeWriteError(resp.StatusCode, body)
}
var out DatabaseInfo
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}

// CreateDatabase creates a new empty database. Returns the server's
// freshly-created metadata on HTTP 201. The server enforces name
// validation (alphanumeric + `_-`, ≤ 64 chars, not a reserved name like
// "system" / "internal"); arcctl forwards whatever the user typed and
// lets the server produce the canonical error.
func (c *Client) CreateDatabase(ctx context.Context, name string) (*DatabaseInfo, error) {
if name == "" {
return nil, fmt.Errorf("database name is required")
}
body, err := json.Marshal(createDatabaseRequest{Name: name})
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Endpoint+"/api/v1/databases", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
c.setCrossDBHeaders(req)

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("create database: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, decodeWriteError(resp.StatusCode, respBody)
}
var out DatabaseInfo
if err := json.Unmarshal(respBody, &out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}

// DeleteDatabase removes a database and ALL its data from the server.
// Always passes `?confirm=true` because the server requires it; the
// CLI's safety story is layered on top (the command refuses unless the
// user explicitly invokes `db drop`).
//
// The server may refuse with HTTP 403 if `delete.enabled` is false in
// arc.toml; the error message is surfaced verbatim so the operator
// knows to enable the flag server-side.
func (c *Client) DeleteDatabase(ctx context.Context, name string) error {
if name == "" {
return fmt.Errorf("database name is required")
}
u := c.cfg.Endpoint + "/api/v1/databases/" + url.PathEscape(name) + "?confirm=true"
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
c.setCrossDBHeaders(req)

resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("delete database: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Drain any small body the server may include.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return decodeWriteError(resp.StatusCode, body)
}

// ListMeasurements returns measurements inside a single database via
// GET /api/v1/databases/:name/measurements.
func (c *Client) ListMeasurements(ctx context.Context, database string) (*MeasurementListResponse, error) {
if database == "" {
return nil, fmt.Errorf("database name is required")
}
u := c.cfg.Endpoint + "/api/v1/databases/" + url.PathEscape(database) + "/measurements"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
c.setCrossDBHeaders(req)

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("list measurements: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, decodeWriteError(resp.StatusCode, body)
}
var out MeasurementListResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}
Loading