abc81e60c8
Single Go binary with embedded Bootstrap 3 + jQuery UI, querying logs.raw over the ClickHouse HTTP interface as the readonly logreader user. Runs behind oauth2-proxy; the app does no auth itself. Server-side enforced time bounds (15m default, 72h max), parameterized queries, raw-SQL WHERE fragment wrapped with enforced bounds and LIMIT, tail polling with a clamped cursor, facets, healthz. Woodpecker build/test plus tag-driven image push to artifactapi docker-internal.
92 lines
2.1 KiB
Go
92 lines
2.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// maxExecutionSeconds bounds every ClickHouse query server-side.
|
|
const maxExecutionSeconds = 30
|
|
|
|
type chClient struct {
|
|
url string
|
|
user string
|
|
password string
|
|
http *http.Client
|
|
}
|
|
|
|
func newCHClient(chURL, user, password string) *chClient {
|
|
return &chClient{
|
|
url: strings.TrimRight(chURL, "/"),
|
|
user: user,
|
|
password: password,
|
|
http: &http.Client{Timeout: (maxExecutionSeconds + 5) * time.Second},
|
|
}
|
|
}
|
|
|
|
type chColumn struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type chResult struct {
|
|
Meta []chColumn `json:"meta"`
|
|
Data []map[string]any `json:"data"`
|
|
Rows int `json:"rows"`
|
|
}
|
|
|
|
// query POSTs sql to the ClickHouse HTTP interface. Every value in params is
|
|
// sent as a bound query parameter (param_<name>), never interpolated.
|
|
func (c *chClient) query(ctx context.Context, sql string, params map[string]string) (*chResult, error) {
|
|
q := url.Values{}
|
|
q.Set("max_execution_time", strconv.Itoa(maxExecutionSeconds))
|
|
q.Set("output_format_json_quote_64bit_integers", "0")
|
|
for k, v := range params {
|
|
q.Set("param_"+k, v)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/?"+q.Encode(), strings.NewReader(sql))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-ClickHouse-User", c.user)
|
|
req.Header.Set("X-ClickHouse-Key", c.password)
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg := strings.TrimSpace(string(body))
|
|
if len(msg) > 500 {
|
|
msg = msg[:500]
|
|
}
|
|
return nil, fmt.Errorf("clickhouse: %s: %s", resp.Status, msg)
|
|
}
|
|
|
|
var res chResult
|
|
if err := json.Unmarshal(body, &res); err != nil {
|
|
return nil, fmt.Errorf("clickhouse: decode response: %w", err)
|
|
}
|
|
return &res, nil
|
|
}
|
|
|
|
func (c *chClient) ping(ctx context.Context) error {
|
|
_, err := c.query(ctx, "SELECT 1 FORMAT JSON", nil)
|
|
return err
|
|
}
|