Drop chcat/chtail/chgrep symlink entrypoints; ship chlog subcommands only
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

The RPM's /usr/bin/chcat conflicts with SELinux's
policycoreutils-python-utils on Fedora. Per Ben: remove the symlink
entrypoints entirely and ship only the chlog binary with cat/tail/grep
subcommands.

- Remove chcat/chtail/chgrep symlinks and their completions from
  nfpm.yaml, build-rpm.sh and the Makefile
- Remove the argv[0] dispatch in main.go; subcommands are unchanged
- Update the completion test to cover chlog only
- Update README usage to chlog cat|tail|grep
This commit is contained in:
2026-08-23 21:39:17 +10:00
parent dfd4efd782
commit ee2f270abc
8 changed files with 60 additions and 145 deletions
-3
View File
@@ -1,8 +1,5 @@
# built binaries (repo root only)
/chlog
/chcat
/chtail
/chgrep
# cross-compiled release artifacts (e.g. chlog-linux-amd64)
/chlog-*
dist/
+1 -1
View File
@@ -44,7 +44,7 @@ steps:
memory: 2Gi
cpu: 2
# Package the built binary + symlinks + shell completions into an RPM.
# Package the built binary + shell completions into an RPM.
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
+4 -9
View File
@@ -1,6 +1,4 @@
BINARY := chlog
# chcat/chtail/chgrep are argv[0]-dispatched symlinks to the single chlog binary.
LINKS := chcat chtail chgrep
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
@@ -13,7 +11,6 @@ all: build
build:
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) .
@for l in $(LINKS); do ln -sf $(BINARY) $(DIST)/$$l; done
test:
go test -v -race ./...
@@ -30,14 +27,12 @@ clean:
install:
go install $(GOFLAGS) .
# Generate bash/zsh/fish completions for chlog and each symlink entrypoint.
# Generate bash/zsh/fish completions for chlog.
completions: build
@mkdir -p $(DIST)/completions
@for b in $(BINARY) $(LINKS); do \
$(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
done
@$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
@$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
@$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
rpm: build rpm-package
+20 -16
View File
@@ -1,20 +1,24 @@
# clickhouse-tools
CLI tools for the ClickHouse log store (`logs.raw`): one binary, `chlog`, with
three entrypoints installed as symlinks:
three subcommands:
| Command | Also as | Does |
|----------|---------------|------|
| `chcat` | `chlog cat` | Print logs oldest-first over a bounded time range |
| `chtail` | `chlog tail` | Follow logs live (2s poll, overlap + dedupe so nothing is lost or repeated) |
| `chgrep` | `chlog grep` | Search log messages (substring, `-i`, `--regex`) |
| Command | Does |
|--------------|------|
| `chlog cat` | Print logs oldest-first over a bounded time range |
| `chlog tail` | Follow logs live (2s poll, overlap + dedupe so nothing is lost or repeated) |
| `chlog grep` | Search log messages (substring, `-i`, `--regex`) |
(Earlier releases also shipped `chcat`/`chtail`/`chgrep` symlinks; they were
dropped because `/usr/bin/chcat` conflicts with SELinux's
`policycoreutils-python-utils` package.)
## Why time bounds everywhere
`logs.raw` has no text index and holds ~281M rows/day (3-day TTL). An unbounded
message scan takes ~1 minute and the server kills queries at 120s. Every query
these tools issue is therefore time-bounded — the default range is the last
hour (`--since 1h`) — and `chgrep` refuses a search wider than 6h with no
hour (`--since 1h`) — and `chlog grep` refuses a search wider than 6h with no
`--namespace`/`--host`/`--app` filter unless you pass `--force`.
All user input travels as ClickHouse HTTP `{name:Type}` parameters; nothing is
@@ -23,12 +27,12 @@ ever interpolated into SQL text.
## Usage
```sh
chcat -n logging --since 30m
chcat --host web01 --since 2h --until 1h --format logfmt
chtail -n media --app jellyfin
chgrep -n kube-system -i "connection refused" --since 4h
chgrep --app vector --regex 'timed? ?out' --since 1d
chgrep --fields req_id=42 -n api "payment"
chlog cat -n logging --since 30m
chlog cat --host web01 --since 2h --until 1h --format logfmt
chlog tail -n media --app jellyfin
chlog grep -n kube-system -i "connection refused" --since 4h
chlog grep --app vector --regex 'timed? ?out' --since 1d
chlog grep --fields req_id=42 -n api "payment"
```
### Common flags
@@ -41,7 +45,7 @@ chgrep --fields req_id=42 -n api "payment"
- `--format text|json|logfmt` — text is `ts ns/pod msg` (host for vm rows),
colored only on a TTY (`NO_COLOR` respected)
### chgrep extras
### chlog grep extras
- pattern is a substring by default; `-i` case-insensitive; `--regex` RE2 (`match()`)
- `--fields key=value` (repeatable) filters the structured `fields` map
@@ -58,8 +62,8 @@ chgrep --fields req_id=42 -n api "payment"
## Build and release
```sh
make build # dist/chlog + symlinks
make build # dist/chlog
make test # go test -race ./...
make rpm # nfpm RPM with binary, symlinks, bash/zsh/fish completions
make rpm # nfpm RPM with binary + bash/zsh/fish completions
make patch # tag + push next vX.Y.Z → CI releases RPM to artifactapi rpm-internal
```
+8 -27
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -88,10 +87,10 @@ func (cf *commonFlags) formatter() (chlog.Formatter, error) {
return chlog.NewFormatter(cf.format, color)
}
func newCatCmd(use string) *cobra.Command {
func newCatCmd() *cobra.Command {
cf := &commonFlags{}
cmd := &cobra.Command{
Use: use,
Use: "cat",
Short: "Print logs from the ClickHouse log store, oldest first",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -114,10 +113,10 @@ func newCatCmd(use string) *cobra.Command {
return cmd
}
func newTailCmd(use string) *cobra.Command {
func newTailCmd() *cobra.Command {
cf := &commonFlags{}
cmd := &cobra.Command{
Use: use,
Use: "tail",
Short: "Follow logs from the ClickHouse log store",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -146,7 +145,7 @@ func newTailCmd(use string) *cobra.Command {
return cmd
}
func newGrepCmd(use string) *cobra.Command {
func newGrepCmd() *cobra.Command {
cf := &commonFlags{}
var (
regex bool
@@ -155,7 +154,7 @@ func newGrepCmd(use string) *cobra.Command {
force bool
)
cmd := &cobra.Command{
Use: use + " <pattern>",
Use: "grep <pattern>",
Short: "Search log messages in the ClickHouse log store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -217,32 +216,14 @@ func newRootCmd() *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(newCatCmd("cat"), newTailCmd("tail"), newGrepCmd("grep"))
root.AddCommand(newCatCmd(), newTailCmd(), newGrepCmd())
return root
}
func entrypoint() *cobra.Command {
var cmd *cobra.Command
switch filepath.Base(os.Args[0]) {
case "chcat":
cmd = newCatCmd("chcat")
case "chtail":
cmd = newTailCmd("chtail")
case "chgrep":
cmd = newGrepCmd("chgrep")
default:
return newRootCmd()
}
cmd.Version = version
cmd.SilenceUsage = true
cmd.SilenceErrors = true
return cmd
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := entrypoint().ExecuteContext(ctx); err != nil {
if err := newRootCmd().ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
+21 -28
View File
@@ -1,7 +1,6 @@
package main
import (
"os"
"strings"
"testing"
"time"
@@ -41,7 +40,7 @@ func TestCommonFlagsFilterDefaults(t *testing.T) {
}
func TestGrepGuardBlocksWideUnfilteredSearch(t *testing.T) {
cmd := newGrepCmd("chgrep")
cmd := newGrepCmd()
cmd.SetArgs([]string{"--since", "24h", "needle"})
var out strings.Builder
cmd.SetOut(&out)
@@ -56,7 +55,7 @@ func TestGrepGuardAllowsFilteredSearch(t *testing.T) {
// A namespace filter disables the guard; the query then fails on the
// unreachable server rather than the guard, proving the guard passed.
t.Setenv("CH_URL", "http://127.0.0.1:1")
cmd := newGrepCmd("chgrep")
cmd := newGrepCmd()
cmd.SetArgs([]string{"--since", "24h", "--namespace", "logging", "needle"})
var out strings.Builder
cmd.SetOut(&out)
@@ -72,7 +71,7 @@ func TestGrepGuardAllowsFilteredSearch(t *testing.T) {
func TestGrepGuardAllowsShortWindow(t *testing.T) {
t.Setenv("CH_URL", "http://127.0.0.1:1")
cmd := newGrepCmd("chgrep")
cmd := newGrepCmd()
cmd.SetArgs([]string{"--since", "1h", "needle"})
err := cmd.Execute()
if err == nil {
@@ -83,33 +82,27 @@ func TestGrepGuardAllowsShortWindow(t *testing.T) {
}
}
func TestCompletionAllEntrypointsAndShells(t *testing.T) {
// scripts/build-rpm.sh runs "<name> completion <shell>" for every
// entrypoint; guard that each argv[0]-dispatched command tree exposes a
// working completion subcommand for all three packaged shells.
for _, name := range []string{"chlog", "chcat", "chtail", "chgrep"} {
for _, shell := range []string{"bash", "zsh", "fish"} {
t.Run(name+"/"+shell, func(t *testing.T) {
orig := os.Args
os.Args = []string{name}
defer func() { os.Args = orig }()
cmd := entrypoint()
var out strings.Builder
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"completion", shell})
if err := cmd.Execute(); err != nil {
t.Fatalf("%s completion %s: %v", name, shell, err)
}
if !strings.Contains(out.String(), name) {
t.Fatalf("%s completion %s output does not mention %q", name, shell, name)
}
})
}
func TestCompletionAllShells(t *testing.T) {
// scripts/build-rpm.sh runs "chlog completion <shell>"; guard that a
// working completion subcommand exists for all three packaged shells.
for _, shell := range []string{"bash", "zsh", "fish"} {
t.Run(shell, func(t *testing.T) {
cmd := newRootCmd()
var out strings.Builder
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"completion", shell})
if err := cmd.Execute(); err != nil {
t.Fatalf("chlog completion %s: %v", shell, err)
}
if !strings.Contains(out.String(), "chlog") {
t.Fatalf("chlog completion %s output does not mention chlog", shell)
}
})
}
}
func TestEntrypointDispatch(t *testing.T) {
func TestRootSubcommands(t *testing.T) {
root := newRootCmd()
names := map[string]bool{}
for _, c := range root.Commands() {
-47
View File
@@ -24,17 +24,6 @@ contents:
owner: root
group: root
# chcat/chtail/chgrep dispatch on argv[0] inside the chlog binary.
- src: /usr/bin/chlog
dst: /usr/bin/chcat
type: symlink
- src: /usr/bin/chlog
dst: /usr/bin/chtail
type: symlink
- src: /usr/bin/chlog
dst: /usr/bin/chgrep
type: symlink
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/chlog.bash
dst: /usr/share/bash-completion/completions/chlog
@@ -48,39 +37,3 @@ contents:
dst: /usr/share/fish/vendor_completions.d/chlog.fish
file_info:
mode: 0644
- src: dist/completions/chcat.bash
dst: /usr/share/bash-completion/completions/chcat
file_info:
mode: 0644
- src: dist/completions/_chcat
dst: /usr/share/zsh/site-functions/_chcat
file_info:
mode: 0644
- src: dist/completions/chcat.fish
dst: /usr/share/fish/vendor_completions.d/chcat.fish
file_info:
mode: 0644
- src: dist/completions/chtail.bash
dst: /usr/share/bash-completion/completions/chtail
file_info:
mode: 0644
- src: dist/completions/_chtail
dst: /usr/share/zsh/site-functions/_chtail
file_info:
mode: 0644
- src: dist/completions/chtail.fish
dst: /usr/share/fish/vendor_completions.d/chtail.fish
file_info:
mode: 0644
- src: dist/completions/chgrep.bash
dst: /usr/share/bash-completion/completions/chgrep
file_info:
mode: 0644
- src: dist/completions/_chgrep
dst: /usr/share/zsh/site-functions/_chgrep
file_info:
mode: 0644
- src: dist/completions/chgrep.fish
dst: /usr/share/fish/vendor_completions.d/chgrep.fish
file_info:
mode: 0644
+6 -14
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# Package the (already built) chlog binary into an RPM with nfpm, bundling
# chcat/chtail/chgrep symlinks and generated bash/zsh/fish shell completions.
# generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
@@ -12,7 +12,6 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
BINARY="chlog"
NAMES=(chlog chcat chtail chgrep)
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
@@ -20,26 +19,19 @@ if [ ! -f "${DIST}/${BINARY}" ]; then
exit 1
fi
for l in chcat chtail chgrep; do
ln -sf "${BINARY}" "${DIST}/${l}"
done
# Generate shell completions per entrypoint name so they always match the
# shipped flags/subcommands (each name dispatches to its own command tree).
# Generate shell completions so they always match the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
for b in "${NAMES[@]}"; do
"./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash"
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
done
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
export PACKAGE_NAME="clickhouse-tools"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for the ClickHouse log store: chlog with chcat (print), chtail (follow) and chgrep (search) entrypoints"
export PACKAGE_DESCRIPTION="CLI tools for the ClickHouse log store: chlog with cat (print), tail (follow) and grep (search) subcommands"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/clickhouse-tools"
export PACKAGE_LICENSE="MIT"