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
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

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

> **Status:** v0.1.0-dev (PR1 scaffold). Manages connection profiles. `query` / `write` / `import` / `db` / `auth` / `cluster` subcommands ship in follow-up PRs.
> **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.

## Why

Today operating Arc means hand-crafting `curl` calls: copying the bootstrap token from a stderr banner, building JSON query bodies, remembering header names like `x-arc-database`, and decoding column-major responses by eye. `arcctl` replaces that with a familiar CLI workflow modeled on `influx`, `kubectl`, and `clickhouse-client`.
Today operating Arc means hand-crafting `curl` calls: copying the bootstrap token from a stderr banner, building JSON query bodies, remembering header names like `x-arc-database`, and decoding `{"columns":[...],"data":[...]}` responses by eye. `arcctl` replaces that with a familiar CLI workflow modeled on `influx`, `kubectl`, and `clickhouse-client`.

## Install

Expand Down Expand Up @@ -79,18 +79,71 @@ If none are set, commands fail with a clear "no active connection" error.

Honors `ARCCTL_CONFIG` env var for test/CI overrides; otherwise `~/.arcctl/config.toml`.

## Querying

```bash
# Pretty table (default)
arcctl query "SELECT host, value FROM cpu ORDER BY value LIMIT 10"

# Override the database for one call
arcctl query --database metrics "SELECT count(*) FROM cpu"

# Read SQL from a file
arcctl query -f reports/p99.sql

# Pipe SQL from another command
echo "SELECT 1" | arcctl query

# Machine-parseable output
arcctl query "SELECT * FROM cpu" -o json | jq '.data[0]'
arcctl query "SELECT * FROM cpu" -o csv > out.csv

# Arrow IPC stream — feed it to pyarrow / duckdb / polars
arcctl query "SELECT * FROM cpu" -o arrow | duckdb -c "SELECT * FROM read_arrow('/dev/stdin')"
```

The output formats:

- `-o table` (default) — pretty-printed bordered table; honors `--no-header` and `--limit N`
- `-o json` — the raw `{"columns":[...],"data":[...]}` response, jq-friendly
- `-o csv` — RFC 4180 with a header row by default
- `-o arrow` — binary Arrow IPC stream on stdout; server-side execution time goes to stderr

## Writing

```bash
# Stdin pipe (most common in CI / log forwarders)
echo "cpu,host=server-1 value=42.5 $(date +%s)000000000" | arcctl write

# From a file
arcctl write -f payload.lp --database metrics --precision ms

# Explicit precision (default is nanoseconds, matching the server)
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.

## TLS

For HTTPS endpoints, certificate verification is on by default. To skip verification (lab / self-signed certs only), use either:

- `--insecure` on a single command, or
- `insecure_tls = true` in the connection profile (set once via `arcctl config create --insecure`)

When verification is skipped, a `WARNING:` line is printed to stderr. The flag is a no-op on `http://` endpoints and the warning is suppressed.

## Roadmap

