From 12a6d4d9b8a577b4ff1a5c2fdcb3c4f4d9f36236 Mon Sep 17 00:00:00 2001 From: Sergio Rua Date: Wed, 8 Jul 2026 17:53:56 +0100 Subject: [PATCH 1/2] feat(proxy): add OTel schema mode and harden SQL escaping Add a schema Mode/Tables config so the proxy can serve metadata, series and queries over OpenTelemetry-style ClickHouse tables via UNION ALL, and fold scalar-only binary expressions to fix a nil-plan panic on expressions like 1+1. Security: chEscape now doubles backslashes before escaping single quotes so a trailing backslash in an unauthenticated request parameter (match[], label name) can no longer break out of a ClickHouse string literal and inject SQL. Signed-off-by: Sergio Rua --- config/config.go | 11 ++++ eval/evaluator.go | 20 ++++++ proxy/cmd/proxy/main.go | 2 + proxy/config/config.go | 5 ++ proxy/config/proxy.go | 2 + proxy/server/handlers/meta.go | 112 ++++++++++++++++++++++++++++++-- proxy/server/handlers/series.go | 21 ++++-- translator/plan.go | 100 ++++++++++++++++++++++++++++ 8 files changed, 265 insertions(+), 8 deletions(-) diff --git a/config/config.go b/config/config.go index f53e30f..bffac7f 100644 --- a/config/config.go +++ b/config/config.go @@ -32,6 +32,17 @@ type SchemaConfig struct { ExtractedColumns []ExtractedColumn `yaml:"extracted_columns"` Downsampling DownsamplingConfig `yaml:"downsampling"` TimestampIsInt bool `yaml:"timestamp_is_int"` // true if timestamp column is Int64 (unix_milli), false if DateTime64 + + // Mode selects the read model. "" (default) is the Prometheus two-table + // layout (samples + time_series JOIN). "otel" reads the OpenTelemetry + // ClickHouse-exporter metric tables directly (single wide row per datapoint: + // MetricName / TimeUnix / Value / Attributes+ResourceAttributes Maps), with + // no fingerprint column or time_series table — fingerprint and labels are + // computed in SQL. See renderOTel in the translator. + Mode string `yaml:"mode"` + // Tables lists the OTel metric tables to UNION in "otel" mode (e.g. + // otel_metrics_gauge_dist, otel_metrics_sum_dist). Ignored unless Mode=="otel". + Tables []string `yaml:"tables"` } type DownsamplingConfig struct { diff --git a/eval/evaluator.go b/eval/evaluator.go index b77e46c..367f228 100644 --- a/eval/evaluator.go +++ b/eval/evaluator.go @@ -54,6 +54,12 @@ func (ev *Evaluator) EvalPlan( step time.Duration, ) (*types.QueryResult, error) { + // Guard: a nil plan (e.g. a folded-out binary operand) must not panic — + // surface a clean error instead of a nil-pointer dereference. + if plan == nil { + return nil, fmt.Errorf("nil plan") + } + if plan.IsScalar { return &types.QueryResult{ Type: "vector", @@ -139,6 +145,20 @@ func (ev *Evaluator) evalBinaryPlan( start, end time.Time, step time.Duration, ) (*types.QueryResult, error) { + // Both sides scalar literals (e.g. `1+1`, Grafana's datasource health check). + // transpileBinary nils out both LHS and RHS in this case, so the scalar-RHS + // branch below would dereference a nil LHS. Fold the constant directly. + if plan.IsScalarLHS && plan.IsScalarRHS { + lhsRes := &types.QueryResult{ + Type: "vector", + Vector: types.Vector{{F: plan.ScalarLHS, T: start.UnixMilli()}}, + } + result := applyScalarBinary(lhsRes, plan.ScalarRHS, plan.BinaryOp, plan.ReturnBool, false) + if len(plan.MathChain) > 0 { + applyMathChainResult(result, plan.MathChain) + } + return result, nil + } // Handle scalar RHS (e.g. expr > 0, exp(rate/1000)) if plan.IsScalarRHS { lhsRes, err := ev.EvalPlan(ctx, plan.LHS, start, end, step) diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 0bd31ab..b6eaa73 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -119,6 +119,8 @@ func main() { User: cfg.ClickHouse.User, Password: cfg.ClickHouse.Password, HTTPClient: metaHTTPClient, + Mode: cfg.Schema.Mode, + Tables: cfg.Schema.Tables, } // Create handler diff --git a/proxy/config/config.go b/proxy/config/config.go index 3af65ec..4cd0bf5 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -22,6 +22,11 @@ type SchemaConfig struct { TimeSeriesTable string `yaml:"time_series_table"` Columns ColumnConfig `yaml:"columns"` LabelsType string `yaml:"labels_type"` + // Mode "otel" reads the OpenTelemetry ClickHouse-exporter metric tables + // directly (single wide row per datapoint) instead of the Prometheus + // samples+time_series JOIN. Tables lists the OTel metric tables to UNION. + Mode string `yaml:"mode"` + Tables []string `yaml:"tables"` } // ColumnConfig maps column names. diff --git a/proxy/config/proxy.go b/proxy/config/proxy.go index c862db0..e5b0106 100644 --- a/proxy/config/proxy.go +++ b/proxy/config/proxy.go @@ -121,6 +121,8 @@ func (c *Config) ToPromqlConfig() *promqlcfg.Config { }, LabelsType: c.Schema.LabelsType, TimestampIsInt: true, + Mode: c.Schema.Mode, + Tables: c.Schema.Tables, }, Prometheus: promqlcfg.PrometheusConfig{ StalenessSeconds: 300, diff --git a/proxy/server/handlers/meta.go b/proxy/server/handlers/meta.go index 166b501..641ba0e 100644 --- a/proxy/server/handlers/meta.go +++ b/proxy/server/handlers/meta.go @@ -16,6 +16,53 @@ type MetaQuerier struct { User string Password string HTTPClient *http.Client + + // Mode "otel" makes the metadata endpoints read the OpenTelemetry + // ClickHouse-exporter metric tables (Tables) directly instead of the + // Prometheus time_series table, mirroring the translator's renderOTel: + // label names/values come from the sanitised merge of ResourceAttributes + + // Attributes, metric names from a sanitised MetricName. + Mode string + Tables []string +} + +// otelMetaLookbackHours bounds metadata scans to recently-active series so +// full-table DISTINCT scans over the _dist tables stay cheap. +const otelMetaLookbackHours = 6 + +// otelSanitize sanitises an identifier expression to a valid Prometheus name. +func otelSanitize(expr string) string { + return fmt.Sprintf("replaceRegexpAll(%s, '[^a-zA-Z0-9_]', '_')", expr) +} + +// otelLabelsExpr is the sanitised merge of ResourceAttributes + Attributes as a +// Map(String,String), matching the label set renderOTel emits at query time. +const otelLabelsExpr = "mapFromArrays(" + + "arrayMap(k -> replaceRegexpAll(k, '[^a-zA-Z0-9_]', '_'), mapKeys(mapConcat(ResourceAttributes, Attributes))), " + + "mapValues(mapConcat(ResourceAttributes, Attributes)))" + +// otelUnion builds a UNION ALL subquery over the configured OTel tables, +// selecting selectCols and applying the lookback window plus any extra +// predicate (already escaped). +func (m *MetaQuerier) otelUnion(selectCols, whereExtra string) string { + arms := make([]string, 0, len(m.Tables)) + for _, t := range m.Tables { + w := fmt.Sprintf("TimeUnix >= now() - toIntervalHour(%d)", otelMetaLookbackHours) + if whereExtra != "" { + w += " AND " + whereExtra + } + arms = append(arms, fmt.Sprintf("SELECT %s FROM %s WHERE %s", selectCols, t, w)) + } + return "(" + strings.Join(arms, " UNION ALL ") + ")" +} + +// chEscape escapes a literal for inline ClickHouse SQL. Backslash is the +// ClickHouse string-literal escape character, so it must be doubled *before* +// escaping the single quote — otherwise a value ending in a backslash would +// escape the closing quote and allow SQL injection. +func chEscape(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + return strings.ReplaceAll(s, "'", "\\'") } func (m *MetaQuerier) query(ctx context.Context, sql string) ([][]byte, error) { @@ -58,8 +105,15 @@ func (m *MetaQuerier) query(ctx context.Context, sql string) ([][]byte, error) { // Labels returns all distinct label names from the tags table. func (m *MetaQuerier) Labels(ctx context.Context, tagsTable, labelsCol string) ([]string, error) { - sql := fmt.Sprintf("SELECT DISTINCT arrayJoin(JSONExtractKeys(%s)) AS name FROM %s ORDER BY name", - labelsCol, tagsTable) + var sql string + if m.Mode == "otel" { + // Distinct sanitised keys of the merged ResourceAttributes+Attributes map. + sql = "SELECT DISTINCT arrayJoin(mapKeys(" + otelLabelsExpr + ")) AS name FROM " + + m.otelUnion("ResourceAttributes, Attributes", "") + " ORDER BY name" + } else { + sql = fmt.Sprintf("SELECT DISTINCT arrayJoin(JSONExtractKeys(%s)) AS name FROM %s ORDER BY name", + labelsCol, tagsTable) + } rows, err := m.query(ctx, sql) if err != nil { return nil, err @@ -90,10 +144,19 @@ func (m *MetaQuerier) Labels(ctx context.Context, tagsTable, labelsCol string) ( // LabelValues returns distinct values for a given label. func (m *MetaQuerier) LabelValues(ctx context.Context, labelName, tagsTable, metricNameCol, labelsCol string) ([]string, error) { var sql string - if labelName == "__name__" { + switch { + case m.Mode == "otel" && labelName == "__name__": + sql = "SELECT DISTINCT " + otelSanitize("MetricName") + " AS value FROM " + + m.otelUnion("MetricName", "") + " ORDER BY value" + case m.Mode == "otel": + // Sanitised value of the requested key from the merged attribute map. + key := chEscape(labelName) + sql = "SELECT DISTINCT value FROM (SELECT (" + otelLabelsExpr + ")['" + key + "'] AS value FROM " + + m.otelUnion("ResourceAttributes, Attributes", "") + ") WHERE value != '' ORDER BY value" + case labelName == "__name__": sql = fmt.Sprintf("SELECT DISTINCT %s AS value FROM %s ORDER BY value", metricNameCol, tagsTable) - } else { + default: sql = fmt.Sprintf("SELECT DISTINCT JSONExtractString(%s, '%s') AS value FROM %s WHERE value != '' ORDER BY value", labelsCol, labelName, tagsTable) } @@ -125,6 +188,9 @@ func parseCount(row []byte) float64 { // TSDBStatus returns cardinality stats from ClickHouse. func (m *MetaQuerier) TSDBStatus(ctx context.Context, tagsTable, metricNameCol, labelsCol, samplesTable, tsCol string) (map[string]interface{}, error) { + if m.Mode == "otel" { + return m.tsdbStatusOTel(ctx) + } // Total series (count distinct fingerprints) rows, _ := m.query(ctx, fmt.Sprintf("SELECT count(DISTINCT fingerprint) AS c FROM %s", tagsTable)) var numSeries float64 @@ -176,3 +242,41 @@ func (m *MetaQuerier) TSDBStatus(ctx context.Context, tagsTable, metricNameCol, "topLabelNames": topLabels, }, nil } + +// tsdbStatusOTel returns best-effort cardinality stats for OTel mode. There is +// no fingerprint/series table, so numSeries approximates distinct metric names +// and numSamples counts datapoints in the lookback window. +func (m *MetaQuerier) tsdbStatusOTel(ctx context.Context) (map[string]interface{}, error) { + countRows, _ := m.query(ctx, "SELECT count() AS c FROM "+m.otelUnion("1", "")) + var numSamples float64 + if len(countRows) > 0 { + numSamples = parseCount(countRows[0]) + } + + sanMetric := otelSanitize("MetricName") + metricRows, _ := m.query(ctx, "SELECT name, count() AS c FROM (SELECT "+sanMetric+ + " AS name FROM "+m.otelUnion("MetricName", "")+") GROUP BY name ORDER BY c DESC LIMIT 10") + var topMetrics []map[string]interface{} + for _, row := range metricRows { + var v struct { + Name string `json:"name"` + C json.Number `json:"c"` + } + json.Unmarshal(row, &v) + cnt, _ := v.C.Float64() + topMetrics = append(topMetrics, map[string]interface{}{"name": v.Name, "seriesCount": cnt}) + } + + distinctRows, _ := m.query(ctx, "SELECT count(DISTINCT "+sanMetric+") AS c FROM "+m.otelUnion("MetricName", "")) + var numSeries float64 + if len(distinctRows) > 0 { + numSeries = parseCount(distinctRows[0]) + } + + return map[string]interface{}{ + "numSeries": numSeries, + "numSamples": numSamples, + "topMetrics": topMetrics, + "topLabelNames": []map[string]interface{}{}, + }, nil +} diff --git a/proxy/server/handlers/series.go b/proxy/server/handlers/series.go index f1049b7..1e8f609 100644 --- a/proxy/server/handlers/series.go +++ b/proxy/server/handlers/series.go @@ -65,10 +65,23 @@ func (m *MetaQuerier) Series(ctx context.Context, matchers []string, tagsTable, return nil, fmt.Errorf("no metric name found in matchers") } - sql := fmt.Sprintf( - "SELECT %s AS metric_name, %s AS labels FROM %s WHERE %s = '%s' ORDER BY unix_milli DESC LIMIT 1 BY fingerprint LIMIT 100", - metricNameCol, labelsCol, tagsTable, metricNameCol, metricName, - ) + var sql string + if m.Mode == "otel" { + // Read distinct label sets for the metric straight from the OTel tables, + // matching raw or sanitised metric name (see renderOTel). + mn := chEscape(metricName) + where := fmt.Sprintf("(MetricName = '%s' OR %s = '%s')", mn, otelSanitize("MetricName"), mn) + // Emit the Map directly (not toJSONString) so JSONEachRow serialises it + // as a JSON object that decodes straight into map[string]string. + sql = "SELECT DISTINCT " + otelSanitize("MetricName") + " AS metric_name, " + + otelLabelsExpr + " AS labels FROM " + + m.otelUnion("MetricName, ResourceAttributes, Attributes", where) + " LIMIT 500" + } else { + sql = fmt.Sprintf( + "SELECT %s AS metric_name, %s AS labels FROM %s WHERE %s = '%s' ORDER BY unix_milli DESC LIMIT 1 BY fingerprint LIMIT 100", + metricNameCol, labelsCol, tagsTable, metricNameCol, metricName, + ) + } rows, err := m.query(ctx, sql) if err != nil { diff --git a/translator/plan.go b/translator/plan.go index ccdfb0a..13cf58e 100644 --- a/translator/plan.go +++ b/translator/plan.go @@ -127,6 +127,13 @@ func (p *SQLPlan) Render() (string, *clickhouse.QueryParams) { if cfg == nil { return "-- no config", params } + + // OTel single-table mode: read the OpenTelemetry ClickHouse-exporter metric + // tables directly instead of the Prometheus samples+time_series JOIN. + if cfg.Schema.Mode == "otel" { + return p.renderOTel(cfg, params) + } + cols := cfg.Schema.Columns var b strings.Builder @@ -180,6 +187,99 @@ func (p *SQLPlan) Render() (string, *clickhouse.QueryParams) { return b.String(), params } +// renderOTel builds a single-table read against the OpenTelemetry ClickHouse +// exporter metric tables (otel_metrics_gauge / otel_metrics_sum and their _dist +// Distributed views). Those tables carry one wide row per datapoint — +// MetricName, TimeUnix (DateTime), Value (Float64), Attributes and +// ResourceAttributes (Map) — with no fingerprint column and no separate +// time_series table. This synthesises the four columns the fetcher expects +// (fingerprint String, ts Int64 ms, value Float64, labels JSON): +// +// - fingerprint = cityHash64(MetricName, ResourceAttributes, Attributes) +// - labels = ResourceAttributes merged with Attributes, keys sanitised to +// valid Prometheus label names ([^a-zA-Z0-9_] -> '_', so k8s.pod.name -> +// k8s_pod_name, service.name -> service_name), emitted as a JSON object. +// - matchers filter on the sanitised merged map. +// +// Tables in cfg.Schema.Tables are UNIONed (each pushes the metric-name and time +// predicate down for partition pruning); only the table holding a given metric +// contributes rows. +func (p *SQLPlan) renderOTel(cfg *config.Config, params *clickhouse.QueryParams) (string, *clickhouse.QueryParams) { + tables := cfg.Schema.Tables + if len(tables) == 0 { + tables = []string{cfg.Schema.SamplesTable} + } + + params.AddString("metricName", p.MetricName) + params.AddInt64("dataStart", p.DataStartMs) + params.AddInt64("dataEnd", p.DataEndMs) + + // Per-table arm: filter by metric name + time window, carry the merged + // attribute map forward. + const mergedExpr = "mapConcat(ResourceAttributes, Attributes)" + var arms []string + for _, tbl := range tables { + // OTel metric names are dotted (system.memory.usage); PromQL names are + // underscored. Match either the raw stored name or its Prometheus- + // sanitised form so both `{__name__="system.memory.usage"}` and the bare + // `system_memory_usage` selector resolve. + arms = append(arms, fmt.Sprintf( + "SELECT MetricName, TimeUnix, Value, %s AS allattr\n"+ + " FROM %s\n"+ + " WHERE (MetricName = {metricName:String}\n"+ + " OR replaceRegexpAll(MetricName, '[^a-zA-Z0-9_]', '_') = {metricName:String})\n"+ + " AND TimeUnix > toDateTime({dataStart:Int64} / 1000)\n"+ + " AND TimeUnix <= toDateTime({dataEnd:Int64} / 1000)", + mergedExpr, tbl)) + } + + var b strings.Builder + b.WriteString("SELECT toString(fp) AS fingerprint, ts, value, toJSONString(lbls) AS labels\nFROM (\n") + // Compute fingerprint, ms timestamp, value and the sanitised label map. + b.WriteString(" SELECT\n") + b.WriteString(" cityHash64(MetricName, toString(allattr)) AS fp,\n") + b.WriteString(" toInt64(toUnixTimestamp(TimeUnix)) * 1000 AS ts,\n") + b.WriteString(" Value AS value,\n") + b.WriteString(" mapFromArrays(\n") + b.WriteString(" arrayMap(k -> replaceRegexpAll(k, '[^a-zA-Z0-9_]', '_'), mapKeys(allattr)),\n") + b.WriteString(" mapValues(allattr)\n") + b.WriteString(" ) AS lbls\n") + b.WriteString(" FROM (\n ") + b.WriteString(strings.Join(arms, "\n UNION ALL\n ")) + b.WriteString("\n )\n)") + + // Label matchers on the sanitised merged map. + first := true + for i, m := range p.Matchers { + paramName := fmt.Sprintf("lv%d", i) + labelKey := fmt.Sprintf("lk%d", i) + labelOp := fmt.Sprintf("lo%d", i) + params.AddString(paramName, m.Val) + params.AddString(labelKey, m.Name) + params.AddString(labelOp, m.Op) + accessor := fmt.Sprintf("lbls[{%s:String}]", labelKey) + if first { + b.WriteString("\nWHERE ") + first = false + } else { + b.WriteString("\n AND ") + } + switch m.Op { + case "=": + fmt.Fprintf(&b, "%s = {%s:String}", accessor, paramName) + case "!=": + fmt.Fprintf(&b, "%s != {%s:String}", accessor, paramName) + case "=~": + fmt.Fprintf(&b, "match(%s, {%s:String})", accessor, paramName) + case "!~": + fmt.Fprintf(&b, "NOT match(%s, {%s:String})", accessor, paramName) + } + } + + b.WriteString("\nORDER BY fingerprint ASC, ts ASC") + return b.String(), params +} + // RenderMV generates SQL to read from a materialized view tier. func (p *SQLPlan) RenderMV(cfg *config.Config, tier *config.DownsampleTier) (string, *clickhouse.QueryParams) { params := clickhouse.NewParams() From b57ee94d63a9f26924cac5a44d0ae81c5f656155 Mon Sep 17 00:00:00 2001 From: Sergio Rua Date: Wed, 8 Jul 2026 17:54:11 +0100 Subject: [PATCH 2/2] chore: vendor PromClick as Digitalis.io distribution Package the upstream Apache-2.0 PromClick project (github.com/PromClick/PromClick) as a Digitalis.io distribution while preserving original attribution: - Add NOTICE crediting the upstream authors and recording Digitalis modifications per Apache-2.0 section 4; add the Digitalis copyright line to LICENSE.md. - Brand the README (logo, attribution, contact) and repoint image/clone/helm refs to ghcr.io/digitalis-io and github.com/digitalis-io. - Add a container build workflow publishing multi-arch images to ghcr.io/digitalis-io/promclick (SHA-pinned actions, timeout, concurrency, manual dispatch); cross-compile in the Dockerfile instead of QEMU; add .dockerignore. - Add CHANGELOG.md (Keep a Changelog) and bump the Helm chart to 1.1.0 with Digitalis maintainer metadata. Signed-off-by: Sergio Rua --- .dockerignore | 27 +++++++++++ .github/workflows/build.yml | 78 ++++++++++++++++++++++++++++++ .gitignore | 3 ++ CHANGELOG.md | 40 +++++++++++++++ Dockerfile | 15 ++++-- LICENSE.md | 3 +- NOTICE | 33 +++++++++++++ README.md | 49 ++++++++++++++++--- charts/promclick-chart/Chart.yaml | 9 +++- charts/promclick-chart/values.yaml | 6 +-- docker-compose.yaml | 6 +-- 11 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 CHANGELOG.md create mode 100644 NOTICE diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..90e28cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# VCS +.git +.gitignore +.github + +# Docs / local artifacts +*.md +img/ +output/ + +# Node / build outputs +node_modules +proxy/ui/node_modules +proxy/ui/dist + +# Built binaries +promclick-proxy +promclick-writer +promclick-downsampler +proxy/proxy +*.exe + +# Editor / OS +.idea +.vscode +*.swp +.DS_Store diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f35d3cf --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,78 @@ +name: Build Container Image + +on: + push: + branches: [ "main" ] + tags: [ "v*" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +# Least privilege at the workflow level; the build job elevates to packages: write. +permissions: + contents: read + +# Cancel superseded runs on the same ref (e.g. rapid PR pushes). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/promclick + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - name: Check out code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Lowercase image name + id: image + run: echo "name=$(printf '%s' '${{ env.IMAGE_NAME }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + # Only authenticate for pushes (pull requests from forks cannot access secrets). + if: github.event_name != 'pull_request' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + # Push everywhere except pull requests (which only validate the build). + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index b205233..dbbfe76 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ promclick-downsampler .DS_Store Thumbs.db + +# Built binaries +proxy/proxy diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffe67b0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `NOTICE` file crediting the upstream [PromClick Authors](https://github.com/PromClick/PromClick) + and recording the Digitalis.io distribution copyright, preserving Apache-2.0 attribution. +- GitHub Actions workflow (`.github/workflows/build.yml`) that builds the multi-arch + container image (`linux/amd64`, `linux/arm64`) and publishes it to + `ghcr.io/digitalis-io/promclick` on pushes to `main` and on tags (`v*`), and can be + triggered manually. Pull requests build the image without pushing. Actions are pinned + to commit SHAs; the job has a build timeout and cancels superseded runs. +- Digitalis.io branding and an attribution section in the README. +- `.dockerignore` to shrink the build context. + +### Changed +- Container image references now point to `ghcr.io/digitalis-io/promclick` instead of + the upstream `quay.io/hinski/promclick` (Helm chart values, `docker-compose.yaml`, + README examples). +- Helm chart `home`, `sources`, and `maintainers` metadata set to the Digitalis.io + distribution; chart version bumped to `1.1.0`. +- README clone URLs and Helm `oci://` pull path updated to the `digitalis-io` org. +- `Dockerfile` now cross-compiles the Go binaries on the native build platform + (`GOARCH=$TARGETARCH`, CGO disabled) instead of building under QEMU emulation, + making multi-arch image builds substantially faster. + +### Security +- Fixed an SQL-injection vector in the OTel metadata/series handlers: `chEscape` + now escapes backslashes before single quotes so ClickHouse string literals cannot + be broken out of via a trailing backslash in request parameters. + +## [0.1.0] + +Initial upstream release vendored into the Digitalis.io distribution. See the +[upstream project](https://github.com/PromClick/PromClick) for the original history. diff --git a/Dockerfile b/Dockerfile index 1f62273..67b207d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,16 @@ -FROM node:20-alpine AS frontend +# Frontend output is architecture-independent, so build it once on the native +# build platform regardless of the target arch. +FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend WORKDIR /ui COPY proxy/ui/package*.json ./ RUN npm ci COPY proxy/ui/ . RUN npm run build -FROM golang:1.24-alpine AS builder +# Build on the native platform and cross-compile with GOARCH (CGO is disabled, +# so no emulation is needed) — far faster than building under QEMU per arch. +FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder +ARG TARGETARCH WORKDIR /src COPY go.work go.work.sum ./ COPY go.mod go.sum ./ @@ -13,9 +18,9 @@ COPY proxy/go.mod proxy/go.sum ./proxy/ RUN cd proxy && go mod download COPY . . COPY --from=frontend /ui/dist ./proxy/ui/dist -RUN cd proxy && CGO_ENABLED=0 go build -o /usr/local/bin/promclick-proxy ./cmd/proxy/ \ - && CGO_ENABLED=0 go build -o /usr/local/bin/promclick-writer ./cmd/writer/ \ - && CGO_ENABLED=0 go build -o /usr/local/bin/promclick-downsampler ./cmd/downsampler/ +RUN cd proxy && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /usr/local/bin/promclick-proxy ./cmd/proxy/ \ + && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /usr/local/bin/promclick-writer ./cmd/writer/ \ + && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /usr/local/bin/promclick-downsampler ./cmd/downsampler/ FROM alpine:3.20 RUN apk add --no-cache ca-certificates diff --git a/LICENSE.md b/LICENSE.md index b3c3d9b..2da1abd 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -176,7 +176,8 @@ END OF TERMS AND CONDITIONS - Copyright 2026 Authors of PromClick + Copyright 2026 The PromClick Authors (https://github.com/PromClick/PromClick) + Modifications Copyright 2026 Digitalis.io Ltd. (https://digitalis.io) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..810689f --- /dev/null +++ b/NOTICE @@ -0,0 +1,33 @@ +PromClick +========= + +PromClick is a PromQL-to-SQL transpiler and Prometheus-compatible HTTP proxy +for ClickHouse. + +This product includes software originally developed by the PromClick Authors: + + Copyright 2026 The PromClick Authors + https://github.com/PromClick/PromClick + + Original authors: + - Mateusz Darmetko (hinskii) + - Maciej Bekas + - Pavel Kravtsov + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +the original work except in compliance with the License. A copy of the License +is included in the LICENSE.md file and is available at: + + http://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +Modifications and packaging for the Digitalis.io distribution: + + Copyright 2026 Digitalis.io Ltd. + https://digitalis.io + +Digitalis.io vendors and maintains this distribution of PromClick. All original +copyright, patent, trademark, and attribution notices from the upstream project +are retained above as required by the Apache License, Version 2.0. The Digitalis +distribution is likewise made available under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 1e36f76..491fce8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ +

+ + Digitalis.io + +

+ # PromClick +> **A Digitalis.io distribution.** PromClick was originally created by the +> [PromClick Authors](https://github.com/PromClick/PromClick) and is licensed under +> Apache 2.0. Digitalis.io vendors and maintains this distribution, preserving the +> original attribution. See [Attribution](#attribution) and [`NOTICE`](NOTICE). + +A Prometheus-compatible HTTP API that translates PromQL to ClickHouse SQL in real time. + **I was tired of Thanos. I was tired of Victoria Metrics. I was tired of Grafana Mimir and I'm tired of everything.** You know the drill. You want long-term Prometheus storage. So you deploy Thanos - suddenly you have 47 YAML files, a sidecar, a store gateway, a compactor, a query frontend, and a PhD in distributed systems. Or you go with Mimir - congrats, you now operate a distributed hash ring and pray to the Memberlist gods every Tuesday. @@ -83,11 +96,11 @@ TSDB Status page shows series count, sample count, top metrics, and label cardin PromClick ships as a single Docker image with three binaries inside. Each handles one concern: ```bash -docker pull quay.io/hinski/promclick:0.1.0 +docker pull ghcr.io/digitalis-io/promclick:0.1.0 -docker run quay.io/hinski/promclick promclick-proxy --config proxy.yaml -docker run quay.io/hinski/promclick promclick-writer --config writer.yaml -docker run quay.io/hinski/promclick promclick-downsampler --config downsampler.yaml +docker run ghcr.io/digitalis-io/promclick promclick-proxy --config proxy.yaml +docker run ghcr.io/digitalis-io/promclick promclick-writer --config writer.yaml +docker run ghcr.io/digitalis-io/promclick promclick-downsampler --config downsampler.yaml ``` ### promclick-proxy (query server) @@ -190,7 +203,7 @@ interval: "1h" # re-check interval in daemon mode ### Docker Compose (all-in-one) ```bash -git clone https://github.com/PromClick/PromClick +git clone https://github.com/digitalis-io/PromClick cd promclick docker compose up -d @@ -572,7 +585,7 @@ With full support for: `on()`, `ignoring()`, `group_left()`, `group_right()`, `b ## Quick Start ```bash -git clone https://github.com/PromClick/PromClick +git clone https://github.com/digitalis-io/PromClick cd promclick docker compose up -d ``` @@ -593,7 +606,7 @@ The compose stack runs everything: ClickHouse, Prometheus, Node Exporter, PromCl Yes, there's a Helm chart. ```bash -helm pull oci://ghcr.io/promclick/promclick-chart --version +helm pull oci://ghcr.io/digitalis-io/promclick-chart --version ``` --- @@ -639,9 +652,29 @@ If you already run ClickHouse, PromClick gives you infinite Prometheus retention --- +## Attribution + +PromClick was originally created by the +[PromClick Authors](https://github.com/PromClick/PromClick) — Mateusz Darmetko +(hinskii), Maciej Bekas, and Pavel Kravtsov — and released under the Apache +License 2.0. + +This repository is the **Digitalis.io distribution** of PromClick. Digitalis.io +vendors and maintains it, preserving all upstream copyright and attribution +notices as required by the licence. The full attribution is recorded in the +[`NOTICE`](NOTICE) file. + ## License -Apache 2.0 +Licensed under the [Apache License, Version 2.0](LICENSE.md). + +- Original work © The PromClick Authors. +- Modifications and packaging © Digitalis.io Ltd. + +## Contact + +Maintained by [Digitalis.io](https://digitalis.io). For support, get in touch at +[digitalis.io/contact](https://digitalis.io/contact). --- diff --git a/charts/promclick-chart/Chart.yaml b/charts/promclick-chart/Chart.yaml index 4eb846a..3c6bb5f 100644 --- a/charts/promclick-chart/Chart.yaml +++ b/charts/promclick-chart/Chart.yaml @@ -2,5 +2,12 @@ apiVersion: v2 name: promclick-chart description: PromClick is a PromQL-to-SQL transpiler + HTTP proxy that makes ClickHouse speak Prometheus. type: application -version: 1.0.0 +version: 1.1.0 appVersion: "0.1.0" +home: https://github.com/digitalis-io/PromClick +sources: + - https://github.com/digitalis-io/PromClick + - https://github.com/PromClick/PromClick +maintainers: + - name: Digitalis.io + url: https://digitalis.io diff --git a/charts/promclick-chart/values.yaml b/charts/promclick-chart/values.yaml index 918cecb..fe925ac 100644 --- a/charts/promclick-chart/values.yaml +++ b/charts/promclick-chart/values.yaml @@ -3,7 +3,7 @@ deployments: promClickProxy: replicas: 1 image: - repository: quay.io/hinski/promclick + repository: ghcr.io/digitalis-io/promclick # tag: 0.1.0 # tags can be overriden like this, by default it takes appVersion from Chart.yaml name: promclick-proxy command: ["promclick-proxy"] @@ -50,7 +50,7 @@ deployments: promClickWriter: replicas: 1 image: - repository: quay.io/hinski/promclick + repository: ghcr.io/digitalis-io/promclick # tag: 0.1.0 # tags can be overriden like this, by default it takes appVersion from Chart.yaml name: promclick-writter command: ["promclick-proxy"] @@ -97,7 +97,7 @@ deployments: promClickDownsampler: replicas: 1 image: - repository: quay.io/hinski/promclick + repository: ghcr.io/digitalis-io/promclick # tag: 0.1.0 # tags can be overriden like this, by default it takes appVersion from Chart.yaml name: promclick-downsampler service: diff --git a/docker-compose.yaml b/docker-compose.yaml index 10d4000..6343acc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,7 +54,7 @@ services: condition: service_healthy promclick-proxy: - image: quay.io/hinski/promclick:0.1.0 + image: ghcr.io/digitalis-io/promclick:0.1.0 container_name: promclick-proxy entrypoint: ["promclick-proxy"] command: ["--config", "proxy.yaml"] @@ -68,7 +68,7 @@ services: restart: unless-stopped promclick-writer: - image: quay.io/hinski/promclick:0.1.0 + image: ghcr.io/digitalis-io/promclick:0.1.0 container_name: promclick-writer entrypoint: ["promclick-writer"] command: ["--config", "writer.yaml"] @@ -82,7 +82,7 @@ services: restart: unless-stopped promclick-downsampler: - image: quay.io/hinski/promclick:0.1.0 + image: ghcr.io/digitalis-io/promclick:0.1.0 container_name: promclick-downsampler entrypoint: ["promclick-downsampler"] command: ["--config", "downsampler.yaml"]