mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 09:04:44 +00:00
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
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:
Vendored
+7
-1
@@ -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']>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user