This repo is being built in [phased PRs](https://github.com/Basekick-Labs/arcctl/pulls):

- **PR1** (this) — scaffold, `config` subcommand tree, multi-connection store
- **PR2** — `arcctl query`, `arcctl write`
- ~~**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`
- **PR4** — `arcctl import {csv,lp,parquet,msgpack}`
- **PR5** — `arcctl auth {token,whoami}`
- **PR6** — `arcctl cluster {status,nodes}`, `arcctl compaction`, `arcctl retention`
- **PR7** — `-o csv` and `-o arrow` output formats
- **PR8** — release workflow + Homebrew tap + multi-arch Docker, cut v1.0.0
- **PR7** — release workflow + Homebrew tap + multi-arch Docker, cut v1.0.0

Target: arcctl 1.x speaks to Arc 26.06+.

Expand Down
101 changes: 101 additions & 0 deletions internal/client/arrow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package client

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

// ArrowResponse wraps the raw Arrow IPC stream from /api/v1/query/arrow.
//
// Wire contract (verified against arc/internal/api/query_arrow.go):
// - On error: HTTP non-2xx + JSON `{"success": false, "error": "..."}`.
// - On success: HTTP 200 + `Content-Type: application/vnd.apache.arrow.stream`,
// body is a streaming Arrow IPC payload. Server-side execution time
// is emitted as the `Arc-Execution-Time-Ms` HTTP trailer, available
// only after the body has been read to EOF.
//
// The caller is responsible for Close()-ing this response (which closes
// the underlying HTTP body); call ExecutionTimeMs() only after reading
// Body to EOF.
type ArrowResponse struct {
// Body is the Arrow IPC stream. Caller reads/copies as needed.
Body io.ReadCloser

// resp is held so we can read trailers after the body's drained.
resp *http.Response
}

// QueryArrow runs a SQL query and returns the response wrapping the
// Arrow IPC stream. The caller MUST Close() the returned response.
//
// On a 4xx/5xx response, the error is decoded from the JSON body
// (same shape as QueryJSON) and the response body is fully consumed
// + closed before the function returns — callers don't need to clean
// up on the error path.
func (c *Client) QueryArrow(ctx context.Context, sql, database string) (*ArrowResponse, error) {
body, err := json.Marshal(queryRequest{SQL: sql})
if err != nil {
return nil, fmt.Errorf("encode query: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Endpoint+"/api/v1/query/arrow", 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/vnd.apache.arrow.stream")
c.setCommonHeaders(req, database)

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("arrow query: %w", err)
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Drain + close the error body ourselves so the caller's
// error-path doesn't need a defer.
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
_ = resp.Body.Close()
return nil, decodeServerError(resp.StatusCode, respBody)
}

return &ArrowResponse{
Body: resp.Body,
resp: resp,
}, nil
}

// Close closes the underlying HTTP body. nil-safe (returns nil if the
// response or its Body is nil). Callers should defer this once and not
// double-close; while net/http's bodyEOFSignal happens to be idempotent
// today, the io.ReadCloser contract does not require it.
func (a *ArrowResponse) Close() error {
if a == nil || a.Body == nil {
return nil
}
return a.Body.Close()
}

// ExecutionTimeMs returns the server's execution time from the
// `Arc-Execution-Time-Ms` HTTP trailer. Only valid after Body has been
// read to EOF — earlier calls return (0, false). Calling before EOF is
// not an error, just a missed read.
func (a *ArrowResponse) ExecutionTimeMs() (int64, bool) {
if a == nil || a.resp == nil {
return 0, false
}
v := a.resp.Trailer.Get(ArrowExecutionTimeTrailer)
if v == "" {
return 0, false
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return n, true
}
126 changes: 126 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Package client is the HTTP wire-format adapter for the Arc server.
//
// Arc speaks JSON-over-HTTP for queries (row-major response, see
// QueryResult), Arrow IPC streaming for large query results, and
// line-protocol POST for writes. This package wraps those endpoints
// so the command layer doesn't have to know wire-format details.
//
// Conventions:
// - The `x-arc-database` header selects the target database for
// every request (query + write). If empty the server defaults to
// "default".
// - Authorization is always `Bearer <token>`.
// - Errors come back as JSON `{"success": false, "error": "..."}`
// on the query endpoints and `{"error": "..."}` on writes. The
// Do* helpers in this package normalise both into a Go error.
//
// The client deliberately uses a configured `*http.Client` rather than
// `http.DefaultClient` so timeouts and TLS verification are explicit.
package client

import (
"crypto/tls"
"fmt"
"net"
"net/http"
"strings"
"time"
)

// HeaderDatabase is the request header Arc uses to select the target
// database for both query and write endpoints.
const HeaderDatabase = "x-arc-database"

// ArrowExecutionTimeTrailer is the HTTP response trailer Arc emits at
// the end of an Arrow IPC stream carrying server-side execution time
// in milliseconds. Clients must read the response body to EOF before
// reading this trailer (HTTP/1.1 trailer semantics).
const ArrowExecutionTimeTrailer = "Arc-Execution-Time-Ms"

// Config holds the per-client tuning knobs. Endpoint and Token are
// required; everything else has a sensible default.
type Config struct {
// Endpoint is the Arc HTTP base URL, e.g. "http://localhost:8000"
// (no trailing slash; we add the API paths ourselves).
Endpoint string

// Token is the Bearer token from Arc's first-run banner.
Token string

// Database is the default database name to send via x-arc-database
// when the per-call override is empty. May itself be empty, in
// which case Arc defaults to "default" server-side.
Database string

// InsecureTLS skips certificate verification. Off by default.
// When true, the caller is responsible for warning the user.
InsecureTLS bool

// Timeout is the per-request HTTP timeout. Default 60s.
// Writes and small queries finish well under this; for large
// `-o arrow` streams we override on the request.
Timeout time.Duration
}

// Client is a stateful adapter around *http.Client + auth headers.
// One Client per Arc cluster; safe for concurrent use.
type Client struct {
cfg Config
http *http.Client
}

// New builds a Client. Returns an error only on missing required
// config; transport construction never fails.
func New(cfg Config) (*Client, error) {
if cfg.Endpoint == "" {
return nil, fmt.Errorf("client: endpoint required")
}
if cfg.Token == "" {
return nil, fmt.Errorf("client: token required")
}
cfg.Endpoint = strings.TrimRight(cfg.Endpoint, "/")
if cfg.Timeout == 0 {
cfg.Timeout = 60 * time.Second
}

// Clone DefaultTransport so we don't mutate the package global.
// We need to set TLSClientConfig per-Client (InsecureTLS varies).
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.InsecureTLS} //nolint:gosec // opt-in via --insecure / insecure_tls
transport.DialContext = (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext

return &Client{
cfg: cfg,
http: &http.Client{
Transport: transport,
Timeout: cfg.Timeout,
},
}, nil
}

// Endpoint returns the base URL the client is configured against.
// Useful for `-v` verbose output.
func (c *Client) Endpoint() string { return c.cfg.Endpoint }

// 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 {
if override != "" {
return override
}
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.
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")
}
Loading