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 getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
@@ -117,6 +119,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
@@ -435,7 +438,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -506,8 +509,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
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 groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -556,6 +561,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in owners" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
@@ -95,8 +95,8 @@
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
|
||||
@@ -57,12 +57,12 @@ export function useServiceStream(service: Ref<Service>): 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`));
|
||||
}
|
||||
|
||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
||||
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
|
||||
const labels = computed(() => `${owner.value.label}:true`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
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(() => {
|
||||
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";
|
||||
|
||||
export type K8sOwnerRef = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -22,6 +30,9 @@ export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly namespaceName: string | undefined,
|
||||
public readonly key: string,
|
||||
public readonly label: string,
|
||||
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", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
@@ -50,24 +112,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
const newNamespaces: K8sNamespace[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
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));
|
||||
}
|
||||
const newOwners = groupK8sOwners(containers);
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
@@ -75,7 +120,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
new K8sNamespace(
|
||||
name,
|
||||
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 ownerGroups: Record<string, Container[]> = {};
|
||||
const containersWithoutNamespace: Container[] = [];
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
const namespace = container.labels["namespace"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
if (namespace) {
|
||||
// Skip containers that are already part of a namespace
|
||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||
if (hasNamespace) continue;
|
||||
}
|
||||
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
containersWithoutNamespace.push(container);
|
||||
}
|
||||
|
||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||
|
||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
||||
const [kind, name] = key.split(":");
|
||||
return new K8sOwner(name, kind, containers);
|
||||
});
|
||||
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -26,6 +26,12 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||
@@ -13,6 +13,12 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||
+284
-13
@@ -2,9 +2,11 @@ package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -14,8 +16,14 @@ import (
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/utils"
|
||||
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/schema"
|
||||
"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"
|
||||
|
||||
@@ -25,15 +33,20 @@ import (
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
)
|
||||
|
||||
type K8sClient struct {
|
||||
Clientset *kubernetes.Clientset
|
||||
Clientset kubernetes.Interface
|
||||
DynamicClient dynamic.Interface
|
||||
restMapper meta.RESTMapper
|
||||
namespace []string
|
||||
config *rest.Config
|
||||
host container.Host
|
||||
ownerCacheMu sync.Mutex
|
||||
ownerCache map[string]ownerLookupResult
|
||||
}
|
||||
|
||||
func NewK8sClient(namespace []string) (*K8sClient, error) {
|
||||
@@ -68,6 +81,14 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
|
||||
if err != nil {
|
||||
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{})
|
||||
if err != nil {
|
||||
@@ -80,16 +101,34 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
|
||||
|
||||
return &K8sClient{
|
||||
Clientset: clientset,
|
||||
DynamicClient: dynamicClient,
|
||||
restMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
|
||||
namespace: namespace,
|
||||
config: config,
|
||||
host: container.Host{
|
||||
ID: node.Status.NodeInfo.MachineID,
|
||||
Name: node.Name,
|
||||
},
|
||||
ownerCache: make(map[string]ownerLookupResult),
|
||||
}, 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{}
|
||||
if pod.Status.StartTime != nil {
|
||||
started = pod.Status.StartTime.Time
|
||||
@@ -101,12 +140,32 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
||||
labels[k] = v
|
||||
}
|
||||
labels["namespace"] = pod.Namespace
|
||||
labels["@k8s.namespace"] = pod.Namespace
|
||||
|
||||
// Add owner reference if present
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
owner := pod.OwnerReferences[0]
|
||||
labels["owner.kind"] = owner.Kind
|
||||
labels["owner.name"] = owner.Name
|
||||
owners := k.resolveOwnerChain(ctx, pod.Namespace, pod.OwnerReferences)
|
||||
if len(owners) > 0 {
|
||||
labels["owner.kind"] = owners[0].Kind
|
||||
labels["owner.name"] = owners[0].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
|
||||
@@ -129,10 +188,217 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
||||
return containers
|
||||
}
|
||||
|
||||
func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
|
||||
selector := ""
|
||||
if labels.Exists() {
|
||||
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) {
|
||||
podLabels, metadataLabels := splitK8sFilters(labels)
|
||||
selector := ""
|
||||
if podLabels.Exists() {
|
||||
for key, values := range podLabels {
|
||||
for _, value := range values {
|
||||
if selector != "" {
|
||||
selector += ","
|
||||
@@ -149,7 +415,12 @@ func (k *K8sClient) ListContainers(ctx context.Context, labels container.Contain
|
||||
}
|
||||
var containers []container.Container
|
||||
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)
|
||||
})
|
||||
@@ -201,7 +472,7 @@ func (k *K8sClient) FindContainer(ctx context.Context, id string) (container.Con
|
||||
return container.Container{}, err
|
||||
}
|
||||
|
||||
for _, c := range podToContainers(pod) {
|
||||
for _, c := range k.podToContainers(ctx, pod) {
|
||||
if c.ID == id {
|
||||
return c, nil
|
||||
}
|
||||
@@ -274,7 +545,7 @@ func (k *K8sClient) ContainerEvents(ctx context.Context, ch chan<- container.Con
|
||||
name = "update"
|
||||
}
|
||||
|
||||
for _, c := range podToContainers(pod) {
|
||||
for _, c := range k.podToContainers(ctx, pod) {
|
||||
ch <- container.ContainerEvent{
|
||||
Name: name,
|
||||
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