├── .github └── workflows │ ├── build-and-test.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── colorization.go ├── config.go ├── controller.go ├── docs └── terminal.gif ├── gen_version.sh ├── go.mod ├── go.sum ├── main.go ├── matching.go ├── printing.go ├── tailer.go ├── terminal.go └── version.go /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@v5 13 | with: {go-version: '^1.23'} 14 | 15 | - name: Build 16 | run: go build -v ./... 17 | 18 | - name: Test 19 | run: go test -v ./... 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | test: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: {go-version: '^1.23'} 16 | - run: | 17 | go generate ./version.go 18 | - run: | 19 | env GO111MODULE=off go get github.com/mitchellh/gox 20 | - run: | 21 | gox -osarch="darwin/amd64 darwin/arm64 linux/amd64 linux/arm windows/amd64" -output="ktail-{{.OS}}-{{.Arch}}" 22 | - uses: marvinpinto/action-automatic-releases@v1.2.1 23 | with: 24 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 25 | prerelease: false 26 | files: | 27 | ktail-darwin-amd64 28 | ktail-darwin-arm64 29 | ktail-linux-amd64 30 | ktail-linux-arm 31 | ktail-windows-amd64.exe 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | build 3 | /dist 4 | /ktail-*.tar.gz 5 | homebrew 6 | /ktail 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [v1.0.1](https://github.com/atombender/ktail/releases/tag/v1.0.1) (2020-08-10) 2 | 3 | ## Features 4 | 5 | * Adds `--version` flag. 6 | 7 | # [v1.0.0](https://github.com/atombender/ktail/releases/tag/v1.0.0) (2020-06-17) 8 | 9 | ## Fixes 10 | 11 | * Fixes issue where `--all-namespaces` would accidentally cause pods to be tailed from their start time. 12 | 13 | ## Other 14 | 15 | * Built with Go 1.14.4. 16 | 17 | # [v0.12.0](https://github.com/atombender/ktail/releases/tag/v0.12.0) (2020-05-01) 18 | 19 | ## Features 20 | 21 | * Updates client to support newer versions of Kubernetes. 22 | * Adds `-s` as shorthand for `--since-start`. 23 | 24 | # [v0.11.0](https://github.com/atombender/ktail/releases/tag/v0.10.0) (2019-06-13) 25 | 26 | ## Features 27 | 28 | * Add `--raw`/`-r` flag, which causes messages to not be formatted with any metadata, unless `--timestamps` is also used. 29 | * Add `-T` as alias for `--timestamps`. 30 | 31 | # [v0.10.0](https://github.com/atombender/ktail/releases/tag/v0.10.0) (2018-06-11) 32 | 33 | ## Fixes 34 | 35 | * Fix parsing of very long lines. 36 | 37 | # [v0.9.0](https://github.com/atombender/ktail/releases/tag/v0.9.0) (2018-06-08) 38 | 39 | ## Fixes 40 | 41 | * Fix timestamp comparison logic that was only supposed to be triggered when recovering from a stream error, and which caused lines to be ignored if sharing the exact same timestamp. 42 | 43 | # [v0.8.0](https://github.com/atombender/ktail/releases/tag/v0.8.0) (2018-06-05) 44 | 45 | ## Fixes 46 | 47 | * Fix surprisingly broken exclusion matching. 48 | 49 | # [v0.7.0](https://github.com/atombender/ktail/releases/tag/v0.7.0) (2018-05-15) 50 | 51 | ## Features 52 | 53 | * Add `--exclude`, `-x` flag to exclude pods and containers. 54 | * Add `--since-start` to get logs since container start. 55 | 56 | ## Fixes 57 | 58 | * Fix rare edge case where we might use the wrong timestamp if a newly discovered pod has multiple containers that started at different times. 59 | * Fix rare edge case where a new container would not be detected because it has the exact same name as a previous instance. 60 | 61 | # [v0.6.0](https://github.com/atombender/ktail/releases/tag/v0.6.0) (2017-12-14) 62 | 63 | ## Fixes 64 | 65 | * Fix race conditions causing some log entries to be dropped on startup, as well as if a container is flapping. 66 | * More fine-grained container status (e.g. a crashed container will not be tracked until it starts again). 67 | 68 | # [v0.5.0](https://github.com/atombender/ktail/releases/tag/v0.5.0) (2017-06-01) 69 | 70 | ## Fixes 71 | 72 | * Fix concurrent mutation bug, causing wrong pod/container to be followed. 73 | 74 | # [v0.4.0](https://github.com/atombender/ktail/releases/tag/v0.4.0) (2017-06-01) 75 | 76 | ## Fixes 77 | 78 | * Fix a weird edge case where logs would sometimes not appear. 79 | 80 | # [v0.3.0](https://github.com/atombender/ktail/releases/tag/v0.3.0) (2017-05-16) 81 | 82 | ## Fixes 83 | 84 | * Upgrade to newer Kubernetes client library, which fixes issues with the `gcp` auth provider. 85 | 86 | # [v0.2.0](https://github.com/atombender/ktail/releases/tag/v0.2.0) (2017-05-16) 87 | 88 | ## Features 89 | 90 | * Filtering by pod/container name. 91 | 92 | # [v0.1.0](https://github.com/atombender/ktail/releases/tag/v0.1.0) (2017-04-24) 93 | 94 | ## Features 95 | 96 | Initial release. 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Alexander Staubo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | default: build 3 | 4 | NAME := ktail 5 | ARCH := $(shell uname -m) 6 | 7 | BUILD_DIR := $(PWD)/build 8 | GO_PACKAGE_PATH := $(GOPATH)/src/github.com/atombender/ktail 9 | GO := go 10 | 11 | GO_SRC := $(shell find . -name '*.go' -type f | fgrep -v ./vendor/ | fgrep -v '${BUILD_DIR}') 12 | 13 | .PHONY: build 14 | build: $(BUILD_DIR)/ktail 15 | 16 | $(BUILD_DIR): 17 | mkdir $(BUILD_DIR) 18 | 19 | $(BUILD_DIR)/ktail: $(BUILD_DIR) $(GO_SRC) 20 | $(GO) build -o ${BUILD_DIR}/ktail github.com/atombender/ktail 21 | 22 | .PHONY: clean 23 | clean: $(BUILD_DIR) 24 | rm -f $(BUILD_DIR)/ktail 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `ktail` is a tool to tail Kubernetes containers 2 | 3 | ## It's like `kubectl logs`, but with a bunch of nice features 4 | 5 | ![Terminal animation](./docs/terminal.gif) 6 | 7 | :white_check_mark: **Detects pods and containers as they come and go**. If you run `ktail foo` and later start a pod or container named `foo`, then it will be picked up automatically. `kubectl` only works on a running pod/container. 8 | 9 | :white_check_mark: **Tails multiple pods and containers in multiple namespaces** at the same time, based on names and labels. `kubectl` can only tail a single pod and container. ktail will match the pattern or patterns you specify against both the pod name and the container name. 10 | 11 | :white_check_mark: **All containers in a pod are tailed by default**, not just a specific one. With `kubectl`, you have to use `-c`. With ktail, just do `ktail foo` and all its containers are automatically tailed. 12 | 13 | :white_check_mark: **Recovers from failure**. ktail will keep retrying forever. `kubectl` often just gives up. 14 | 15 | :white_check_mark: **Better formatting**. ktail will show log lines in different colours, and has syntax highlighting of JSON payloads. 16 | 17 | # Usage 18 | 19 | ktail makes it super easy to tail by pod or container name. The following will match all containers whose pod name or container name contains the substring `foo`: 20 | 21 | ```shell 22 | $ ktail foo 23 | ``` 24 | 25 | The arguments are regular expressions, so this is possible: 26 | 27 | ```shell 28 | $ ktail '^foo' 29 | ``` 30 | 31 | If no filters are specified, _all_ pods in the current namespace are tailed. 32 | 33 | Tailing supports the usual things like labels: 34 | 35 | ```shell 36 | $ ktail -l app=myapp 37 | ``` 38 | 39 | This will tail all containers in all pods matching the label `app=myapp`. As new pods are created, it will also automatically tail those, too. 40 | 41 | To abort tailing, hit `Ctrl+C`. 42 | 43 | ## Options 44 | 45 | Run `ktail -h` for usage. 46 | 47 | ## Configuration 48 | 49 | Ktail will read the file `$HOME/.config/ktail/config.yml` if it exists. This must be a file in YAML format. The following options can be set (these are the defaults): 50 | 51 | ```yaml 52 | noColor: false 53 | raw: false 54 | timestamps: false 55 | quiet: false 56 | colorScheme: bw 57 | colorMode: auto 58 | kubeConfigPath: "" 59 | templateString: "" 60 | ``` 61 | 62 | ## Templating 63 | 64 | ktail has a basic output format. To override, you can use a simple Go template. For example: 65 | 66 | ```shell 67 | $ ktail -t "{{.Container.Name}} {{.Message}}" 68 | ``` 69 | 70 | The following variables are available: 71 | 72 | * `Timestamp`: The time of the log event. 73 | * `Message`: The log message. 74 | * `Pod`: The pod object. It has properties such as `Name`, `Namespace`, `Status`, etc. 75 | * `Container`: The container object. It has properties such as `Name`. 76 | 77 | # Installation 78 | 79 | ## Homebrew 80 | 81 | ```shell 82 | $ brew tap atombender/ktail 83 | $ brew install atombender/ktail/ktail 84 | ``` 85 | 86 | ## Binary installation 87 | 88 | Precompiled binaries for Windows, macOS, Linux (x64 and ARM) are available on the [GitHub release page](https://github.com/atombender/ktail/releases). 89 | 90 | ## From source 91 | 92 | This requires Go >= 1.10, as we use Go modules. 93 | 94 | ```shell 95 | $ mkdir -p $GOPATH/src/github.com/atombender 96 | $ cd $GOPATH/src/github.com/atombender 97 | $ git clone https://github.com/atombender/ktail 98 | $ cd ktail 99 | $ go install . 100 | ``` 101 | 102 | # Acknowledgements 103 | 104 | Some setup code was borrowed from [k8stail](https://github.com/dtan4/k8stail). 105 | 106 | # License 107 | 108 | MIT license. See `LICENSE` file. 109 | -------------------------------------------------------------------------------- /colorization.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "hash/fnv" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | type colorConfig struct { 10 | labels *color.Color 11 | metadata *color.Color 12 | } 13 | 14 | var colorConfigs = []colorConfig{ 15 | { 16 | color.New(color.FgHiBlue).Add(color.Bold), 17 | color.New(color.FgBlue).Add(color.Bold), 18 | }, 19 | { 20 | color.New(color.FgHiCyan).Add(color.Bold), 21 | color.New(color.FgCyan).Add(color.Bold), 22 | }, 23 | { 24 | color.New(color.FgHiGreen).Add(color.Bold), 25 | color.New(color.FgGreen).Add(color.Bold), 26 | }, 27 | { 28 | color.New(color.FgHiMagenta).Add(color.Bold), 29 | color.New(color.FgMagenta).Add(color.Bold), 30 | }, 31 | { 32 | color.New(color.FgHiRed).Add(color.Bold), 33 | color.New(color.FgRed).Add(color.Bold), 34 | }, 35 | { 36 | color.New(color.FgHiYellow).Add(color.Bold), 37 | color.New(color.FgYellow).Add(color.Bold), 38 | }, 39 | } 40 | 41 | func getColorConfig(parts ...string) colorConfig { 42 | hash := fnv.New32() 43 | for _, a := range parts { 44 | _, _ = hash.Write([]byte(a)) 45 | } 46 | return colorConfigs[hash.Sum32()%uint32(len(colorConfigs))] 47 | } 48 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "k8s.io/apimachinery/pkg/util/yaml" 9 | ) 10 | 11 | type Config struct { 12 | Quiet bool `yaml:"quiet"` 13 | NoColor bool `yaml:"noColor"` 14 | Raw bool `yaml:"raw"` 15 | Timestamps bool `yaml:"timestamps"` 16 | ColorMode string `yaml:"colorMode"` 17 | ColorScheme string `yaml:"colorScheme"` 18 | TemplateString string `yaml:"templateString"` 19 | KubeConfigPath string `yaml:"kubeConfigPath"` 20 | } 21 | 22 | func (c *Config) LoadDefault() error { 23 | home := os.Getenv("HOME") 24 | if home == "" { 25 | return nil 26 | } 27 | if err := c.LoadFromPath(filepath.Join(home, ".config", "ktail", "config.yml")); err != nil && 28 | !os.IsNotExist(err) { 29 | return err 30 | } 31 | return nil 32 | } 33 | 34 | func (c *Config) LoadFromPath(path string) error { 35 | data, err := os.ReadFile(path) 36 | if err != nil { 37 | return err 38 | } 39 | if err := yaml.UnmarshalStrict(data, c); err != nil { 40 | return fmt.Errorf("parsing config file %q: %w", path, err) 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "k8s.io/client-go/kubernetes" 10 | 11 | v1 "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/internalversion" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/fields" 15 | "k8s.io/client-go/tools/cache" 16 | ) 17 | 18 | type ControllerOptions struct { 19 | Namespaces []string 20 | InclusionMatcher Matcher 21 | ExclusionMatcher Matcher 22 | SinceStart bool 23 | Since *time.Time 24 | } 25 | 26 | type ( 27 | ContainerEnterFunc func(pod *v1.Pod, container *v1.Container, initialAddPhase bool) bool 28 | ContainerExitFunc func(pod *v1.Pod, container *v1.Container) 29 | ContainerErrorFunc func(pod *v1.Pod, container *v1.Container, err error) 30 | ) 31 | 32 | type Callbacks struct { 33 | OnEvent LogEventFunc 34 | OnEnter ContainerEnterFunc 35 | OnExit ContainerExitFunc 36 | OnError ContainerErrorFunc 37 | OnNothingDiscovered func() 38 | } 39 | 40 | type Controller struct { 41 | ControllerOptions 42 | client kubernetes.Interface 43 | tailers map[string]*ContainerTailer 44 | callbacks Callbacks 45 | sync.Mutex 46 | } 47 | 48 | func NewController(client kubernetes.Interface, options ControllerOptions, callbacks Callbacks) *Controller { 49 | return &Controller{ 50 | ControllerOptions: options, 51 | client: client, 52 | tailers: map[string]*ContainerTailer{}, 53 | callbacks: callbacks, 54 | } 55 | } 56 | 57 | func (ctl *Controller) Run(ctx context.Context) error { 58 | stopCh := make(chan struct{}) 59 | defer close(stopCh) 60 | 61 | discoveredAny := false 62 | for _, ns := range ctl.Namespaces { 63 | podListWatcher := cache.NewListWatchFromClient( 64 | ctl.client.CoreV1().RESTClient(), "pods", ns, fields.Everything()) 65 | 66 | obj, err := podListWatcher.List(metav1.ListOptions{}) 67 | if err != nil { 68 | return fmt.Errorf("listing pods in %q: %w", ns, err) 69 | } 70 | switch t := obj.(type) { 71 | case *v1.PodList: 72 | for _, pod := range t.Items { 73 | if ctl.onInitialAdd(&pod) { 74 | discoveredAny = true 75 | } 76 | } 77 | case *internalversion.List: 78 | for _, item := range t.Items { 79 | if pod, ok := item.(*v1.Pod); ok { 80 | if ctl.onInitialAdd(pod) { 81 | discoveredAny = true 82 | } 83 | } 84 | } 85 | default: 86 | panic(fmt.Sprintf("unexpected return type %T when listing pods", obj)) 87 | } 88 | 89 | _, informer := cache.NewIndexerInformer( 90 | podListWatcher, &v1.Pod{}, 0, cache.ResourceEventHandlerFuncs{ 91 | AddFunc: func(obj interface{}) { 92 | if pod, ok := obj.(*v1.Pod); ok { 93 | ctl.onAdd(pod) 94 | } 95 | }, 96 | UpdateFunc: func(old interface{}, new interface{}) { 97 | if pod, ok := new.(*v1.Pod); ok { 98 | ctl.onUpdate(pod) 99 | } 100 | }, 101 | DeleteFunc: func(obj interface{}) { 102 | if pod, ok := obj.(*v1.Pod); ok { 103 | ctl.onDelete(pod) 104 | } 105 | }, 106 | }, cache.Indexers{}) 107 | 108 | go informer.Run(stopCh) 109 | } 110 | 111 | if !discoveredAny { 112 | ctl.callbacks.OnNothingDiscovered() 113 | } 114 | 115 | <-ctx.Done() 116 | return ctx.Err() 117 | } 118 | 119 | func (ctl *Controller) onInitialAdd(pod *v1.Pod) bool { 120 | added := false 121 | for _, container := range pod.Spec.InitContainers { 122 | if ctl.shouldIncludeContainer(pod, &container) { 123 | ctl.addContainer(pod, &container, true) 124 | added = true 125 | } 126 | } 127 | for _, container := range pod.Spec.Containers { 128 | if ctl.shouldIncludeContainer(pod, &container) { 129 | ctl.addContainer(pod, &container, true) 130 | added = true 131 | } 132 | } 133 | return added 134 | } 135 | 136 | func (ctl *Controller) onAdd(pod *v1.Pod) { 137 | for _, container := range pod.Spec.InitContainers { 138 | if ctl.shouldIncludeContainer(pod, &container) { 139 | ctl.addContainer(pod, &container, false) 140 | } 141 | } 142 | for _, container := range pod.Spec.Containers { 143 | if ctl.shouldIncludeContainer(pod, &container) { 144 | ctl.addContainer(pod, &container, false) 145 | } 146 | } 147 | } 148 | 149 | func (ctl *Controller) onUpdate(pod *v1.Pod) { 150 | containers := pod.Spec.Containers 151 | containerStatuses := allContainerStatusesForPod(pod) 152 | for _, containerStatus := range containerStatuses { 153 | var container *v1.Container 154 | for i, c := range containers { 155 | if c.Name == containerStatus.Name { 156 | container = &containers[i] 157 | break 158 | } 159 | } 160 | if container == nil { 161 | // Should be impossible; means there's a status for a container that isn't 162 | // part of the spec 163 | continue 164 | } 165 | 166 | if ctl.shouldIncludeContainer(pod, container) { 167 | ctl.addContainer(pod, container, false) 168 | } else { 169 | ctl.deleteContainer(pod, container) 170 | } 171 | } 172 | } 173 | 174 | func (ctl *Controller) onDelete(pod *v1.Pod) { 175 | for _, container := range pod.Spec.Containers { 176 | ctl.deleteContainer(pod, &container) 177 | } 178 | } 179 | 180 | func (ctl *Controller) shouldIncludeContainer(pod *v1.Pod, container *v1.Container) bool { 181 | if !(pod.Status.Phase == v1.PodRunning || pod.Status.Phase == v1.PodPending) { 182 | return false 183 | } 184 | 185 | running := false 186 | for _, s := range allContainerStatusesForPod(pod) { 187 | if s.Name == container.Name && (s.State.Waiting != nil || s.State.Terminated != nil || 188 | s.State.Running != nil) { 189 | running = true 190 | break 191 | } 192 | } 193 | if !running { 194 | return false 195 | } 196 | 197 | if ctl.ExclusionMatcher.Match(pod) { 198 | return false 199 | } 200 | if !(ctl.InclusionMatcher.Match(pod) || ctl.InclusionMatcher.Match(container)) { 201 | return false 202 | } 203 | return !ctl.ExclusionMatcher.Match(container) 204 | } 205 | 206 | func (ctl *Controller) addContainer(pod *v1.Pod, container *v1.Container, initialAdd bool) { 207 | ctl.Lock() 208 | defer ctl.Unlock() 209 | 210 | key := buildKey(pod, container) 211 | if _, ok := ctl.tailers[key]; ok { 212 | return 213 | } 214 | 215 | if !ctl.callbacks.OnEnter(pod, container, initialAdd) { 216 | return 217 | } 218 | 219 | fromTimestamp, ok := ctl.getStartTimestamp(pod, container, initialAdd) 220 | if !ok { 221 | return 222 | } 223 | 224 | targetPod, targetContainer := *pod, *container // Copy to avoid mutation 225 | 226 | tailer := NewContainerTailer(ctl.client, targetPod, targetContainer, 227 | ctl.callbacks.OnEvent, fromTimestamp) 228 | ctl.tailers[key] = tailer 229 | 230 | go func() { 231 | tailer.Run(context.Background(), func(err error) { 232 | ctl.callbacks.OnError(&targetPod, &targetContainer, err) 233 | }) 234 | }() 235 | } 236 | 237 | func (ctl *Controller) deleteContainer(pod *v1.Pod, container *v1.Container) { 238 | ctl.Lock() 239 | defer ctl.Unlock() 240 | 241 | key := buildKey(pod, container) 242 | if tailer, ok := ctl.tailers[key]; ok { 243 | delete(ctl.tailers, key) 244 | tailer.Stop() 245 | ctl.callbacks.OnExit(pod, container) 246 | } 247 | } 248 | 249 | func (ctl *Controller) getStartTimestamp(pod *v1.Pod, container *v1.Container, initialAdd bool) (*time.Time, bool) { 250 | switch { 251 | case ctl.SinceStart: 252 | return nil, true 253 | case ctl.Since != nil: 254 | return ctl.Since, true 255 | case initialAdd: 256 | // Don't show any history, but add a small amount of buffer to 257 | // account for clock skew 258 | now := time.Now().Add(time.Second * -5) 259 | return &now, true 260 | default: 261 | var t *time.Time 262 | for _, status := range allContainerStatusesForPod(pod) { 263 | if status.Name == container.Name && status.State.Running != nil { 264 | startTime := status.State.Running.StartedAt.Time 265 | if t == nil || startTime.Before(*t) { 266 | t = &startTime 267 | } 268 | } 269 | } 270 | if t == nil { 271 | return nil, false 272 | } 273 | return t, true 274 | } 275 | } 276 | 277 | func buildKey(pod *v1.Pod, container *v1.Container) string { 278 | return fmt.Sprintf("%s/%s/%s", pod.Namespace, pod.Name, container.Name) 279 | } 280 | 281 | func allContainerStatusesForPod(pod *v1.Pod) []v1.ContainerStatus { 282 | statuses := make([]v1.ContainerStatus, len(pod.Status.ContainerStatuses)+len(pod.Status.InitContainerStatuses)) 283 | return append( 284 | append(statuses, pod.Status.InitContainerStatuses...), 285 | pod.Status.ContainerStatuses...) 286 | } 287 | -------------------------------------------------------------------------------- /docs/terminal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atombender/ktail/9f4122b591c0ebfc5ebded67e09adf431dc321c0/docs/terminal.gif -------------------------------------------------------------------------------- /gen_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | v=$(git tag -l | tail -n1) 4 | if [ "$v" = "" ]; then 5 | echo "Cannot determine tag." >&2 6 | exit 1 7 | fi 8 | 9 | v=$(echo "$v" | sed -Ee 's#^v(.*)#\1#') 10 | 11 | cat <version.go 12 | // Code generated by gen_version.sh, DO NOT EDIT. 13 | 14 | package main 15 | 16 | //go:generate sh -c ./gen_version.sh 17 | 18 | const version = "$v" 19 | END 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/atombender/ktail 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.0 6 | 7 | require ( 8 | github.com/alecthomas/chroma v0.10.0 9 | github.com/fatih/color v1.7.0 10 | github.com/go-logr/logr v1.4.2 11 | github.com/jpillora/backoff v1.0.0 12 | github.com/spf13/pflag v1.0.5 13 | golang.org/x/crypto v0.24.0 14 | k8s.io/api v0.31.0 15 | k8s.io/apimachinery v0.31.0 16 | k8s.io/client-go v0.31.0 17 | k8s.io/klog/v2 v2.130.1 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 22 | github.com/dlclark/regexp2 v1.4.0 // indirect 23 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 24 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 25 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 26 | github.com/go-openapi/jsonreference v0.20.2 // indirect 27 | github.com/go-openapi/swag v0.22.4 // indirect 28 | github.com/gogo/protobuf v1.3.2 // indirect 29 | github.com/golang/protobuf v1.5.4 // indirect 30 | github.com/google/gnostic-models v0.6.8 // indirect 31 | github.com/google/go-cmp v0.6.0 // indirect 32 | github.com/google/gofuzz v1.2.0 // indirect 33 | github.com/google/uuid v1.6.0 // indirect 34 | github.com/imdario/mergo v0.3.6 // indirect 35 | github.com/josharian/intern v1.0.0 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/mailru/easyjson v0.7.7 // indirect 38 | github.com/mattn/go-colorable v0.1.12 // indirect 39 | github.com/mattn/go-isatty v0.0.14 // indirect 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 41 | github.com/modern-go/reflect2 v1.0.2 // indirect 42 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 43 | github.com/x448/float16 v0.8.4 // indirect 44 | golang.org/x/net v0.26.0 // indirect 45 | golang.org/x/oauth2 v0.21.0 // indirect 46 | golang.org/x/sys v0.21.0 // indirect 47 | golang.org/x/term v0.21.0 // indirect 48 | golang.org/x/text v0.16.0 // indirect 49 | golang.org/x/time v0.3.0 // indirect 50 | google.golang.org/protobuf v1.34.2 // indirect 51 | gopkg.in/inf.v0 v0.9.1 // indirect 52 | gopkg.in/yaml.v2 v2.4.0 // indirect 53 | gopkg.in/yaml.v3 v3.0.1 // indirect 54 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 55 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect 56 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 57 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 58 | sigs.k8s.io/yaml v1.4.0 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 9 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 10 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 11 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 12 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 13 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 14 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 15 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 16 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 17 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 18 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 19 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 20 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 21 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 22 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 23 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 24 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 25 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 26 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 27 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 28 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 29 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 30 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 31 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 32 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 33 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 35 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 37 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 38 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 39 | github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= 40 | github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= 41 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 42 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 43 | github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= 44 | github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 45 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 46 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 47 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 48 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 49 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 50 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 51 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 52 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 53 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 59 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 60 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 61 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 62 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 63 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 64 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 65 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 66 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 67 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 68 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 69 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 70 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 71 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 72 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 73 | github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= 74 | github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= 75 | github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= 76 | github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 77 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 78 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 79 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 80 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 81 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 82 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 83 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 84 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 85 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 86 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 87 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 88 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 89 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 90 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 91 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 92 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 93 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 94 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 95 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 96 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 97 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 98 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 99 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 100 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 101 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 102 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 103 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 104 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 105 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 106 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 107 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 108 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 109 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 110 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 111 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 112 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 113 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 114 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 115 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 116 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 117 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 122 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 123 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 124 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 125 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 126 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 127 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 128 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 129 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 130 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 131 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 132 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 133 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 134 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 135 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 136 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 137 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 138 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 139 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 140 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 141 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 142 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 143 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 144 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 145 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 146 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 147 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 148 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 149 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 150 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 151 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 153 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 154 | k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= 155 | k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= 156 | k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= 157 | k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= 158 | k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= 159 | k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= 160 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 161 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 162 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 163 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 164 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= 165 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 166 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 167 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 168 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 169 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 170 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 171 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 172 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "regexp" 11 | "sync" 12 | "text/template" 13 | "time" 14 | 15 | _ "github.com/alecthomas/chroma/formatters" 16 | "github.com/alecthomas/chroma/quick" 17 | "github.com/fatih/color" 18 | "github.com/go-logr/logr" 19 | "github.com/spf13/pflag" 20 | v1 "k8s.io/api/core/v1" 21 | "k8s.io/apimachinery/pkg/labels" 22 | "k8s.io/client-go/kubernetes" 23 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 24 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 25 | "k8s.io/client-go/tools/clientcmd" 26 | "k8s.io/klog/v2" 27 | ) 28 | 29 | func main() { 30 | klog.SetLogger(logr.New(&kubeLogger{})) 31 | 32 | cfg := Config{ 33 | ColorMode: "auto", 34 | ColorScheme: "bw", 35 | } 36 | 37 | var ( 38 | contextName string 39 | labelSelectorExpr string 40 | namespaces []string 41 | allNamespaces bool 42 | 43 | kubeconfigPath string 44 | quiet bool 45 | timestamps bool 46 | raw bool 47 | tmplString string 48 | sinceStart bool 49 | sinceExpr string 50 | showVersion bool 51 | includePatterns []*regexp.Regexp 52 | excludePatternStrings []string 53 | noColor bool 54 | colorMode string 55 | colorScheme string 56 | ) 57 | 58 | if err := cfg.LoadDefault(); err != nil { 59 | fail(err.Error()) 60 | } 61 | 62 | flags := pflag.NewFlagSet("ktail", pflag.ContinueOnError) 63 | flags.SortFlags = false 64 | flags.Usage = func() { 65 | fmt.Printf("Usage: ktail [OPTION ...] PATTERN [PATTERN ...]\n") 66 | flags.PrintDefaults() 67 | } 68 | flags.StringVar(&contextName, "context", "", "Kubernetes context name") 69 | flags.StringArrayVarP(&namespaces, "namespace", "n", []string{}, "Kubernetes namespace") 70 | flags.BoolVar(&allNamespaces, "all-namespaces", false, "Apply to all Kubernetes namespaces") 71 | flags.StringArrayVarP(&excludePatternStrings, "exclude", "x", []string{}, 72 | "Exclude using a regular expression. Pattern can be repeated. Takes priority over"+ 73 | " include patterns and labels.") 74 | flags.StringVarP(&labelSelectorExpr, "selector", "l", "", 75 | "Match pods by label (see 'kubectl get -h' for syntax).") 76 | flags.BoolVarP(&sinceStart, "since-start", "s", false, 77 | "Start reading log from the beginning of the container's lifetime.") 78 | flags.BoolVarP(&showVersion, "version", "", false, "Show version.") 79 | flags.StringVarP(&sinceExpr, "since", "S", "", "Get logs since a given time (e.g. 2023-03-30) or duration (e.g. 1h).") 80 | 81 | flags.StringVar(&kubeconfigPath, "kubeconfig", cfg.KubeConfigPath, 82 | "Path to kubeconfig (only required out-of-cluster)") 83 | flags.StringVarP(&tmplString, "template", "t", cfg.TemplateString, 84 | "Template to format each line. For example, for"+ 85 | " just the message, use --template '{{ .Message }}'.") 86 | flags.BoolVarP(&raw, "raw", "r", cfg.Raw, "Don't format output; output messages only (unless --timestamps)") 87 | flags.BoolVarP(×tamps, "timestamps", "T", cfg.Timestamps, "Include timestamps on each line") 88 | flags.BoolVarP(&quiet, "quiet", "q", cfg.Quiet, "Don't print events about new/deleted pods") 89 | flags.BoolVar(&noColor, "no-color", cfg.NoColor, "Alias for --color=never.") 90 | flags.StringVar(&colorMode, "color", cfg.ColorMode, "Set color mode: one of 'auto' (default), 'never', or 'always'. (Aliased as --colour.)") 91 | flags.StringVar(&colorMode, "colour", cfg.ColorMode, "Set color mode: one of 'auto' (default), 'never', or 'always'.") 92 | flags.StringVar(&colorScheme, "color-scheme", cfg.ColorScheme, "Set color scheme (see https://github.com/alecthomas/chroma/tree/master/styles). (Aliased as --colour-scheme.)") 93 | flags.StringVar(&colorScheme, "colour-scheme", cfg.ColorScheme, "Set color scheme (see https://github.com/alecthomas/chroma/tree/master/styles).") 94 | _ = flags.MarkHidden("colour") 95 | _ = flags.MarkHidden("colour-scheme") 96 | 97 | if err := flags.Parse(os.Args[1:]); err != nil { 98 | if err == pflag.ErrHelp { 99 | os.Exit(2) 100 | } 101 | fail(err.Error()) 102 | } 103 | 104 | if noColor { 105 | colorMode = "never" 106 | } 107 | var colorEnabled bool 108 | switch colorMode { 109 | case "always": 110 | colorEnabled = true 111 | case "auto": 112 | colorEnabled = isTerminal(os.Stdout) 113 | case "never": 114 | } 115 | 116 | color.NoColor = !colorEnabled 117 | 118 | if showVersion { 119 | fmt.Printf("ktail %s\n", version) 120 | os.Exit(0) 121 | } 122 | 123 | var excludePatterns []*regexp.Regexp 124 | for _, p := range excludePatternStrings { 125 | r, err := regexp.Compile(p) 126 | if err != nil { 127 | fail("Invalid regexp: %q: %s\n", p, err) 128 | } 129 | excludePatterns = append(excludePatterns, r) 130 | } 131 | 132 | for _, arg := range flags.Args() { 133 | r, err := regexp.Compile(arg) 134 | if err != nil { 135 | fail("Invalid regexp: %q: %s\n", arg, err) 136 | } 137 | includePatterns = append(includePatterns, r) 138 | } 139 | 140 | labelSelector := labels.Everything() 141 | if labelSelectorExpr != "" { 142 | if sel, err := labels.Parse(labelSelectorExpr); err != nil { 143 | fail(err.Error()) 144 | } else { 145 | labelSelector = sel 146 | } 147 | } 148 | 149 | inclusionMatcher := buildMatcher(includePatterns, labelSelector, true) 150 | exclusionMatcher := buildMatcher(excludePatterns, nil, false) 151 | 152 | var loadingRules *clientcmd.ClientConfigLoadingRules 153 | if kubeconfigPath != "" { 154 | loadingRules = &clientcmd.ClientConfigLoadingRules{ 155 | ExplicitPath: kubeconfigPath, 156 | } 157 | } else { 158 | loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() 159 | } 160 | 161 | clientConfig := clientcmd.NewInteractiveDeferredLoadingClientConfig(loadingRules, 162 | &clientcmd.ConfigOverrides{ 163 | CurrentContext: contextName, 164 | }, 165 | nil) 166 | 167 | config, err := clientConfig.ClientConfig() 168 | if err != nil { 169 | fail(err.Error()) 170 | } 171 | 172 | // Set higher rate limits 173 | config.QPS = 100 174 | config.Burst = 100 175 | 176 | clientset, err := kubernetes.NewForConfig(config) 177 | if err != nil { 178 | fail(err.Error()) 179 | } 180 | 181 | rawConfig, err := clientConfig.RawConfig() 182 | if err != nil { 183 | fail(err.Error()) 184 | } 185 | 186 | if allNamespaces { 187 | namespaces = []string{v1.NamespaceAll} 188 | } else if len(namespaces) == 0 { 189 | if rawConfig.Contexts[rawConfig.CurrentContext].Namespace == "" { 190 | namespaces = []string{v1.NamespaceDefault} 191 | } else { 192 | namespaces = []string{rawConfig.Contexts[rawConfig.CurrentContext].Namespace} 193 | } 194 | } 195 | 196 | var tmpl *template.Template 197 | if tmplString != "" { 198 | var err error 199 | tmpl, err = template.New("line").Parse(tmplString) 200 | if err != nil { 201 | fail("invalid template: %s", err) 202 | } 203 | } 204 | 205 | since, err := parseSinceExpr(sinceExpr) 206 | if err != nil { 207 | fail("invalid --since flag: %s", err) 208 | } 209 | 210 | formatPod := func(pod *v1.Pod) string { 211 | if allNamespaces || len(namespaces) > 1 { 212 | return fmt.Sprintf("%s/%s", pod.Namespace, pod.Name) 213 | } 214 | return pod.Name 215 | } 216 | 217 | formatPodAndContainer := func(pod *v1.Pod, container *v1.Container) string { 218 | return fmt.Sprintf("%s:%s", formatPod(pod), container.Name) 219 | } 220 | 221 | var printEvent func(*LogEvent) error 222 | 223 | if tmpl != nil { 224 | printEvent = func(event *LogEvent) error { 225 | type templateEvent struct { 226 | Pod *v1.Pod 227 | Container *v1.Container 228 | Timestamp string 229 | Message string 230 | } 231 | 232 | var buf bytes.Buffer 233 | if err := tmpl.Execute(&buf, &templateEvent{ 234 | Pod: event.Pod, 235 | Container: event.Container, 236 | Message: event.Message, 237 | Timestamp: formatTimestamp(event.Timestamp), 238 | }); err != nil { 239 | return err 240 | } 241 | 242 | _, err := fmt.Fprintln(os.Stdout, buf.String()) 243 | return err 244 | } 245 | } else { 246 | printEvent = func(event *LogEvent) error { 247 | col := getColorConfig(event.Pod.Name, event.Container.Name) 248 | 249 | var line string 250 | if !raw { 251 | if timestamps { 252 | line = col.metadata.Sprint(formatTimestamp(event.Timestamp)) 253 | line += " " 254 | } 255 | if allNamespaces { 256 | line += col.labels.Sprint(fmt.Sprintf("%s/%s:%s", 257 | event.Pod.Namespace, event.Pod.Name, event.Container.Name)) 258 | } else { 259 | line += col.labels.Sprint(fmt.Sprintf("%s:%s", event.Pod.Name, event.Container.Name)) 260 | } 261 | line += " " 262 | } 263 | 264 | payload := event.Message 265 | if colorEnabled && len(payload) >= 2 && payload[0] == '{' && payload[len(payload)-1] == '}' { 266 | var dest interface{} 267 | if err := json.Unmarshal([]byte(payload), &dest); err == nil { 268 | var buf bytes.Buffer 269 | if err := quick.Highlight(&buf, payload, "json", "terminal256", colorScheme); err == nil { 270 | payload = buf.String() 271 | } 272 | } 273 | } 274 | 275 | line += payload 276 | 277 | _, err := fmt.Fprintln(os.Stdout, line) 278 | return err 279 | } 280 | } 281 | 282 | ctx, cancel := context.WithCancel(context.Background()) 283 | defer cancel() 284 | 285 | var stdoutMutex sync.Mutex 286 | controller := NewController(clientset, 287 | ControllerOptions{ 288 | Namespaces: namespaces, 289 | InclusionMatcher: inclusionMatcher, 290 | ExclusionMatcher: exclusionMatcher, 291 | Since: since, 292 | SinceStart: sinceStart, 293 | }, 294 | Callbacks{ 295 | OnEvent: func(event LogEvent) { 296 | stdoutMutex.Lock() 297 | defer stdoutMutex.Unlock() 298 | if err := printEvent(&event); err != nil { 299 | printError(fmt.Sprintf("Could not write event: %s", err)) 300 | cancel() 301 | } 302 | }, 303 | OnEnter: func(pod *v1.Pod, container *v1.Container, initialAddPhase bool) bool { 304 | if !quiet { 305 | if initialAddPhase { 306 | printInfo("Attached to container [%s]", formatPodAndContainer(pod, container)) 307 | } else { 308 | printInfo("New container [%s]", formatPodAndContainer(pod, container)) 309 | } 310 | } 311 | return true 312 | }, 313 | OnExit: func(pod *v1.Pod, container *v1.Container) { 314 | if !quiet { 315 | var status = "unknown" 316 | for _, containerStatus := range pod.Status.ContainerStatuses { 317 | if containerStatus.Name == container.Name { 318 | if containerStatus.State.Running != nil { 319 | status = "running" 320 | } else if containerStatus.State.Waiting != nil { 321 | status = "waiting" 322 | } else if containerStatus.State.Terminated != nil { 323 | status = "terminated" 324 | } 325 | break 326 | } 327 | } 328 | printInfo(fmt.Sprintf("Container left (%s) [%s]", status, 329 | formatPodAndContainer(pod, container))) 330 | } 331 | }, 332 | OnNothingDiscovered: func() { 333 | printInfo("No matching pods running yet") 334 | }, 335 | OnError: func(pod *v1.Pod, container *v1.Container, err error) { 336 | printError(fmt.Sprintf("Error while tailing container [%s]: %s", 337 | formatPodAndContainer(pod, container), err)) 338 | }, 339 | }) 340 | 341 | if err := controller.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { 342 | printError(err.Error()) 343 | } 344 | } 345 | 346 | func fail(format string, args ...interface{}) { 347 | msg := fmt.Sprintf(format, args...) 348 | _, _ = fmt.Fprintf(os.Stderr, fmt.Sprintf("fatal: %s\n", msg)) 349 | os.Exit(1) 350 | } 351 | 352 | func parseSinceExpr(s string) (*time.Time, error) { 353 | if s == "" { 354 | return nil, nil 355 | } 356 | for _, layout := range []string{ 357 | time.ANSIC, time.UnixDate, time.RubyDate, time.RFC822, time.RFC822Z, time.RFC850, time.RFC1123, 358 | time.RFC1123Z, time.RFC3339, time.RFC3339Nano, time.Kitchen, time.Stamp, time.StampMilli, 359 | time.StampMicro, time.StampNano, time.DateTime, time.DateOnly, time.TimeOnly, 360 | } { 361 | if t, err := time.Parse(layout, s); err == nil { 362 | return &t, nil 363 | } 364 | } 365 | dur, err := time.ParseDuration(s) 366 | if err != nil { 367 | return nil, fmt.Errorf("parsing as duration: %w", err) 368 | } 369 | t := time.Now().Add(-dur) 370 | return &t, nil 371 | } 372 | 373 | var ( 374 | colorInfo = color.New(color.FgYellow).SprintFunc() 375 | colorError = color.New(color.FgRed).SprintFunc() 376 | ) 377 | -------------------------------------------------------------------------------- /matching.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | 6 | "k8s.io/api/core/v1" 7 | "k8s.io/apimachinery/pkg/labels" 8 | ) 9 | 10 | type Matcher interface { 11 | Match(interface{}) bool 12 | } 13 | 14 | type and []Matcher 15 | 16 | func (m and) Match(value interface{}) bool { 17 | if len(m) == 0 { 18 | return false 19 | } 20 | for _, sm := range m { 21 | if !sm.Match(value) { 22 | return false 23 | } 24 | } 25 | return true 26 | } 27 | 28 | type or []Matcher 29 | 30 | func (m or) Match(value interface{}) bool { 31 | if len(m) == 0 { 32 | return false 33 | } 34 | for _, sm := range m { 35 | if sm.Match(value) { 36 | return true 37 | } 38 | } 39 | return false 40 | } 41 | 42 | type not struct { 43 | matcher Matcher 44 | } 45 | 46 | func (m not) Match(value interface{}) bool { 47 | if m.matcher == nil { 48 | return false 49 | } 50 | v := !m.matcher.Match(value) 51 | return v 52 | } 53 | 54 | type regexMatcher struct { 55 | regexp *regexp.Regexp 56 | } 57 | 58 | func (m regexMatcher) Match(value interface{}) bool { 59 | switch t := value.(type) { 60 | case *v1.Pod: 61 | return m.regexp.MatchString(t.Name) 62 | case *v1.Container: 63 | return m.regexp.MatchString(t.Name) 64 | default: 65 | } 66 | return false 67 | } 68 | 69 | type labelSelectorMatcher struct { 70 | selector labels.Selector 71 | } 72 | 73 | func (m labelSelectorMatcher) Match(value interface{}) bool { 74 | switch t := value.(type) { 75 | case *v1.Pod: 76 | return m.selector.Matches(labels.Set(t.Labels)) 77 | } 78 | return false 79 | } 80 | 81 | type trueMatcher struct{} 82 | 83 | func (trueMatcher) Match(value interface{}) bool { 84 | return true 85 | } 86 | 87 | type falseMatcher struct{} 88 | 89 | func (falseMatcher) Match(value interface{}) bool { 90 | return false 91 | } 92 | 93 | func buildAnd(a, b Matcher) Matcher { 94 | if a == nil { 95 | return b 96 | } 97 | if b == nil { 98 | return a 99 | } 100 | return and{a, b} 101 | } 102 | 103 | func buildMatcher( 104 | patterns []*regexp.Regexp, 105 | labelSelector labels.Selector, 106 | defaultMatch bool) Matcher { 107 | var matcher Matcher 108 | if len(patterns) > 0 { 109 | ors := make(or, len(patterns)) 110 | for i, r := range patterns { 111 | ors[i] = regexMatcher{regexp: r} 112 | } 113 | matcher = ors 114 | } else if defaultMatch { 115 | matcher = trueMatcher{} 116 | } else { 117 | matcher = falseMatcher{} 118 | } 119 | if labelSelector != nil && !labelSelector.Empty() { 120 | matcher = and{labelSelectorMatcher{labelSelector}, matcher} 121 | } 122 | return matcher 123 | } 124 | -------------------------------------------------------------------------------- /printing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/go-logr/logr" 10 | ) 11 | 12 | func printInfo(format string, args ...interface{}) { 13 | message := fmt.Sprintf(format, args...) 14 | _, _ = fmt.Fprint(os.Stderr, colorInfo("==> "+message+"\n")) 15 | } 16 | 17 | func printError(format string, args ...interface{}) { 18 | message := fmt.Sprintf(format, args...) 19 | _, _ = fmt.Fprint(os.Stderr, colorError("==> "+message+"\n")) 20 | } 21 | 22 | func formatTimestamp(t *time.Time) string { 23 | s := t.Local().Format("2006-01-02T15:04:05.999") 24 | for len(s) < 23 { 25 | s += "0" 26 | } 27 | return s 28 | } 29 | 30 | type kubeLogger struct{} 31 | 32 | func (l *kubeLogger) Init(logr.RuntimeInfo) {} 33 | func (l *kubeLogger) Enabled(int) bool { return true } 34 | func (l *kubeLogger) WithValues(...interface{}) logr.LogSink { return l } 35 | func (l *kubeLogger) WithName(string) logr.LogSink { return l } 36 | 37 | func (l *kubeLogger) Info(_ int, msg string, keysAndValues ...interface{}) { 38 | printInfo(formatKeysAndValues(msg, keysAndValues...)) 39 | } 40 | 41 | func (l *kubeLogger) Error(_ error, msg string, keysAndValues ...interface{}) { 42 | printError(formatKeysAndValues(msg, keysAndValues...)) 43 | } 44 | 45 | func formatKeysAndValues(msg string, kv ...interface{}) string { 46 | var sb strings.Builder 47 | _, _ = sb.WriteString(strings.TrimSpace(msg)) 48 | for i := 0; i < len(kv)-1; i++ { 49 | _, _ = sb.WriteString(" ") 50 | _, _ = sb.WriteString(kv[i].(string)) 51 | i++ 52 | _, _ = sb.WriteString(fmt.Sprintf("=%v", kv[i])) 53 | } 54 | return strings.ReplaceAll(sb.String(), "%", "%%") 55 | } 56 | -------------------------------------------------------------------------------- /tailer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/sha256" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "sync/atomic" 12 | "time" 13 | 14 | "github.com/jpillora/backoff" 15 | "k8s.io/api/core/v1" 16 | "k8s.io/apimachinery/pkg/api/errors" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/client-go/kubernetes" 19 | ) 20 | 21 | type tailState int 22 | 23 | const ( 24 | tailStateNormal tailState = iota 25 | tailStateRecover 26 | ) 27 | 28 | type LogEvent struct { 29 | Pod *v1.Pod 30 | Container *v1.Container 31 | Timestamp *time.Time 32 | Message string 33 | } 34 | 35 | type LogEventFunc func(LogEvent) 36 | 37 | func NewContainerTailer( 38 | client kubernetes.Interface, 39 | pod v1.Pod, 40 | container v1.Container, 41 | eventFunc LogEventFunc, 42 | fromTimestamp *time.Time) *ContainerTailer { 43 | return &ContainerTailer{ 44 | client: client, 45 | pod: pod, 46 | container: container, 47 | eventFunc: eventFunc, 48 | fromTimestamp: fromTimestamp, 49 | errorBackoff: &backoff.Backoff{}, 50 | state: tailStateNormal, 51 | } 52 | } 53 | 54 | type ContainerTailer struct { 55 | client kubernetes.Interface 56 | pod v1.Pod 57 | container v1.Container 58 | stop atomic.Bool 59 | eventFunc LogEventFunc 60 | fromTimestamp *time.Time 61 | errorBackoff *backoff.Backoff 62 | lastLineChecksum []byte 63 | state tailState 64 | } 65 | 66 | func (ct *ContainerTailer) Stop() { 67 | ct.stop.Store(true) 68 | } 69 | 70 | func (ct *ContainerTailer) Run(ctx context.Context, onError func(err error)) { 71 | ct.errorBackoff.Reset() 72 | for !ct.stop.Load() { 73 | stream, err := ct.getStream(ctx) 74 | if err != nil { 75 | time.Sleep(ct.errorBackoff.Duration()) 76 | onError(err) 77 | continue 78 | } 79 | if stream == nil { 80 | break 81 | } 82 | if err := ct.runStream(stream); err != nil { 83 | onError(err) 84 | time.Sleep(ct.errorBackoff.Duration()) 85 | } 86 | ct.state = tailStateRecover 87 | } 88 | } 89 | 90 | func (ct *ContainerTailer) runStream(stream io.ReadCloser) error { 91 | defer func() { 92 | _ = stream.Close() 93 | }() 94 | 95 | r := bufio.NewReader(stream) 96 | for { 97 | line, err := r.ReadString('\n') 98 | if err == io.EOF { 99 | break 100 | } 101 | if err != nil { 102 | return err 103 | } 104 | ct.errorBackoff.Reset() 105 | ct.receiveLine(line) 106 | } 107 | return nil 108 | } 109 | 110 | func (ct *ContainerTailer) receiveLine(s string) { 111 | if len(s) > 0 && s[len(s)-1] == '\n' { 112 | s = s[0 : len(s)-1] 113 | } 114 | for len(s) > 0 && s[len(s)-1] == '\r' { 115 | s = s[0 : len(s)-1] 116 | } 117 | 118 | parts := strings.SplitN(s, " ", 2) 119 | if len(parts) < 2 { 120 | // TODO: Warn 121 | return 122 | } 123 | 124 | timeString, message := parts[0], parts[1] 125 | 126 | var timestamp time.Time 127 | if t, err := time.Parse(time.RFC3339Nano, timeString); err == nil { 128 | timestamp = t 129 | } else { 130 | // TODO: Warn 131 | return 132 | } 133 | 134 | checksum := checksumLine(message) 135 | 136 | if ct.state == tailStateRecover { 137 | if ct.lastLineChecksum != nil && bytes.Equal(ct.lastLineChecksum, checksum) { 138 | // If just restarted, we might be continuing off a timestamp that results in dupes, 139 | // so discard the dupes. 140 | return 141 | } 142 | if ct.fromTimestamp != nil && timestamp.Before(*ct.fromTimestamp) { 143 | // We are receiving an old line, skip it 144 | return 145 | } 146 | } 147 | 148 | ct.lastLineChecksum = checksum 149 | ct.state = tailStateNormal 150 | 151 | // On restart, start from this timestamp. This isn't exact, however. 152 | nextTimestamp := timestamp.Add(time.Millisecond * 1) 153 | ct.fromTimestamp = &nextTimestamp 154 | 155 | ct.eventFunc(LogEvent{ 156 | Pod: &ct.pod, 157 | Container: &ct.container, 158 | Timestamp: ×tamp, 159 | Message: parts[1], 160 | }) 161 | } 162 | 163 | func (ct *ContainerTailer) getStream(ctx context.Context) (io.ReadCloser, error) { 164 | var sinceTime *metav1.Time 165 | if ct.fromTimestamp != nil { 166 | sinceTime = &metav1.Time{ 167 | Time: ct.fromTimestamp.UTC(), 168 | } 169 | } 170 | 171 | boff := &backoff.Backoff{} 172 | for { 173 | stream, err := ct.client.CoreV1().Pods(ct.pod.Namespace).GetLogs(ct.pod.Name, &v1.PodLogOptions{ 174 | Container: ct.container.Name, 175 | Follow: true, 176 | Timestamps: true, 177 | SinceTime: sinceTime, 178 | }).Stream(ctx) 179 | if err == nil { 180 | return stream, nil 181 | } 182 | if status, ok := err.(errors.APIStatus); ok { 183 | // This will happen if the pod isn't ready for log-reading yet 184 | switch status.Status().Code { 185 | case http.StatusBadRequest: 186 | time.Sleep(boff.Duration()) 187 | continue 188 | case http.StatusNotFound: 189 | return nil, nil 190 | } 191 | } 192 | return nil, err 193 | } 194 | } 195 | 196 | func checksumLine(s string) []byte { 197 | digest := sha256.New() 198 | digest.Write([]byte(s)) 199 | return digest.Sum(nil) 200 | } 201 | -------------------------------------------------------------------------------- /terminal.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "golang.org/x/crypto/ssh/terminal" 8 | ) 9 | 10 | func isTerminal(w io.Writer) bool { 11 | if f, ok := w.(*os.File); ok { 12 | return terminal.IsTerminal(int(f.Fd())) 13 | } 14 | return false 15 | } 16 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | // Code generated by gen_version.sh, DO NOT EDIT. 2 | 3 | package main 4 | 5 | //go:generate sh -c ./gen_version.sh 6 | 7 | const version = "1.4.0" 8 | --------------------------------------------------------------------------------