diff --git a/README.md b/README.md index 2a292fe..bf55d8d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ helm install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \ --set url= ``` +> **Multi-Namespace support** +> +> By default, coder-logstream-kube will watch all namespaces in the cluster. To limit which namespaces are monitored, you can specify them in the [values.yaml](helm/values.yaml) file: +> +> ```yaml +> # Watch specific namespaces only +> namespaces: ["default", "kube-system"] +> +> # Watch all namespaces (default) +> namespaces: [] +> ``` +> +> When `namespaces` is empty or not specified, the service will monitor all namespaces in the cluster. + > **Note** > For additional customization (such as customizing the image, pull secrets, annotations, etc.), you can use the > [values.yaml](helm/values.yaml) file directly. diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index a414f2a..b108831 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -1,5 +1,5 @@ apiVersion: rbac.authorization.k8s.io/v1 -kind: Role +kind: ClusterRole metadata: name: coder-logstream-kube-role rules: @@ -18,16 +18,17 @@ metadata: labels: {{ toYaml .Values.serviceAccount.labels | nindent 4 }} --- apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding +kind: ClusterRoleBinding metadata: name: coder-logstream-kube-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io - kind: Role + kind: ClusterRole name: coder-logstream-kube-role subjects: - kind: ServiceAccount name: {{ .Values.serviceAccount.name | quote }} + namespace: {{ .Release.Namespace }} --- apiVersion: apps/v1 kind: Deployment @@ -75,8 +76,8 @@ spec: env: - name: CODER_URL value: {{ .Values.url }} - - name: CODER_NAMESPACE - value: {{ .Values.namespace | default .Release.Namespace }} + - name: CODER_NAMESPACES + value: {{ if .Values.namespaces }}{{ join "," .Values.namespaces }}{{ else }}{{ end }} {{- if .Values.image.sslCertFile }} - name: SSL_CERT_FILE value: {{ .Values.image.sslCertFile }} diff --git a/helm/values.yaml b/helm/values.yaml index 7ae1a1c..5a6d1b6 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -1,9 +1,9 @@ # url -- The URL of your Coder deployment. Must prefix with http or https url: "" -# namespace -- The namespace to searching for Pods within. -# If unspecified, this defaults to the Helm namespace. -namespace: "" +# namespace -- List of namespaces to search for Pods within. +# If unspecified or empty it will watch all namespaces. +namespaces: [] # volumes -- A list of extra volumes to add to the coder-logstream pod. volumes: diff --git a/logger.go b/logger.go index 231d01b..25b2076 100644 --- a/logger.go +++ b/logger.go @@ -36,7 +36,7 @@ type podEventLoggerOptions struct { logDebounce time.Duration // The following fields are optional! - namespace string + namespaces []string fieldSelector string labelSelector string } @@ -78,7 +78,18 @@ func newPodEventLogger(ctx context.Context, opts podEventLoggerOptions) (*podEve }, } - return reporter, reporter.init() + // If no namespaces are provided, we listen for events in all namespaces. + if len(opts.namespaces) == 0 { + reporter.initNamespace("") + } else { + for _, namespace := range opts.namespaces { + if err := reporter.initNamespace(namespace); err != nil { + return nil, err + } + } + } + + return reporter, nil } type podEventLogger struct { @@ -96,21 +107,21 @@ type podEventLogger struct { } // init starts the informer factory and registers event handlers. -func (p *podEventLogger) init() error { +func (p *podEventLogger) initNamespace(namespace string) error { // We only track events that happen after the reporter starts. // This is to prevent us from sending duplicate events. startTime := time.Now() go p.lq.work(p.ctx) - podFactory := informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(p.namespace), informers.WithTweakListOptions(func(lo *v1.ListOptions) { + podFactory := informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(namespace), informers.WithTweakListOptions(func(lo *v1.ListOptions) { lo.FieldSelector = p.fieldSelector lo.LabelSelector = p.labelSelector })) eventFactory := podFactory if p.fieldSelector != "" || p.labelSelector != "" { // Events cannot filter on labels and fields! - eventFactory = informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(p.namespace)) + eventFactory = informers.NewSharedInformerFactoryWithOptions(p.client, 0, informers.WithNamespace(namespace)) } // We listen for Pods and Events in the informer factory. @@ -277,7 +288,7 @@ func (p *podEventLogger) init() error { p.logger.Info(p.ctx, "listening for pod events", slog.F("coder_url", p.coderURL.String()), - slog.F("namespace", p.namespace), + slog.F("namespace", namespace), slog.F("field_selector", p.fieldSelector), slog.F("label_selector", p.labelSelector), ) diff --git a/logger_test.go b/logger_test.go index 51d99f5..3ab1c0b 100644 --- a/logger_test.go +++ b/logger_test.go @@ -47,7 +47,7 @@ func TestReplicaSetEvents(t *testing.T) { reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ client: client, coderURL: agentURL, - namespace: namespace, + namespaces: []string{namespace}, logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), logDebounce: 5 * time.Second, clock: cMock, @@ -144,7 +144,7 @@ func TestPodEvents(t *testing.T) { reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ client: client, coderURL: agentURL, - namespace: namespace, + namespaces: []string{namespace}, logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), logDebounce: 5 * time.Second, clock: cMock, @@ -221,6 +221,153 @@ func TestPodEvents(t *testing.T) { require.NoError(t, err) } +func Test_newPodEventLogger_multipleNamespaces(t *testing.T) { + t.Parallel() + + api := newFakeAgentAPI(t) + + ctx := testutil.Context(t, testutil.WaitShort) + agentURL, err := url.Parse(api.server.URL) + require.NoError(t, err) + namespaces := []string{"test-namespace1", "test-namespace2"} + client := fake.NewSimpleClientset() + + cMock := quartz.NewMock(t) + reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{ + client: client, + coderURL: agentURL, + namespaces: namespaces, + logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + logDebounce: 5 * time.Second, + clock: cMock, + }) + require.NoError(t, err) + + // Create a pod in the test-namespace1 namespace + pod1 := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-pod-1", + Namespace: "test-namespace1", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-1", + }, + }, + }, + }, + }, + } + _, err = client.CoreV1().Pods("test-namespace1").Create(ctx, pod1, v1.CreateOptions{}) + require.NoError(t, err) + + // Create a pod in the test-namespace2 namespace + pod2 := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-pod-2", + Namespace: "test-namespace2", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Env: []corev1.EnvVar{ + { + Name: "CODER_AGENT_TOKEN", + Value: "test-token-2", + }, + }, + }, + }, + }, + } + _, err = client.CoreV1().Pods("test-namespace2").Create(ctx, pod2, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for both pods to be registered + source1 := testutil.RequireRecvCtx(ctx, t, api.logSource) + require.Equal(t, sourceUUID, source1.ID) + require.Equal(t, "Kubernetes", source1.DisplayName) + require.Equal(t, "/icon/k8s.png", source1.Icon) + + source2 := testutil.RequireRecvCtx(ctx, t, api.logSource) + require.Equal(t, sourceUUID, source2.ID) + require.Equal(t, "Kubernetes", source2.DisplayName) + require.Equal(t, "/icon/k8s.png", source2.Icon) + + // Wait for both creation logs + logs1 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, logs1, 1) + require.Contains(t, logs1[0].Output, "Created pod") + + logs2 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, logs2, 1) + require.Contains(t, logs2[0].Output, "Created pod") + + // Create an event in the first namespace + event1 := &corev1.Event{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-event-1", + Namespace: "test-namespace1", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "Pod", + Name: "test-pod-1", + Namespace: "test-namespace1", + }, + Reason: "Test", + Message: "Test event for namespace1", + } + _, err = client.CoreV1().Events("test-namespace1").Create(ctx, event1, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for the event log + eventLogs := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, eventLogs, 1) + require.Contains(t, eventLogs[0].Output, "Test event for namespace1") + + // Create an event in the first namespace + event2 := &corev1.Event{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-event-2", + Namespace: "test-namespace2", + CreationTimestamp: v1.Time{ + Time: time.Now().Add(time.Hour), + }, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "Pod", + Name: "test-pod-2", + Namespace: "test-namespace2", + }, + Reason: "Test", + Message: "Test event for namespace2", + } + _, err = client.CoreV1().Events("test-namespace2").Create(ctx, event2, v1.CreateOptions{}) + require.NoError(t, err) + + // Wait for the event log + eventLogs2 := testutil.RequireRecvCtx(ctx, t, api.logs) + require.Len(t, eventLogs2, 1) + require.Contains(t, eventLogs2[0].Output, "Test event for namespace2") + + // Clean up + err = reporter.Close() + require.NoError(t, err) +} + func Test_tokenCache(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 3d48cb9..5e5d2bd 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "strings" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" @@ -27,7 +28,7 @@ func root() *cobra.Command { coderURL string fieldSelector string kubeConfig string - namespace string + namespacesStr string labelSelector string ) cmd := &cobra.Command{ @@ -63,10 +64,18 @@ func root() *cobra.Command { return fmt.Errorf("create kubernetes client: %w", err) } + var namespaces []string + if namespacesStr != "" { + namespaces = strings.Split(namespacesStr, ",") + for i, namespace := range namespaces { + namespaces[i] = strings.TrimSpace(namespace) + } + } + reporter, err := newPodEventLogger(cmd.Context(), podEventLoggerOptions{ coderURL: parsedURL, client: client, - namespace: namespace, + namespaces: namespaces, fieldSelector: fieldSelector, labelSelector: labelSelector, logger: slog.Make(sloghuman.Sink(cmd.ErrOrStderr())).Leveled(slog.LevelDebug), @@ -85,7 +94,7 @@ func root() *cobra.Command { } cmd.Flags().StringVarP(&coderURL, "coder-url", "u", os.Getenv("CODER_URL"), "URL of the Coder instance") cmd.Flags().StringVarP(&kubeConfig, "kubeconfig", "k", "~/.kube/config", "Path to the kubeconfig file") - cmd.Flags().StringVarP(&namespace, "namespace", "n", os.Getenv("CODER_NAMESPACE"), "Namespace to use when listing pods") + cmd.Flags().StringVarP(&namespacesStr, "namespaces", "n", os.Getenv("CODER_NAMESPACES"), "List of namespaces to use when listing pods") cmd.Flags().StringVarP(&fieldSelector, "field-selector", "f", "", "Field selector to use when listing pods") cmd.Flags().StringVarP(&labelSelector, "label-selector", "l", "", "Label selector to use when listing pods")