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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<duration>` 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.
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 **`<cluster>`** 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 **`<file_prefix>-<short>-<timestamp>[-since-<slug>]/`**, then **`<sessionBase>-<cluster>[-<message>].tar.gz`** beside it. **`<short>`** is from **`run_id`** (concurrent-safe). Supports **`~`** and **`${VAR}`** expansion. |
| **`output_dir`** | Base directory: each run creates **`<file_prefix>-<short>[-since-<slug>]-<timestamp>/`**, then **`<sessionBase>-<cluster>[-<message>].tar.gz`** beside it. **`<short>`** 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)). |
Expand All @@ -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-<slug>`** 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-<slug>`** 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/<node>/proxy/logs/messages`** → **`nodes/<node>.log`** (host **`/var/log/messages`** when the kubelet serves it — common on **AKS** and other managed nodes); (2) **GET** **`/api/v1/nodes/<node>/proxy/logs/?query=kubelet`** (optional **`&tailLines=N`**) → **`nodes/<node>-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. |
Expand Down Expand Up @@ -703,6 +703,12 @@ Capture output names use **`file_prefix`** (default **`groot-capture`**):
- **directory:** `<file_prefix>-<short>-<timestamp>` or `<file_prefix>-<short>-<timestamp>-since-<slug>` when **`pod_logs_since`** / **`--since`** is set (`<short>` = lowercase `run_id` suffix)
- **archive:** `<sessionBase>-<cluster>[-<message>].tar.gz` (for example **`groot-capture-7kqv2xy-20260606-120000-my-cluster.tar.gz`**)

The **`--message`** and the **`-since-<duration>`** marker both sit BEFORE the
timestamp in the archive basename, so the **cluster** is always the LAST
segment (everything after the `-<YYYYMMDD>-<HHMMSS>` 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, **`<slug>`** is a filesystem-safe form of the duration (for example **`12h`**, **`45m`**).

`--message` is sanitized before use:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.2
1.1.3
2 changes: 1 addition & 1 deletion contrib/freebsd/Makefile
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion contrib/man/man1/groot.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion contrib/man/man1/kubectl-groot.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions contrib/openbsd/port/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Binary file modified docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 37 additions & 10 deletions internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<file_prefix>-<short>-<timestamp>" or
// "<file_prefix>-<short>-<timestamp>-since-<slug>" when pod_logs_since is set.
// "<file_prefix>-<short>-<since>-<timestamp>" when pod_logs_since is set.
// The -since-<duration> is placed BEFORE the timestamp so the trailing
// "<timestamp>-<cluster>" is a clean positional anchor: everything after the
// timestamp is the cluster, everything before it is free-form metadata.
// <short> 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 {
Expand All @@ -553,13 +554,39 @@ func sanitizeFilePrefix(p string) string {
}

func archiveBasename(sessionBase, clusterName, message string) string {
name := fmt.Sprintf("%s-%s", sessionBase, clusterName)
// sessionBase = "<prefix>-<short>[-since-<duration>]-<timestamp>". 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:
// "-<YYYYMMDD>-<HHMMSS>". The -since-<duration> (when present) sits BEFORE the
// timestamp, so the timestamp is always the terminal slot.
var tsTailRegex = regexp.MustCompile(`-\d{8}-\d{6}$`)

// splitSessionBase splits "<prefix>-<short>[-since-<duration>]-<timestamp>"
// into the leading head ("<prefix>-<short>[-since-<duration>]") and the
// trailing timestamp ("<YYYYMMDD>-<HHMMSS>"), 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, ":", "_")
Expand Down
38 changes: 34 additions & 4 deletions internal/collector/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package collector
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/hrodrig/groot/internal/config"
Expand Down Expand Up @@ -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", "")
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion internal/config/sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ kubeconfig: ""
# beside it. Supports ~ and ${VAR} expansion.
output_dir: "./out"

# Prefix for capture directory and archive basename: <file_prefix>-<timestamp>[-since-<slug>]-<cluster>
# Prefix for capture directory and archive basename:
# <file_prefix>-<short>-<since?>-<message?>-<timestamp>-<cluster>
# 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 <cluster> segment. When empty, Groot resolves in order:
Expand Down
Loading