feat(k8s): group containers by owner chain (#4811)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

Signed-off-by: Hiram Chirino <hiram@hiramchirino.com>
This commit is contained in:
Hiram Chirino
2026-07-01 12:07:51 -04:00
committed by GitHub
parent beddc79892
commit 3062410b42
10 changed files with 833 additions and 60 deletions
+7 -1
View File
@@ -66,8 +66,10 @@ declare global {
const getCurrentScope: typeof import('vue').getCurrentScope const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const getDeep: typeof import('./utils/index').getDeep const getDeep: typeof import('./utils/index').getDeep
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
const globalShowPopup: typeof import('./composable/popup').globalShowPopup const globalShowPopup: typeof import('./composable/popup').globalShowPopup
const groupContainers: typeof import('./stores/settings').groupContainers const groupContainers: typeof import('./stores/settings').groupContainers
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
const h: typeof import('vue').h const h: typeof import('vue').h
const hashCode: typeof import('./utils/index').hashCode const hashCode: typeof import('./utils/index').hashCode
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
@@ -117,6 +119,7 @@ declare global {
const onUnmounted: typeof import('vue').onUnmounted const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
const parseMessage: typeof import('./composable/loadBetween').parseMessage const parseMessage: typeof import('./composable/loadBetween').parseMessage
const pausableWatch: typeof import('@vueuse/core').pausableWatch const pausableWatch: typeof import('@vueuse/core').pausableWatch
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
@@ -435,7 +438,7 @@ declare global {
export type { Host } from './stores/hosts' export type { Host } from './stores/hosts'
import('./stores/hosts') import('./stores/hosts')
// @ts-ignore // @ts-ignore
export type { K8sNamespace, K8sOwner } from './stores/k8s' export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
import('./stores/k8s') import('./stores/k8s')
// @ts-ignore // @ts-ignore
export type { Settings } from './stores/settings' export type { Settings } from './stores/settings'
@@ -506,8 +509,10 @@ declare module 'vue' {
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']> readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']> readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']> readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']> readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']> readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']> readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
@@ -556,6 +561,7 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']> readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']> readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']> readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
+4 -4
View File
@@ -78,8 +78,8 @@
</router-link> </router-link>
</summary> </summary>
<ul> <ul>
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`"> <li v-for="owner in owners" :key="owner.key">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active"> <router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
<ph:stack-simple /> <ph:stack-simple />
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div> <div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
</router-link> </router-link>
@@ -95,8 +95,8 @@
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }}) {{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
</summary> </summary>
<ul> <ul>
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`"> <li v-for="owner in ownersWithoutNamespace" :key="owner.key">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active"> <router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
<ph:stack-simple /> <ph:stack-simple />
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div> <div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
</router-link> </router-link>
+3 -3
View File
@@ -57,12 +57,12 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
} }
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource { export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
const labels = computed(() => `namespace:${namespace.value.name}`); const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`)); return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
} }
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource { export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`); const labels = computed(() => `${owner.value.label}:true`);
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`)); return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
} }
+8 -1
View File
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const k8sStore = useK8sStore(); const k8sStore = useK8sStore();
const { owners } = storeToRefs(k8sStore); const { owners } = storeToRefs(k8sStore);
const owner = computed(() => owners.value.find((o) => o.name === route.params.name)); const ownerKey = computed(() => {
try {
return decodeURIComponent(String(route.params.name));
} catch {
return String(route.params.name);
}
});
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
watchEffect(() => { watchEffect(() => {
if (ready.value) { if (ready.value) {
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, test, vi } from "vitest";
import { Container } from "@/models/Container";
import { getK8sOwnerRefs, groupK8sOwners, ownerMembershipLabel } from "./k8s";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
function makeContainer(id: string, labels: Record<string, string>) {
return new Container(
id,
new Date(),
new Date(),
new Date(),
"image",
id,
"command",
"localhost",
labels,
"running",
0,
0,
[],
);
}
describe("getK8sOwnerRefs", () => {
test("parses indexed owner-chain labels", () => {
const container = makeContainer("api", {
namespace: "default",
"@k8s.owner.count": "2",
"@k8s.owner.0.kind": "ReplicaSet",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api-6f88b977f4",
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
"@k8s.owner.1.kind": "Deployment",
"@k8s.owner.1.namespace": "default",
"@k8s.owner.1.name": "api",
"@k8s.owner.1.key": "Deployment~default~api",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "ReplicaSet~default~api-6f88b977f4",
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
kind: "ReplicaSet",
name: "api-6f88b977f4",
namespace: "default",
},
{
key: "Deployment~default~api",
label: ownerMembershipLabel("Deployment~default~api"),
kind: "Deployment",
name: "api",
namespace: "default",
},
]);
});
test("falls back to legacy immediate owner labels", () => {
const container = makeContainer("api", {
namespace: "default",
"owner.kind": "ReplicaSet",
"owner.name": "api-6f88b977f4",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "ReplicaSet~default~api-6f88b977f4",
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
kind: "ReplicaSet",
name: "api-6f88b977f4",
namespace: "default",
},
]);
});
test("uses kind for display and type for CRD identity", () => {
const container = makeContainer("rollout-api", {
namespace: "default",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Rollout",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "argoproj.io~v1alpha1~Rollout~default~api",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "argoproj.io~v1alpha1~Rollout~default~api",
label: ownerMembershipLabel("argoproj.io~v1alpha1~Rollout~default~api"),
kind: "Rollout",
name: "api",
namespace: "default",
},
]);
});
});
describe("groupK8sOwners", () => {
test("groups a container under every owner in its chain", () => {
const container = makeContainer("api", {
namespace: "default",
"@k8s.owner.count": "2",
"@k8s.owner.0.kind": "ReplicaSet",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api-6f88b977f4",
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
"@k8s.owner.1.kind": "Deployment",
"@k8s.owner.1.namespace": "default",
"@k8s.owner.1.name": "api",
"@k8s.owner.1.key": "Deployment~default~api",
});
const owners = groupK8sOwners([container]);
expect(owners.map((owner) => owner.key).sort()).toEqual([
"Deployment~default~api",
"ReplicaSet~default~api-6f88b977f4",
]);
expect(owners.every((owner) => owner.containers.length === 1)).toBe(true);
});
test("keeps same-name owners in different namespaces separate", () => {
const owners = groupK8sOwners([
makeContainer("default-api", {
namespace: "default",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Deployment",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "Deployment~default~api",
}),
makeContainer("prod-api", {
namespace: "prod",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Deployment",
"@k8s.owner.0.namespace": "prod",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "Deployment~prod~api",
}),
]);
expect(owners.map((owner) => owner.key).sort()).toEqual(["Deployment~default~api", "Deployment~prod~api"]);
});
});
+68 -33
View File
@@ -2,6 +2,14 @@ import { acceptHMRUpdate, defineStore } from "pinia";
import { Container, GroupedContainers } from "@/models/Container"; import { Container, GroupedContainers } from "@/models/Container";
export type K8sOwnerRef = {
key: string;
label: string;
kind: string;
name: string;
namespace?: string;
};
export class K8sNamespace { export class K8sNamespace {
constructor( constructor(
public readonly name: string, public readonly name: string,
@@ -22,6 +30,9 @@ export class K8sOwner {
constructor( constructor(
public readonly name: string, public readonly name: string,
public readonly kind: string, public readonly kind: string,
public readonly namespaceName: string | undefined,
public readonly key: string,
public readonly label: string,
public readonly containers: Container[], public readonly containers: Container[],
) {} ) {}
@@ -32,6 +43,57 @@ export class K8sOwner {
} }
} }
export function ownerMembershipLabel(key: string) {
const bytes = new TextEncoder().encode(key);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
}
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
const count = Number(container.labels["@k8s.owner.count"] ?? container.labels["k8s.owner.count"] ?? 0);
if (count > 0) {
const owners: K8sOwnerRef[] = [];
for (let i = 0; i < count; i++) {
const syntheticPrefix = `@k8s.owner.${i}.`;
const legacyPrefix = `k8s.owner.${i}.`;
const labelValue = (key: string) =>
container.labels[`${syntheticPrefix}${key}`] ?? container.labels[`${legacyPrefix}${key}`];
const kind = labelValue("kind");
const name = labelValue("name");
const namespace = labelValue("namespace");
if (!kind || !name) continue;
const key = labelValue("key") ?? `${kind}~${namespace ?? ""}~${name}`;
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
}
return owners;
}
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
const kind = container.labels["owner.kind"];
const name = container.labels["owner.name"];
if (!kind || !name) return [];
const namespace = container.labels["namespace"];
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
}
export function groupK8sOwners(containers: Container[]) {
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
for (const container of containers) {
for (const owner of getK8sOwnerRefs(container)) {
ownerGroups[owner.key] ||= { owner, containers: [] };
ownerGroups[owner.key].containers.push(container);
}
}
return Object.values(ownerGroups).map(({ owner, containers }) => {
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
});
}
export const useK8sStore = defineStore("k8s", () => { export const useK8sStore = defineStore("k8s", () => {
const containerStore = useContainerStore(); const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> }; const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
@@ -50,24 +112,7 @@ export const useK8sStore = defineStore("k8s", () => {
const newNamespaces: K8sNamespace[] = []; const newNamespaces: K8sNamespace[] = [];
for (const [name, containers] of Object.entries(namespacedContainers)) { for (const [name, containers] of Object.entries(namespacedContainers)) {
const ownerGroups: Record<string, Container[]> = {}; const newOwners = groupK8sOwners(containers);
for (const container of containers) {
const ownerKind = container.labels["owner.kind"];
const ownerName = container.labels["owner.name"];
if (ownerKind === undefined || ownerName === undefined) continue;
const key = `${ownerKind}:${ownerName}`;
ownerGroups[key] ||= [];
ownerGroups[key].push(container);
}
const newOwners: K8sOwner[] = [];
for (const [key, containers] of Object.entries(ownerGroups)) {
const [kind, name] = key.split(":");
newOwners.push(new K8sOwner(name, kind, containers));
}
if (newOwners.length === 0) continue; if (newOwners.length === 0) continue;
@@ -75,7 +120,7 @@ export const useK8sStore = defineStore("k8s", () => {
new K8sNamespace( new K8sNamespace(
name, name,
containers, containers,
newOwners.sort((a, b) => a.name.localeCompare(b.name)), newOwners.sort((a, b) => a.key.localeCompare(b.key)),
), ),
); );
} }
@@ -83,33 +128,23 @@ export const useK8sStore = defineStore("k8s", () => {
}); });
const owners = computed(() => { const owners = computed(() => {
const ownerGroups: Record<string, Container[]> = {}; const containersWithoutNamespace: Container[] = [];
for (const container of runningContainers.value) { for (const container of runningContainers.value) {
const ownerKind = container.labels["owner.kind"];
const ownerName = container.labels["owner.name"];
const namespace = container.labels["namespace"]; const namespace = container.labels["namespace"];
if (ownerKind === undefined || ownerName === undefined) continue;
if (namespace) { if (namespace) {
// Skip containers that are already part of a namespace // Skip containers that are already part of a namespace
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace); const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
if (hasNamespace) continue; if (hasNamespace) continue;
} }
containersWithoutNamespace.push(container);
const key = `${ownerKind}:${ownerName}`;
ownerGroups[key] ||= [];
ownerGroups[key].push(container);
} }
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners); const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => { const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
const [kind, name] = key.split(":");
return new K8sOwner(name, kind, containers);
});
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name)); return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
}); });
const customGroups = computed(() => { const customGroups = computed(() => {
+6
View File
@@ -26,6 +26,12 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["pods", "pods/log", "nodes"] resources: ["pods", "pods/log", "nodes"]
verbs: ["get", "list", "watch"] verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
verbs: ["get"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get"]
- apiGroups: ["metrics.k8s.io"] - apiGroups: ["metrics.k8s.io"]
resources: ["pods"] resources: ["pods"]
verbs: ["get", "list"] verbs: ["get", "list"]
+6
View File
@@ -13,6 +13,12 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["pods", "pods/log", "nodes"] resources: ["pods", "pods/log", "nodes"]
verbs: ["get", "list", "watch"] verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
verbs: ["get"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get"]
- apiGroups: ["metrics.k8s.io"] - apiGroups: ["metrics.k8s.io"]
resources: ["pods"] resources: ["pods"]
verbs: ["get", "list"] verbs: ["get", "list"]
+289 -18
View File
@@ -2,9 +2,11 @@ package k8s
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -14,8 +16,14 @@ import (
"github.com/amir20/dozzle/internal/container" "github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/utils" "github.com/amir20/dozzle/internal/utils"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -25,15 +33,20 @@ import (
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/remotecommand" "k8s.io/client-go/tools/remotecommand"
) )
type K8sClient struct { type K8sClient struct {
Clientset *kubernetes.Clientset Clientset kubernetes.Interface
namespace []string DynamicClient dynamic.Interface
config *rest.Config restMapper meta.RESTMapper
host container.Host namespace []string
config *rest.Config
host container.Host
ownerCacheMu sync.Mutex
ownerCache map[string]ownerLookupResult
} }
func NewK8sClient(namespace []string) (*K8sClient, error) { func NewK8sClient(namespace []string) (*K8sClient, error) {
@@ -68,6 +81,14 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, err
}
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{}) nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
if err != nil { if err != nil {
@@ -79,17 +100,35 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
node := nodes.Items[0] node := nodes.Items[0]
return &K8sClient{ return &K8sClient{
Clientset: clientset, Clientset: clientset,
namespace: namespace, DynamicClient: dynamicClient,
config: config, restMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
namespace: namespace,
config: config,
host: container.Host{ host: container.Host{
ID: node.Status.NodeInfo.MachineID, ID: node.Status.NodeInfo.MachineID,
Name: node.Name, Name: node.Name,
}, },
ownerCache: make(map[string]ownerLookupResult),
}, nil }, nil
} }
func podToContainers(pod *corev1.Pod) []container.Container { type k8sOwner struct {
APIVersion string
Kind string
Namespace string
Name string
UID string
TypeKey string
Key string
}
type ownerLookupResult struct {
ownerReferences []metav1.OwnerReference
found bool
}
func (k *K8sClient) podToContainers(ctx context.Context, pod *corev1.Pod) []container.Container {
started := time.Time{} started := time.Time{}
if pod.Status.StartTime != nil { if pod.Status.StartTime != nil {
started = pod.Status.StartTime.Time started = pod.Status.StartTime.Time
@@ -101,12 +140,32 @@ func podToContainers(pod *corev1.Pod) []container.Container {
labels[k] = v labels[k] = v
} }
labels["namespace"] = pod.Namespace labels["namespace"] = pod.Namespace
labels["@k8s.namespace"] = pod.Namespace
// Add owner reference if present owners := k.resolveOwnerChain(ctx, pod.Namespace, pod.OwnerReferences)
if len(pod.OwnerReferences) > 0 { if len(owners) > 0 {
owner := pod.OwnerReferences[0] labels["owner.kind"] = owners[0].Kind
labels["owner.kind"] = owner.Kind labels["owner.name"] = owners[0].Name
labels["owner.name"] = owner.Name labels["owner.key"] = owners[0].Key
labels["k8s.owner.count"] = fmt.Sprintf("%d", len(owners))
labels["@k8s.owner.count"] = fmt.Sprintf("%d", len(owners))
}
for i, owner := range owners {
prefix := fmt.Sprintf("k8s.owner.%d.", i)
syntheticPrefix := fmt.Sprintf("@k8s.owner.%d.", i)
labels[prefix+"apiVersion"] = owner.APIVersion
labels[prefix+"kind"] = owner.Kind
labels[prefix+"namespace"] = owner.Namespace
labels[prefix+"name"] = owner.Name
labels[prefix+"uid"] = owner.UID
labels[prefix+"key"] = owner.Key
labels[syntheticPrefix+"apiVersion"] = owner.APIVersion
labels[syntheticPrefix+"kind"] = owner.Kind
labels[syntheticPrefix+"namespace"] = owner.Namespace
labels[syntheticPrefix+"name"] = owner.Name
labels[syntheticPrefix+"uid"] = owner.UID
labels[syntheticPrefix+"key"] = owner.Key
labels[ownerMembershipLabel(owner.Key)] = "true"
} }
var containers []container.Container var containers []container.Container
@@ -129,10 +188,217 @@ func podToContainers(pod *corev1.Pod) []container.Container {
return containers return containers
} }
func (k *K8sClient) resolveOwnerChain(ctx context.Context, namespace string, refs []metav1.OwnerReference) []k8sOwner {
owners := make([]k8sOwner, 0)
seen := make(map[string]struct{})
for len(refs) > 0 {
ref := ownerReferenceToFollow(refs)
if isNodeOwnerReference(ref) {
break
}
owner := newK8sOwner(namespace, ref)
if _, ok := seen[owner.cacheKey()]; ok {
break
}
seen[owner.cacheKey()] = struct{}{}
owners = append(owners, owner)
next, ok := k.lookupOwnerReferences(ctx, owner)
if !ok {
break
}
refs = next
}
return owners
}
func ownerReferenceToFollow(refs []metav1.OwnerReference) metav1.OwnerReference {
for _, ref := range refs {
if ref.Controller != nil && *ref.Controller {
return ref
}
}
return refs[0]
}
func isNodeOwnerReference(ref metav1.OwnerReference) bool {
return ref.APIVersion == "v1" && ref.Kind == "Node"
}
func newK8sOwner(namespace string, ref metav1.OwnerReference) k8sOwner {
typeKey := ownerTypeKey(ref.APIVersion, ref.Kind)
// "~" is URL-safe and not allowed in Kubernetes resource names/namespaces,
// so owner route keys stay readable without colliding with real names.
key := fmt.Sprintf("%s~%s~%s", typeKey, namespace, ref.Name)
return k8sOwner{
APIVersion: ref.APIVersion,
Kind: ref.Kind,
Namespace: namespace,
Name: ref.Name,
UID: string(ref.UID),
TypeKey: typeKey,
Key: key,
}
}
func (o k8sOwner) cacheKey() string {
return fmt.Sprintf("%s/%s/%s/%s/%s", o.APIVersion, o.Kind, o.Namespace, o.Name, o.UID)
}
func ownerMembershipLabel(key string) string {
return "@k8s.owner.key." + base64.RawURLEncoding.EncodeToString([]byte(key))
}
func ownerTypeKey(apiVersion, kind string) string {
if isKnownK8sOwnerType(apiVersion, kind) {
return kind
}
return strings.ReplaceAll(apiVersion, "/", "~") + "~" + kind
}
func isKnownK8sOwnerType(apiVersion, kind string) bool {
switch apiVersion + "/" + kind {
case "apps/v1/Deployment",
"apps/v1/ReplicaSet",
"apps/v1/DaemonSet",
"apps/v1/StatefulSet",
"batch/v1/Job",
"batch/v1/CronJob",
"v1/Pod",
"v1/Service",
"v1/ConfigMap",
"v1/Secret":
return true
default:
return false
}
}
func (k *K8sClient) lookupOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool) {
cacheKey := owner.cacheKey()
k.ownerCacheMu.Lock()
if k.ownerCache == nil {
k.ownerCache = make(map[string]ownerLookupResult)
}
if result, ok := k.ownerCache[cacheKey]; ok {
k.ownerCacheMu.Unlock()
return result.ownerReferences, result.found
}
k.ownerCacheMu.Unlock()
refs, ok, cacheable := k.fetchOwnerReferences(ctx, owner)
if cacheable {
k.ownerCacheMu.Lock()
k.ownerCache[cacheKey] = ownerLookupResult{ownerReferences: refs, found: ok}
k.ownerCacheMu.Unlock()
}
return refs, ok
}
func (k *K8sClient) fetchOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool, bool) {
if k.DynamicClient == nil || k.restMapper == nil {
return nil, false, false
}
groupVersion, err := schema.ParseGroupVersion(owner.APIVersion)
if err != nil {
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to parse owner apiVersion")
return nil, false, false
}
mapping, err := k.restMapper.RESTMapping(groupVersion.WithKind(owner.Kind).GroupKind(), groupVersion.Version)
if err != nil {
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to map owner resource")
return nil, false, false
}
var resource dynamic.ResourceInterface
if mapping.Scope.Name() != meta.RESTScopeNameRoot {
resource = k.DynamicClient.Resource(mapping.Resource).Namespace(owner.Namespace)
} else {
resource = k.DynamicClient.Resource(mapping.Resource)
}
obj, err := resource.Get(ctx, owner.Name, metav1.GetOptions{})
if err != nil {
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to fetch owner resource")
if ctx.Err() != nil {
return nil, false, false
}
return nil, false, apierrors.IsNotFound(err) || apierrors.IsForbidden(err)
}
return obj.GetOwnerReferences(), true, true
}
func splitK8sFilters(labels container.ContainerLabels) (container.ContainerLabels, container.ContainerLabels) {
podLabels := make(container.ContainerLabels)
metadataLabels := make(container.ContainerLabels)
for key, values := range labels {
if isK8sMetadataLabel(key) || !isValidK8sLabelKey(key) {
metadataLabels[key] = values
} else {
podLabels[key] = values
}
}
return podLabels, metadataLabels
}
func isK8sMetadataLabel(key string) bool {
if strings.HasPrefix(key, "@k8s.") {
return true
}
return key == "namespace" ||
key == "owner.kind" ||
key == "owner.name" ||
key == "owner.key" ||
strings.HasPrefix(key, "k8s.owner.")
}
var (
k8sLabelNamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$`)
k8sLabelPrefixPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
)
func isValidK8sLabelKey(key string) bool {
prefix, name, hasPrefix := strings.Cut(key, "/")
if !hasPrefix {
name = prefix
} else if len(prefix) == 0 || len(prefix) > 253 || !k8sLabelPrefixPattern.MatchString(prefix) {
return false
}
return len(name) <= 63 && k8sLabelNamePattern.MatchString(name)
}
func matchesContainerLabels(labels map[string]string, filters container.ContainerLabels) bool {
for key, values := range filters {
value, ok := labels[key]
if !ok {
return false
}
matched := false
for _, expected := range values {
if value == expected {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) { func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
podLabels, metadataLabels := splitK8sFilters(labels)
selector := "" selector := ""
if labels.Exists() { if podLabels.Exists() {
for key, values := range labels { for key, values := range podLabels {
for _, value := range values { for _, value := range values {
if selector != "" { if selector != "" {
selector += "," selector += ","
@@ -149,7 +415,12 @@ func (k *K8sClient) ListContainers(ctx context.Context, labels container.Contain
} }
var containers []container.Container var containers []container.Container
for _, pod := range pods.Items { for _, pod := range pods.Items {
containers = append(containers, podToContainers(&pod)...) for _, c := range k.podToContainers(ctx, &pod) {
if metadataLabels.Exists() && !matchesContainerLabels(c.Labels, metadataLabels) {
continue
}
containers = append(containers, c)
}
} }
return lo.T2[[]container.Container, error](containers, nil) return lo.T2[[]container.Container, error](containers, nil)
}) })
@@ -201,7 +472,7 @@ func (k *K8sClient) FindContainer(ctx context.Context, id string) (container.Con
return container.Container{}, err return container.Container{}, err
} }
for _, c := range podToContainers(pod) { for _, c := range k.podToContainers(ctx, pod) {
if c.ID == id { if c.ID == id {
return c, nil return c, nil
} }
@@ -274,7 +545,7 @@ func (k *K8sClient) ContainerEvents(ctx context.Context, ch chan<- container.Con
name = "update" name = "update"
} }
for _, c := range podToContainers(pod) { for _, c := range k.podToContainers(ctx, pod) {
ch <- container.ContainerEvent{ ch <- container.ContainerEvent{
Name: name, Name: name,
ActorID: c.ID, ActorID: c.ID,
+293
View File
@@ -0,0 +1,293 @@
package k8s
import (
"context"
"errors"
"testing"
"github.com/amir20/dozzle/internal/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
dynamicfake "k8s.io/client-go/dynamic/fake"
k8sfake "k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
)
func TestPodToContainersAddsOwnerChainLabels(t *testing.T) {
client := newTestK8sClient(t,
&appsv1.ReplicaSet{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "api-6f88b977f4",
UID: types.UID("rs-uid"),
OwnerReferences: []metav1.OwnerReference{
{APIVersion: "apps/v1", Kind: "Deployment", Name: "api", UID: types.UID("deploy-uid")},
},
},
},
&appsv1.Deployment{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "api", UID: types.UID("deploy-uid")},
},
)
containers := client.podToContainers(t.Context(), podWithOwner())
require.Len(t, containers, 1)
labels := containers[0].Labels
assert.Equal(t, "default", labels["namespace"])
assert.Equal(t, "default", labels["@k8s.namespace"])
assert.Equal(t, "ReplicaSet", labels["owner.kind"])
assert.Equal(t, "api-6f88b977f4", labels["owner.name"])
assert.Equal(t, "ReplicaSet~default~api-6f88b977f4", labels["owner.key"])
assert.Equal(t, "2", labels["k8s.owner.count"])
assert.Equal(t, "2", labels["@k8s.owner.count"])
assert.Equal(t, "ReplicaSet", labels["k8s.owner.0.kind"])
assert.Equal(t, "ReplicaSet", labels["@k8s.owner.0.kind"])
assert.Equal(t, "api-6f88b977f4", labels["k8s.owner.0.name"])
assert.Equal(t, "ReplicaSet~default~api-6f88b977f4", labels["k8s.owner.0.key"])
assert.Equal(t, "Deployment", labels["k8s.owner.1.kind"])
assert.Equal(t, "Deployment", labels["@k8s.owner.1.kind"])
assert.Equal(t, "api", labels["k8s.owner.1.name"])
assert.Equal(t, "Deployment~default~api", labels["k8s.owner.1.key"])
assert.Equal(t, "true", labels[ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4")])
assert.Equal(t, "true", labels[ownerMembershipLabel("Deployment~default~api")])
}
func TestPodToContainersStopsOwnerChainWhenOwnerCannotBeFetched(t *testing.T) {
client := newTestK8sClient(t)
containers := client.podToContainers(t.Context(), podWithOwner())
require.Len(t, containers, 1)
labels := containers[0].Labels
assert.Equal(t, "1", labels["k8s.owner.count"])
assert.Equal(t, "ReplicaSet", labels["k8s.owner.0.kind"])
assert.Equal(t, "api-6f88b977f4", labels["k8s.owner.0.name"])
assert.Empty(t, labels["k8s.owner.1.kind"])
}
func TestPodToContainersDoesNotAddNodeOwner(t *testing.T) {
client := newTestK8sClient(t)
pod := podWithOwner()
pod.OwnerReferences = []metav1.OwnerReference{
{APIVersion: "v1", Kind: "Node", Name: "node-1", UID: types.UID("node-uid")},
}
containers := client.podToContainers(t.Context(), pod)
require.Len(t, containers, 1)
labels := containers[0].Labels
assert.Empty(t, labels["owner.kind"])
assert.Empty(t, labels["k8s.owner.count"])
assert.Empty(t, labels["@k8s.owner.count"])
}
func TestPodToContainersStopsBeforeNodeOwnerInChain(t *testing.T) {
client := newTestK8sClient(t,
&appsv1.ReplicaSet{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "api-6f88b977f4",
UID: types.UID("rs-uid"),
OwnerReferences: []metav1.OwnerReference{
{APIVersion: "v1", Kind: "Node", Name: "node-1", UID: types.UID("node-uid")},
},
},
},
)
containers := client.podToContainers(t.Context(), podWithOwner())
require.Len(t, containers, 1)
labels := containers[0].Labels
assert.Equal(t, "1", labels["k8s.owner.count"])
assert.Equal(t, "ReplicaSet", labels["k8s.owner.0.kind"])
assert.Empty(t, labels["k8s.owner.1.kind"])
}
func TestListContainersAppliesSyntheticOwnerFiltersAfterPodList(t *testing.T) {
client := newTestK8sClient(t,
&appsv1.ReplicaSet{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "api-6f88b977f4",
UID: types.UID("rs-uid"),
OwnerReferences: []metav1.OwnerReference{
{APIVersion: "apps/v1", Kind: "Deployment", Name: "api", UID: types.UID("deploy-uid")},
},
},
},
&appsv1.Deployment{
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "api", UID: types.UID("deploy-uid")},
},
)
client.Clientset = k8sfake.NewSimpleClientset(podWithOwner())
containers, err := client.ListContainers(t.Context(), container.ContainerLabels{
"app": {"api"},
ownerMembershipLabel("Deployment~default~api"): {"true"},
})
require.NoError(t, err)
require.Len(t, containers, 1)
assert.Equal(t, "default:api-6f88b977f4-pod:api", containers[0].ID)
}
func TestSplitK8sFiltersKeepsInvalidSyntheticKeysOutOfPodSelector(t *testing.T) {
podLabels, metadataLabels := splitK8sFilters(container.ContainerLabels{
"app": {"api"},
"team.example.com/component": {"backend"},
"@k8s.namespace": {"default"},
"@k8s.owner.key.abc123": {"true"},
"not:a:kubernetes:label:key": {"value"},
"k8s.owner.key.legacyabc123": {"true"},
})
assert.Equal(t, container.ContainerLabels{
"app": {"api"},
"team.example.com/component": {"backend"},
}, podLabels)
assert.Equal(t, container.ContainerLabels{
"@k8s.namespace": {"default"},
"@k8s.owner.key.abc123": {"true"},
"not:a:kubernetes:label:key": {"value"},
"k8s.owner.key.legacyabc123": {"true"},
}, metadataLabels)
}
func TestOwnerTypeKeyUsesFullAPINameForUnknownTypes(t *testing.T) {
assert.Equal(t, "Deployment", ownerTypeKey("apps/v1", "Deployment"))
assert.Equal(t, "argoproj.io~v1alpha1~Rollout", ownerTypeKey("argoproj.io/v1alpha1", "Rollout"))
ref := metav1.OwnerReference{APIVersion: "argoproj.io/v1alpha1", Kind: "Rollout", Name: "api"}
assert.Equal(t, "argoproj.io~v1alpha1~Rollout~default~api", newK8sOwner("default", ref).Key)
}
func TestOwnerReferenceToFollowPrefersController(t *testing.T) {
controller := true
ref := ownerReferenceToFollow([]metav1.OwnerReference{
{Kind: "ConfigMap", Name: "sidecar-config"},
{Kind: "ReplicaSet", Name: "api-6f88b977f4", Controller: &controller},
})
assert.Equal(t, "ReplicaSet", ref.Kind)
assert.Equal(t, "api-6f88b977f4", ref.Name)
}
func TestLookupOwnerReferencesDoesNotCacheTransientFailures(t *testing.T) {
client := newTestK8sClient(t)
dynamicClient := client.DynamicClient.(*dynamicfake.FakeDynamicClient)
calls := 0
dynamicClient.PrependReactor("get", "replicasets", func(action k8stesting.Action) (bool, runtime.Object, error) {
calls++
return true, nil, context.Canceled
})
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, ok := client.lookupOwnerReferences(ctx, replicaSetOwner())
assert.False(t, ok)
_, ok = client.lookupOwnerReferences(ctx, replicaSetOwner())
assert.False(t, ok)
assert.Equal(t, 2, calls)
}
func TestLookupOwnerReferencesCachesRealNegatives(t *testing.T) {
tests := []struct {
name string
err error
}{
{
name: "forbidden",
err: apierrors.NewForbidden(schema.GroupResource{Group: "apps", Resource: "replicasets"}, "api-6f88b977f4", errors.New("denied")),
},
{
name: "not found",
err: apierrors.NewNotFound(schema.GroupResource{Group: "apps", Resource: "replicasets"}, "api-6f88b977f4"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := newTestK8sClient(t)
dynamicClient := client.DynamicClient.(*dynamicfake.FakeDynamicClient)
calls := 0
dynamicClient.PrependReactor("get", "replicasets", func(action k8stesting.Action) (bool, runtime.Object, error) {
calls++
return true, nil, tt.err
})
_, ok := client.lookupOwnerReferences(t.Context(), replicaSetOwner())
assert.False(t, ok)
_, ok = client.lookupOwnerReferences(t.Context(), replicaSetOwner())
assert.False(t, ok)
assert.Equal(t, 1, calls)
})
}
}
func podWithOwner() *corev1.Pod {
return &corev1.Pod{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "api-6f88b977f4-pod",
Labels: map[string]string{"app": "api"},
OwnerReferences: []metav1.OwnerReference{
{APIVersion: "apps/v1", Kind: "ReplicaSet", Name: "api-6f88b977f4", UID: types.UID("rs-uid")},
},
},
Spec: corev1.PodSpec{
NodeName: "node-1",
Containers: []corev1.Container{
{Name: "api", Image: "example/api:latest"},
},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
}
}
func replicaSetOwner() k8sOwner {
return newK8sOwner("default", metav1.OwnerReference{
APIVersion: "apps/v1",
Kind: "ReplicaSet",
Name: "api-6f88b977f4",
UID: types.UID("rs-uid"),
})
}
func newTestK8sClient(t *testing.T, objects ...runtime.Object) *K8sClient {
scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
require.NoError(t, appsv1.AddToScheme(scheme))
mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{appsv1.SchemeGroupVersion, corev1.SchemeGroupVersion})
mapper.Add(appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), meta.RESTScopeNamespace)
mapper.Add(appsv1.SchemeGroupVersion.WithKind("Deployment"), meta.RESTScopeNamespace)
return &K8sClient{
Clientset: k8sfake.NewSimpleClientset(),
DynamicClient: dynamicfake.NewSimpleDynamicClient(scheme, objects...),
restMapper: mapper,
namespace: []string{"default"},
ownerCache: make(map[string]ownerLookupResult),
}
}