diff --git a/.examples/docker/docker-compose.yml b/.examples/docker/docker-compose.yml index 59a33dc1..b7981427 100644 --- a/.examples/docker/docker-compose.yml +++ b/.examples/docker/docker-compose.yml @@ -11,9 +11,26 @@ services: - "TZ=Europe/Paris" - "LOG_LEVEL=info" - "LOG_JSON=false" + - "DIUN_WATCH_WORKERS=20" + - "DIUN_WATCH_SCHEDULE=*/30 * * * *" - "DIUN_PROVIDERS_DOCKER=true" - - "DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=false" - - "DIUN_PROVIDERS_DOCKER_WATCHSTOPPED=false" + labels: + - "diun.enable=true" + - "diun.watch_repo=true" + restart: always + + cloudflared: + image: crazymax/cloudflared:latest + ports: + - target: 5053 + published: 5053 + protocol: udp + - target: 49312 + published: 49312 + protocol: tcp + environment: + - "TZ=Europe/Paris" + - "TUNNEL_DNS_UPSTREAM=https://1.1.1.1/dns-query,https://1.0.0.1/dns-query" labels: - "diun.enable=true" - "diun.watch_repo=true" diff --git a/.examples/k8s/diun.yml b/.examples/k8s/diun.yml new file mode 100644 index 00000000..9bdf2ad1 --- /dev/null +++ b/.examples/k8s/diun.yml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: diun +spec: + replicas: 1 + selector: + matchLabels: + app: diun + template: + metadata: + labels: + app: diun +# annotations: +# diun.enable: "true" +# diun.watch_repo: "true" + spec: + containers: + - name: diun + image: crazymax/diun:latest + imagePullPolicy: Always + env: + - name: TZ + value: "Europe/Paris" + - name: LOG_LEVEL + value: "info" + - name: LOG_JSON + value: "false" + - name: DIUN_WATCH_WORKERS + value: "20" + - name: DIUN_WATCH_SCHEDULE + value: "*/30 * * * *" + - name: DIUN_PROVIDERS_KUBERNETES + value: "true" + volumeMounts: + - mountPath: "/data" + name: "data" + restartPolicy: Always + volumes: + # Set up a data directory for gitea + # For production usage, you should consider using PV/PVC instead(or simply using storage like NAS) + # For more details, please see https://kubernetes.io/docs/concepts/storage/volumes/ + - name: "data" + hostPath: + path: "/data" + type: Directory diff --git a/.examples/k8s/nginx.yml b/.examples/k8s/nginx.yml new file mode 100644 index 00000000..edc9b33c --- /dev/null +++ b/.examples/k8s/nginx.yml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + selector: + matchLabels: + run: nginx + replicas: 2 + template: + metadata: + labels: + run: nginx + annotations: + diun.enable: "true" + diun.watch_repo: "true" + spec: + containers: + - name: nginx + image: nginx + ports: + - containerPort: 80 diff --git a/doc/configuration.md b/doc/configuration.md index 5e0745f4..abe4a4a1 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -97,6 +97,10 @@ providers: watchStopped: true swarm: watchByDefault: true + kubernetes: + namespaces: + - default + - production file: directory: ./imagesdir ``` @@ -157,4 +161,5 @@ You can also use the following environment variables: * [docker](providers/docker.md) * [swarm](providers/swarm.md) +* [kubernetes](providers/kubernetes.md) * [file](providers/file.md) diff --git a/doc/faq.md b/doc/faq.md index 8a84113c..5695b6aa 100644 --- a/doc/faq.md +++ b/doc/faq.md @@ -20,7 +20,7 @@ docker-compose exec diun --test-notif ## field docker|swarm uses unsupported type: invalid -If you have the error `failed to decode configuration from file: field docker uses unsupported type: invalid` that's because your `docker` or `swarm` provider is not initialized in your configuration: +If you have the error `failed to decode configuration from file: field docker uses unsupported type: invalid` that's because your `docker`, `swarm` or `kubernetes` provider is not initialized in your configuration: ```yaml providers: diff --git a/doc/providers/kubernetes.md b/doc/providers/kubernetes.md new file mode 100644 index 00000000..6805aa47 --- /dev/null +++ b/doc/providers/kubernetes.md @@ -0,0 +1,214 @@ +# Kubernetes provider + +* [About](#about) +* [Quick start](#quick-start) +* [Provider configuration](#provider-configuration) + * [Configuration file](#configuration-file) + * [Environment variables](#environment-variables) +* [Kubernetes annotations](#kubernetes-annotations) + +## About + +The Kubernetes provider allows you to analyze the pods of your Kubernetes cluster to extract images found and check for updates on the registry. + +## Quick start + +In this section we quickly go over a basic deployment using your local Kubernetes cluster. + +Here we use our local Kubernetes provider with a minimum configuration to analyze annotated pods (watch by default disabled). + +Now let's create a simple pod for Diun: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: diun +spec: + replicas: 1 + selector: + matchLabels: + app: diun + template: + metadata: + labels: + app: diun + spec: + containers: + - name: diun + image: crazymax/diun:latest + imagePullPolicy: Always + env: + - name: TZ + value: "Europe/Paris" + - name: LOG_LEVEL + value: "info" + - name: LOG_JSON + value: "false" + - name: DIUN_WATCH_WORKERS + value: "20" + - name: DIUN_WATCH_SCHEDULE + value: "*/30 * * * *" + - name: DIUN_PROVIDERS_KUBERNETES + value: "true" + volumeMounts: + - mountPath: "/data" + name: "data" + restartPolicy: Always + volumes: + # Set up a data directory for gitea + # For production usage, you should consider using PV/PVC instead(or simply using storage like NAS) + # For more details, please see https://kubernetes.io/docs/concepts/storage/volumes/ + - name: "data" + hostPath: + path: "/data" + type: Directory +``` + +And another one with a simple Nginx pod: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + selector: + matchLabels: + run: nginx + replicas: 2 + template: + metadata: + labels: + run: nginx + annotations: + diun.enable: "true" + diun.watch_repo: "true" + spec: + containers: + - name: nginx + image: nginx + ports: + - containerPort: 80 +``` + +As an example we use [nginx](https://hub.docker.com/_/nginx/) Docker image. A few [annotations](#kubernetes-annotations) are added to configure the image analysis of this pod for Diun. We can now start these 2 pods: + +``` +kubectl apply -f diun.yml +kubectl apply -f nginx.yml +``` + +Now take a look at the logs: + +``` +$ kubectl logs -f -l app=diun --all-containers +# TODO: add logs example +``` + +## Provider configuration + +### Configuration file + +#### `endpoint` + +The Kubernetes server endpoint as URL. + +```yaml +providers: + kubernetes: + endpoint: "http://localhost:8080" +``` + +Kubernetes server endpoint as URL, which is only used when the behavior based on environment variables described below does not apply. + +When deployed into Kubernetes, Diun reads the environment variables `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` or `KUBECONFIG` to create the endpoint. + +The access token is looked up in `/var/run/secrets/kubernetes.io/serviceaccount/token` and the SSL CA certificate in `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`. They are both provided automatically as mounts in the pod where Diun is deployed. + +When the environment variables are not found, Diun tries to connect to the Kubernetes API server with an external-cluster client. In which case, the endpoint is required. Specifically, it may be set to the URL used by `kubectl proxy` to connect to a Kubernetes cluster using the granted authentication and authorization of the associated kubeconfig. + +#### `token` + +```yaml +providers: + kubernetes: + token: "atoken" +``` + +Bearer token used for the Kubernetes client configuration. + +#### `tokenFile` + +Use content of secret file as bearer token if `token` not defined. + +```yaml +providers: + kubernetes: + tokenFile: "/run/secrets/token" +``` + +#### `certAuthFilePath` + +Path to the certificate authority file. Used for the Kubernetes client configuration. + +```yaml +providers: + kubernetes: + certAuthFilePath: "/a/ca.crt" +``` + +#### `tlsInsecure` + +Controls whether client does not verify the server's certificate chain and hostname (default `false`). + +```yaml +providers: + kubernetes: + tlsInsecure: false +``` + +#### `namespaces` + +Array of namespaces to watch (default all namespaces). + +```yaml +providers: + kubernetes: + namespaces: + - default + - production +``` + +#### `watchByDefault` + +Enable watch by default. If false, pods that don't have `diun.enable: "true"` annotation will be ignored (default `false`). + +```yaml +providers: + kubernetes: + watchByDefault: false +``` + +### Environment variables + +* `DIUN_PROVIDERS_KUBERNETES` +* `DIUN_PROVIDERS_KUBERNETES_ENDPOINT` +* `DIUN_PROVIDERS_KUBERNETES_TOKEN` +* `DIUN_PROVIDERS_KUBERNETES_TOKENFILE` +* `DIUN_PROVIDERS_KUBERNETES_CERTAUTHFILEPATH` +* `DIUN_PROVIDERS_KUBERNETES_TLSINSECURE` +* `DIUN_PROVIDERS_KUBERNETES_NAMESPACES` (comma separated) +* `DIUN_PROVIDERS_KUBERNETES_WATCHBYDEFAULT` + +## Kubernetes annotations + +You can configure more finely the way to analyze the image of your pods through Kubernetes annotations: + +* `diun.enable`: Set to true to enable image analysis of this pod. +* `diun.regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use. +* `diun.watch_repo`: Watch all tags of this pod image (default `false`). +* `diun.max_tags`: Maximum number of tags to watch if `diun.watch_repo` enabled. 0 means all of them (default `0`). +* `diun.include_tags`: Semi-colon separated list of regular expressions to include tags. Can be useful if you enable `diun.watch_repo`. +* `diun.exclude_tags`: Semi-colon separated list of regular expressions to exclude tags. Can be useful if you enable `diun.watch_repo`. diff --git a/internal/model/provider_kubernetes.go b/internal/model/provider_kubernetes.go index ec4ae06e..8dd1714e 100644 --- a/internal/model/provider_kubernetes.go +++ b/internal/model/provider_kubernetes.go @@ -6,13 +6,13 @@ import ( // PrdKubernetes holds kubernetes provider configuration type PrdKubernetes struct { - Endpoint string `yaml:"endpoint" json:"endpoint,omitempty" validate:"omitempty"` - Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"` - TokenFile string `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"` - ConfigFile string `yaml:"configFile" json:"configFile,omitempty" validate:"omitempty,file"` - TLSCAFile string `yaml:"tlsCaFile" json:"tlsCaFile,omitempty" validate:"omitempty"` - TLSInsecure *bool `yaml:"tlsInsecure" json:"tlsInsecure,omitempty" validate:"required"` - WatchByDefault *bool `yaml:"watchByDefault" json:"watchByDefault,omitempty" validate:"required"` + Endpoint string `yaml:"endpoint" json:"endpoint,omitempty" validate:"omitempty"` + Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"` + TokenFile string `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"` + CertAuthFilePath string `yaml:"certAuthFilePath" json:"certAuthFilePath,omitempty" validate:"omitempty"` + TLSInsecure *bool `yaml:"tlsInsecure" json:"tlsInsecure,omitempty" validate:"required"` + Namespaces []string `yaml:"namespaces" json:"namespaces,omitempty" validate:"omitempty"` + WatchByDefault *bool `yaml:"watchByDefault" json:"watchByDefault,omitempty" validate:"required"` } // GetDefaults gets the default values diff --git a/internal/provider/kubernetes/kubernetes.go b/internal/provider/kubernetes/kubernetes.go index c5f83bc7..be2788e7 100644 --- a/internal/provider/kubernetes/kubernetes.go +++ b/internal/provider/kubernetes/kubernetes.go @@ -19,7 +19,7 @@ func New(config *model.PrdKubernetes) *provider.Client { return &provider.Client{ Handler: &Client{ config: config, - logger: log.With().Str("provider", "k8s").Logger(), + logger: log.With().Str("provider", "kubernetes").Logger(), }, } } @@ -40,7 +40,7 @@ func (c *Client) ListJob() []model.Job { var list []model.Job for _, image := range images { list = append(list, model.Job{ - Provider: "k8s", + Provider: "kubernetes", Image: image, }) } diff --git a/internal/provider/kubernetes/pod.go b/internal/provider/kubernetes/pod.go index 57cdad92..93a47dca 100644 --- a/internal/provider/kubernetes/pod.go +++ b/internal/provider/kubernetes/pod.go @@ -11,11 +11,12 @@ import ( func (c *Client) listPodImage() []model.Image { cli, err := k8s.New(k8s.Options{ - Endpoint: c.config.Endpoint, - Token: c.config.Token, - TokenFile: c.config.TokenFile, - TLSCAFile: c.config.TLSCAFile, - TLSInsecure: c.config.TLSInsecure, + Endpoint: c.config.Endpoint, + Token: c.config.Token, + TokenFile: c.config.TokenFile, + CertAuthFilePath: c.config.CertAuthFilePath, + TLSInsecure: c.config.TLSInsecure, + Namespaces: c.config.Namespaces, }) if err != nil { c.logger.Error().Err(err).Msg("Cannot create Kubernetes client") @@ -31,7 +32,7 @@ func (c *Client) listPodImage() []model.Image { var list []model.Image for _, pod := range pods { for _, ctn := range pod.Spec.Containers { - image, err := provider.ValidateContainerImage(ctn.Image, pod.Labels, *c.config.WatchByDefault) + image, err := provider.ValidateContainerImage(ctn.Image, pod.Annotations, *c.config.WatchByDefault) if err != nil { c.logger.Error().Err(err).Msgf("Cannot get image from container %s (pod %s)", ctn.Name, pod.Name) continue diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index e07aa272..d70c9062 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -8,6 +8,7 @@ import ( "github.com/crazy-max/diun/v4/pkg/utl" "github.com/pkg/errors" "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -15,39 +16,46 @@ import ( // Client represents an active kubernetes object type Client struct { - ctx context.Context - API *kubernetes.Clientset + ctx context.Context + namespaces []string + API *kubernetes.Clientset } // Options holds kubernetes client object options type Options struct { - Endpoint string - Token string - TokenFile string - TLSCAFile string - TLSInsecure *bool + Endpoint string + Token string + TokenFile string + CertAuthFilePath string + TLSInsecure *bool + Namespaces []string } // New initializes a new Kubernetes client func New(opts Options) (*Client, error) { var err error - var cl *kubernetes.Clientset + var api *kubernetes.Clientset switch { case os.Getenv("KUBERNETES_SERVICE_HOST") != "" && os.Getenv("KUBERNETES_SERVICE_PORT") != "": log.Debug().Msgf("Creating in-cluster Kubernetes provider client %s", opts.Endpoint) - cl, err = newInClusterClient(opts) + api, err = newInClusterClient(opts) case os.Getenv("KUBECONFIG") != "": log.Debug().Msgf("Creating cluster-external Kubernetes provider client from KUBECONFIG %s", os.Getenv("KUBECONFIG")) - cl, err = newExternalClusterClientFromFile(opts, os.Getenv("KUBECONFIG")) + api, err = newExternalClusterClientFromFile(opts, os.Getenv("KUBECONFIG")) default: log.Debug().Msgf("Creating cluster-external Kubernetes provider client %s", opts.Endpoint) - cl, err = newExternalClusterClient(opts) + api, err = newExternalClusterClient(opts) + } + + if len(opts.Namespaces) == 0 { + opts.Namespaces = []string{metav1.NamespaceAll} } return &Client{ - ctx: context.Background(), - API: cl, + ctx: context.Background(), + namespaces: opts.Namespaces, + API: api, }, err } @@ -76,7 +84,6 @@ func newExternalClusterClientFromFile(opts Options, file string) (*kubernetes.Cl configFromFlags.TLSClientConfig.Insecure = *opts.TLSInsecure } - configFromFlags.TLSClientConfig.Insecure = true return kubernetes.NewForConfig(configFromFlags) } @@ -97,8 +104,8 @@ func newExternalClusterClient(opts Options) (*kubernetes.Clientset, error) { BearerToken: opts.Token, } - if opts.TLSCAFile != "" { - caData, err := ioutil.ReadFile(opts.TLSCAFile) + if opts.CertAuthFilePath != "" { + caData, err := ioutil.ReadFile(opts.CertAuthFilePath) if err != nil { return nil, errors.Wrap(err, "Failed to read CA file") } diff --git a/pkg/k8s/pod.go b/pkg/k8s/pod.go index 2e52ad92..15b08a7e 100644 --- a/pkg/k8s/pod.go +++ b/pkg/k8s/pod.go @@ -9,14 +9,19 @@ import ( // PodList returns Kubernetes pods func (c *Client) PodList(opts metav1.ListOptions) ([]v1.Pod, error) { - pods, err := c.API.CoreV1().Pods("").List(c.ctx, opts) - if err != nil { - return nil, err + var podList []v1.Pod + + for _, ns := range c.namespaces { + pods, err := c.API.CoreV1().Pods(ns).List(c.ctx, opts) + if err != nil { + return nil, err + } + podList = append(podList, pods.Items...) } - sort.Slice(pods.Items, func(i, j int) bool { - return pods.Items[i].Name < pods.Items[j].Name + sort.Slice(podList, func(i, j int) bool { + return podList[i].Name < podList[j].Name }) - return pods.Items, nil + return podList, nil } diff --git a/pkg/k8s/pod_test.go b/pkg/k8s/pod_test.go new file mode 100644 index 00000000..61769c50 --- /dev/null +++ b/pkg/k8s/pod_test.go @@ -0,0 +1,37 @@ +package k8s_test + +import ( + "fmt" + "os" + "testing" + + "github.com/crazy-max/diun/v4/pkg/k8s" + "github.com/crazy-max/diun/v4/pkg/utl" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPodList(t *testing.T) { + if os.Getenv("CI") != "" { + t.Skip("Skipping testing in CI environment") + } + + os.Setenv("KUBECONFIG", "./.dev/minikube.config") + + kc, err := k8s.New(k8s.Options{ + TLSInsecure: utl.NewTrue(), + }) + assert.NoError(t, err) + assert.NotNil(t, kc) + + pods, err := kc.PodList(metav1.ListOptions{}) + assert.NoError(t, err) + assert.NotNil(t, pods) + assert.True(t, len(pods) > 0) + + for _, pod := range pods { + for _, ctn := range pod.Spec.Containers { + fmt.Println(pod.Name, ctn.Image) + } + } +}