diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f690e..c4ce27c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.3] - 2026-08-25 + +Patch — positional archive naming (cluster anchored at the tail). + +### Changed + +- **Capture / archive naming:** the cluster is now the absolute tail of the + archive basename, anchored positionally by the terminal + `-YYYYMMDD-HHMMSS` timestamp. The free-form `--message` and the + `-since-` marker now precede the timestamp, so the cluster can + contain arbitrary characters (DO-style hosts, UUIDs, dashes, dots, even + `develop`) without downstream parsing ambiguity. The consuming gfs parser + (`groot-share`) reads the cluster by position (everything after the + timestamp), so both sides must ship together — pair with gfs `0.7.0`. +- **README / man pages / BSD ports:** version badge and packaging pins synced + to **1.1.3**. + ## [1.1.2] - 2026-08-18 Security patch — Go 1.26.6 stdlib CVEs; golangci-lint v2.12.2. diff --git a/README.md b/README.md index dcc637f..741adef 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **☸** _Collect Kubernetes logs and cluster context into one archive_ [![Release](https://img.shields.io/github/v/release/hrodrig/groot?display_name=tag&label=release&logo=github)](https://github.com/hrodrig/groot/releases) -[![Version](https://img.shields.io/badge/version-1.1.2-blue)](https://github.com/hrodrig/groot/releases) +[![Version](https://img.shields.io/badge/version-1.1.3-blue)](https://github.com/hrodrig/groot/releases) [![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8?logo=go)](https://go.dev/) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![pkg.go.dev](https://pkg.go.dev/badge/github.com/hrodrig/groot)](https://pkg.go.dev/github.com/hrodrig/groot) @@ -583,7 +583,7 @@ notify: |-----|----------------| | **`kubeconfig`** | Path to the kubeconfig file used to build the **client-go** REST config (same discovery rules as **client-go** / **`clientcmd`**). Empty: use **`KUBECONFIG`** if set, then the default kubeconfig locations (for example **`~/.kube/config`**), or in-cluster credentials when Groot runs as a pod. Supports **`~`** and **`${VAR}`** expansion (YAML, `KUBECONFIG`, and **`--kubeconfig`**). **`groot --kubeconfig`** overrides this for a single run (see [Resolution and precedence](#resolution-and-precedence)). | | **`cluster_name`** | Optional label for the **``** segment in archive basenames. When empty, Groot resolves: kubeconfig cluster name → **`kube-public/cluster-info`** → API server host → **`unknown-cluster`**. Set explicitly for in-cluster pods without kubeconfig context. Env: **`GROOT_CLUSTER_NAME`**. | -| **`output_dir`** | Base directory: each run creates **`--[-since-]/`**, then **`-[-].tar.gz`** beside it. **``** is from **`run_id`** (concurrent-safe). Supports **`~`** and **`${VAR}`** expansion. | +| **`output_dir`** | Base directory: each run creates **`-[-since-]-/`**, then **`-[-].tar.gz`** beside it. **``** is from **`run_id`** (concurrent-safe). Supports **`~`** and **`${VAR}`** expansion. | | **`file_prefix`** | Prefix for capture directory and archive basename (default **`groot-capture`**). Example session: **`groot-capture-7kqv2xy-20260606-120000-my-cluster.tar.gz`**. | | **`collection`** | Tuning for timeouts, parallelism, namespaces, pod logs, optional **`extra_kubectl`** argv lines, redaction, etc. (see below). | | **`notify`** | Optional webhooks, email, and failure alerts after collect (see [Notifications](#notifications)). | @@ -601,7 +601,7 @@ Pod ↔ node placement at capture start is in **`extras/all-pod-node-placement.t | **`include_pod_logs`** | When **`true`**, collects **pod logs** for workload and control-plane pods via the API (subject to **`targets`**, **`pod_log_tail_lines`**, **`pod_logs_since`**). When **`false`**, skips all pod log jobs. | | **`include_previous_logs`** | When **`true`**, also collects **previous-container** logs into **`*.previous.log`** (same semantics as **`--previous`** on pod logs; marked optional so a missing previous container does not fail the run). | | **`pod_log_tail_lines`** | When **`>0`**, passes **`--tail N`** to pod log commands. **`0`** means **no `--tail`** (full log stream — can be very large). | -| **`pod_logs_since`** | When set, passes **`--since=…`** to **pod log** commands only (digits-only = **hours**, e.g. **`24`** → **`24h`**; otherwise a Go duration like **`24h`**, **`45m`**). **`groot collect --since`** overrides this when the flag is set. The capture directory and **`.tar.gz`** basename include **`since-`** after the timestamp so runs with a log window are identifiable on disk (see [Output naming](#output-naming)). | +| **`pod_logs_since`** | When set, passes **`--since=…`** to **pod log** commands only (digits-only = **hours**, e.g. **`24`** → **`24h`**; otherwise a Go duration like **`24h`**, **`45m`**). **`groot collect --since`** overrides this when the flag is set. The capture directory and **`.tar.gz`** basename include **`since-`** before the timestamp so runs with a log window are identifiable on disk (see [Output naming](#output-naming)). | | **`include_node_details`** | When **`true`**, for each node writes **describe**-style summaries and **node metrics** (when the metrics API is available) under **`nodes/`**. | | **`include_node_logs`** | When **`true`**, for each node: (1) **GET** **`/api/v1/nodes//proxy/logs/messages`** → **`nodes/.log`** (host **`/var/log/messages`** when the kubelet serves it — common on **AKS** and other managed nodes); (2) **GET** **`/api/v1/nodes//proxy/logs/?query=kubelet`** (optional **`&tailLines=N`**) → **`nodes/-kubelet.log`** when the cluster exposes the **Node Log Query** API (Kubernetes **1.27+**; often **404 on AKS**). **Both** jobs are **optional** (failure does not fail the run). | | **`node_log_tail_lines`** | When **`>0`**, appends **`tailLines`** to the optional kubelet log query (**default `5000`**). **`0`** omits **`tailLines`** (server default limit). Does not apply to the **`messages`** proxy. | @@ -703,6 +703,12 @@ Capture output names use **`file_prefix`** (default **`groot-capture`**): - **directory:** `--` or `---since-` when **`pod_logs_since`** / **`--since`** is set (`` = lowercase `run_id` suffix) - **archive:** `-[-].tar.gz` (for example **`groot-capture-7kqv2xy-20260606-120000-my-cluster.tar.gz`**) +The **`--message`** and the **`-since-`** marker both sit BEFORE the +timestamp in the archive basename, so the **cluster** is always the LAST +segment (everything after the `--` anchor). This keeps the +cluster positionally unambiguous: it may contain any characters (DO-style +hosts, UUIDs, dashes, dots) without breaking downstream parsing. + When **`pod_logs_since`** is set, **``** is a filesystem-safe form of the duration (for example **`12h`**, **`45m`**). `--message` is sanitized before use: diff --git a/VERSION b/VERSION index 45a1b3f..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.2 +1.1.3 diff --git a/contrib/freebsd/Makefile b/contrib/freebsd/Makefile index fd0fc3f..d2570d8 100644 --- a/contrib/freebsd/Makefile +++ b/contrib/freebsd/Makefile @@ -1,5 +1,5 @@ PORTNAME= groot -PORTVERSION= 1.1.2 +PORTVERSION= 1.1.3 DISTVERSIONPREFIX= v CATEGORIES= sysutils # Local test before release: copy the tarball into DISTDIR (see `make -V DISTDIR` diff --git a/contrib/man/man1/groot.1 b/contrib/man/man1/groot.1 index 9f5c239..ea122ea 100644 --- a/contrib/man/man1/groot.1 +++ b/contrib/man/man1/groot.1 @@ -1,6 +1,6 @@ .\" Copyright (c) 2026 hrodrig .\" SPDX-License-Identifier: MIT -.TH GROOT 1 "2026-08-18" "groot v1.1.2" "User Commands" +.TH GROOT 1 "2026-08-25" "groot v1.1.3" "User Commands" .SH NAME groot \- collect Kubernetes logs and cluster context into one archive .SH SYNOPSIS diff --git a/contrib/man/man1/kubectl-groot.1 b/contrib/man/man1/kubectl-groot.1 index d5a7744..41714f3 100644 --- a/contrib/man/man1/kubectl-groot.1 +++ b/contrib/man/man1/kubectl-groot.1 @@ -1,6 +1,6 @@ .\" Copyright (c) 2026 hrodrig .\" SPDX-License-Identifier: MIT -.TH KUBECTL-GROOT 1 "2026-08-18" "groot v1.1.2" "User Commands" +.TH KUBECTL-GROOT 1 "2026-08-25" "groot v1.1.3" "User Commands" .SH NAME kubectl-groot \- kubectl plugin entry point for groot .SH SYNOPSIS diff --git a/contrib/openbsd/port/Makefile b/contrib/openbsd/port/Makefile index b35f7f6..97de068 100644 --- a/contrib/openbsd/port/Makefile +++ b/contrib/openbsd/port/Makefile @@ -3,8 +3,8 @@ # Submit: send diff to ports@openbsd.org COMMENT = read-only log and context collector for Kubernetes -DISTNAME = groot_v1.1.2_openbsd_${MACHINE_ARCH:S/aarch64/arm64/} -PKGNAME = groot-1.1.2 +DISTNAME = groot_v1.1.3_openbsd_${MACHINE_ARCH:S/aarch64/arm64/} +PKGNAME = groot-1.1.3 CATEGORIES = sysutils HOMEPAGE = https://github.com/hrodrig/groot MAINTAINER = hrodrig@usb.ve @@ -16,8 +16,8 @@ PERMIT_DISTFILES = Yes # Local test before release: copy tarball to DISTDIR or use # make fetch MASTER_SITES=file:///absolute/path/to/dir/ # See README.md "Test with a local tarball". -MASTER_SITES = https://github.com/hrodrig/groot/releases/download/v1.1.2/ -DISTFILES = groot_v1.1.2_openbsd_${MACHINE_ARCH:S/aarch64/arm64/}.tar.gz +MASTER_SITES = https://github.com/hrodrig/groot/releases/download/v1.1.3/ +DISTFILES = groot_v1.1.3_openbsd_${MACHINE_ARCH:S/aarch64/arm64/}.tar.gz EXTRACT_SUFX = .tar.gz ONLY_FOR_ARCHS = amd64 arm64 aarch64 diff --git a/docs/demo.gif b/docs/demo.gif index 6dda767..f643110 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 2b7df5e..46f94d3 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -525,23 +525,24 @@ func (s *Service) runJobs(ctx context.Context, captureDir string, jobs []job) Su // captureSessionBase is the capture folder name and the leading part of the archive basename. // Format: "--" or -// "---since-" when pod_logs_since is set. +// "---" when pod_logs_since is set. +// The -since- is placed BEFORE the timestamp so the trailing +// "-" is a clean positional anchor: everything after the +// timestamp is the cluster, everything before it is free-form metadata. // is the lowercase random suffix from run_id (concurrent-collect uniqueness). func captureSessionBase(filePrefix, short, timestamp, podLogsSince string) string { short = strings.TrimSpace(short) if short == "" { short = strings.ToLower(newRunIDShort()) } - base := sanitizeFilePrefix(filePrefix) + "-" + short + "-" + timestamp + base := sanitizeFilePrefix(filePrefix) + "-" + short s := strings.TrimSpace(podLogsSince) - if s == "" { - return base - } - slug := sanitizeMessage(s) - if slug == "" { - return base + if s != "" { + if slug := sanitizeMessage(s); slug != "" { + base += "-since-" + slug + } } - return base + "-since-" + slug + return base + "-" + timestamp } func sanitizeFilePrefix(p string) string { @@ -553,13 +554,39 @@ func sanitizeFilePrefix(p string) string { } func archiveBasename(sessionBase, clusterName, message string) string { - name := fmt.Sprintf("%s-%s", sessionBase, clusterName) + // sessionBase = "-[-since-]-". Insert + // the free-form message BEFORE the timestamp, and put the cluster LAST + // (after the timestamp) so it is positionally unambiguous: downstream + // parsers read "everything after the timestamp" as the cluster, no matter + // what characters it contains (DO-style hosts, UUIDs, dashes, dots). + head, tsTail := splitSessionBase(sessionBase) + name := head if suffix := sanitizeMessage(message); suffix != "" { name += "-" + suffix } + name += "-" + tsTail + name += "-" + clusterName return name } +// tsTailRegex matches the terminal timestamp slot of a session base: +// "--". The -since- (when present) sits BEFORE the +// timestamp, so the timestamp is always the terminal slot. +var tsTailRegex = regexp.MustCompile(`-\d{8}-\d{6}$`) + +// splitSessionBase splits "-[-since-]-" +// into the leading head ("-[-since-]") and the +// trailing timestamp ("-"), so a message can be intercalated +// between. If the shape is unexpected it returns the input unchanged with an +// empty tail (never splits a name we don't recognize). +func splitSessionBase(sessionBase string) (head, tsTail string) { + loc := tsTailRegex.FindStringIndex(sessionBase) + if loc == nil || loc[0] == 0 { + return sessionBase, "" + } + return sessionBase[:loc[0]], sessionBase[loc[0]+1:] +} + func sanitize(value string) string { value = strings.ReplaceAll(value, "/", "_") value = strings.ReplaceAll(value, ":", "_") diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 7e10c91..860650e 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -3,7 +3,6 @@ package collector import ( "os" "path/filepath" - "strings" "testing" "github.com/hrodrig/groot/internal/config" @@ -327,10 +326,10 @@ func TestCaptureSessionBase(t *testing.T) { if got := captureSessionBase("groot-capture", "7kqv2xy", "20260102-150405", ""); got != "groot-capture-7kqv2xy-20260102-150405" { t.Fatalf("empty since: %q", got) } - if got := captureSessionBase("groot-capture", "7kqv2xy", "20260102-150405", "12h"); got != "groot-capture-7kqv2xy-20260102-150405-since-12h" { + if got := captureSessionBase("groot-capture", "7kqv2xy", "20260102-150405", "12h"); got != "groot-capture-7kqv2xy-since-12h-20260102-150405" { t.Fatalf("12h: %q", got) } - if got := captureSessionBase("", "abc1234", "20260102-150405", "45m"); got != "groot-capture-abc1234-20260102-150405-since-45m" { + if got := captureSessionBase("", "abc1234", "20260102-150405", "45m"); got != "groot-capture-abc1234-since-45m-20260102-150405" { t.Fatalf("45m: %q", got) } a := captureSessionBase("groot-capture", "aaaaaaa", "20260102-150405", "") @@ -341,10 +340,41 @@ func TestCaptureSessionBase(t *testing.T) { } func TestArchiveBasename(t *testing.T) { + // New order: message before timestamp, cluster last (positionally unambiguous). got := archiveBasename("groot-capture-7kqv2xy-20260102-150405", "prod", "RCA run") - if !strings.Contains(got, "groot-capture-7kqv2xy-20260102-150405-prod") { + if got != "groot-capture-7kqv2xy-rca-run-20260102-150405-prod" { t.Fatalf("got %q", got) } + // No message: cluster still last. + if got := archiveBasename("groot-capture-7kqv2xy-20260102-150405", "prod", ""); got != "groot-capture-7kqv2xy-20260102-150405-prod" { + t.Fatalf("no message: got %q", got) + } + // Cluster with UUIDs/dots/dashes (DO-style) stays intact as the tail. + do := "e1359a66-dd34-49da-8740-519d490679b6.k8s.ondigitalocean.com-cluster" + got = archiveBasename("groot-capture-7kqv2xy-20260102-150405", do, "incident 42") + if got != "groot-capture-7kqv2xy-incident-42-20260102-150405-e1359a66-dd34-49da-8740-519d490679b6.k8s.ondigitalocean.com-cluster" { + t.Fatalf("DO cluster: got %q", got) + } + // With -since- BEFORE timestamp: message goes after since, then timestamp+cluster. + got = archiveBasename("groot-capture-7kqv2xy-since-12h-20260102-150405", "prod", "rca") + if got != "groot-capture-7kqv2xy-since-12h-rca-20260102-150405-prod" { + t.Fatalf("since: got %q", got) + } +} + +func TestSplitSessionBase(t *testing.T) { + h, ts := splitSessionBase("groot-capture-7kqv2xy-20260102-150405") + if h != "groot-capture-7kqv2xy" || ts != "20260102-150405" { + t.Fatalf("got %q / %q", h, ts) + } + h, ts = splitSessionBase("groot-capture-7kqv2xy-since-12h-20260102-150405") + if h != "groot-capture-7kqv2xy-since-12h" || ts != "20260102-150405" { + t.Fatalf("since: got %q / %q", h, ts) + } + h, ts = splitSessionBase("no-timestamp-here") + if h != "no-timestamp-here" || ts != "" { + t.Fatalf("no ts: got %q / %q", h, ts) + } } func TestEnsureGroupDirs(t *testing.T) { diff --git a/internal/config/sample.go b/internal/config/sample.go index 803726f..c2929b7 100644 --- a/internal/config/sample.go +++ b/internal/config/sample.go @@ -16,7 +16,10 @@ kubeconfig: "" # beside it. Supports ~ and ${VAR} expansion. output_dir: "./out" -# Prefix for capture directory and archive basename: -[-since-]- +# Prefix for capture directory and archive basename: +# ----- +# The cluster segment is always last (after the timestamp), so it can contain +# any characters (DO-style hosts, UUIDs, dashes, dots) without ambiguity. file_prefix: "groot-capture" # Optional label for archive basename segment. When empty, Groot resolves in order: