mirror of
https://github.com/crazy-max/diun.git
synced 2025-04-11 14:11:21 +00:00
Allow to define namespaces for Kubernetes
Use annotations instead of labels for Kubernetes Add Kubernetes provider doc
This commit is contained in:
parent
2a936199ef
commit
dc5949819b
12 changed files with 395 additions and 40 deletions
.examples
doc
internal
pkg/k8s
|
@ -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"
|
||||
|
|
47
.examples/k8s/diun.yml
Normal file
47
.examples/k8s/diun.yml
Normal file
|
@ -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
|
22
.examples/k8s/nginx.yml
Normal file
22
.examples/k8s/nginx.yml
Normal file
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
214
doc/providers/kubernetes.md
Normal file
214
doc/providers/kubernetes.md
Normal file
|
@ -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`.
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
37
pkg/k8s/pod_test.go
Normal file
37
pkg/k8s/pod_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue