diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2c35665 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/go.sum linguist-generated=true diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6348fda..082f1a3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -47,11 +47,12 @@ jobs: - name: Prepare manifests for linting run: | + go build -C deploy . mkdir manifests - go run deploy/main.go my-images v0.0.8 vanilla > manifests/vanilla.yaml - go run deploy/main.go my-images v0.0.8 ocp > manifests/ocp.yaml - go run deploy/main.go my-images v0.0.8 vanilla my-secret > manifests/vanilla-with-secret.yaml - go run deploy/main.go my-images v0.0.8 ocp my-secret > manifests/ocp-with-secret.yaml + ./deploy/deploy --k8s-flavor vanilla my-images > manifests/vanilla.yaml + ./deploy/deploy --k8s-flavor ocp my-images > manifests/ocp.yaml + ./deploy/deploy --k8s-flavor vanilla --secret my-secret my-images > manifests/vanilla-with-secret.yaml + ./deploy/deploy --k8s-flavor ocp --secret my-secret my-images > manifests/ocp-with-secret.yaml - name: kube-linter uses: stackrox/kube-linter-action@v1.0.5 diff --git a/README.md b/README.md index 9930685..f9e7983 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ Talks directly to Container Runtime Interface ([CRI](https://kubernetes.io/docs/ ### `image-prefetcher` - main binary, -- meant to be run in pods of a DaemonSet, - shipped as an OCI image, - provides two subcommands: - - `fetch`: runs the actual image pulls via CRI, meant to run as an init container, + - `fetch`: runs the actual image pulls via CRI, meant to run as an init container + of DaemonSet pods. Requires access to the CRI UNIX domain socket from the host. - - `sleep`: just sleeps forever, meant to run as the main container, + - `sleep`: just sleeps forever, meant to run as the main container of DaemonSet pods. ### `deploy` @@ -29,21 +29,22 @@ Talks directly to Container Runtime Interface ([CRI](https://kubernetes.io/docs/ You can run many instances independently. - It requires a few arguments: - - **name** of the instance. - This also determines the name of a `ConfigMap` supplying names of images to fetch. - - `image-prefetcher` OCI image **version**. See [list of existing tags](https://quay.io/repository/mowsiany/image-prefetcher?tab=tags). - - **cluster flavor**. Currently one of: + It requires a single positional argument for the **name** of the instance. + This also determines the name of a `ConfigMap` supplying names of images to fetch. + + It also accepts a few optional flags: + - `--version`: `image-prefetcher` OCI image tag. See [list of existing tags](https://quay.io/repository/mowsiany/image-prefetcher?tab=tags). + - `--k8s-flavor` depending on the cluster. Currently one of: - `vanilla`: a generic Kubernetes distribution without additional restrictions. - `ocp`: OpenShift, which requires explicitly granting special privileges. - - optional **image pull `Secret` name**. Required if the images are not pullable anonymously. + - `--secret`: image pull `Secret` name. Required if the images are not pullable anonymously. This image pull secret should be usable for all images fetched by the given instance. If provided, it must be of type `kubernetes.io/dockerconfigjson` and exist in the same namespace. Example: ``` - go run github.com/stackrox/image-prefetcher/deploy@main my-images v0.0.8 vanilla > manifest.yaml + go run github.com/stackrox/image-prefetcher/deploy@master my-images v0.0.8 vanilla > manifest.yaml ``` 2. Prepare an image list. This should be a plain text file with one image name per line. diff --git a/cmd/fetch.go b/cmd/fetch.go index 4312451..e7ac905 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -1,12 +1,12 @@ package cmd import ( - "log/slog" "os" "strings" "time" "github.com/stackrox/image-prefetcher/internal" + "github.com/stackrox/image-prefetcher/internal/logging" "github.com/spf13/cobra" ) @@ -19,11 +19,7 @@ var fetchCmd = &cobra.Command{ It talks to Container Runtime Interface API to pull images in parallel, with retries.`, RunE: func(cmd *cobra.Command, args []string) error { - opts := &slog.HandlerOptions{AddSource: true} - if debug { - opts.Level = slog.LevelDebug - } - logger := slog.New(slog.NewTextHandler(os.Stderr, opts)) + logger := logging.GetLogger() timing := internal.TimingConfig{ ImageListTimeout: imageListTimeout, InitialPullAttemptTimeout: initialPullAttemptTimeout, @@ -45,7 +41,6 @@ var ( criSocket string dockerConfigJSONPath string imageListFile string - debug bool imageListTimeout = time.Minute initialPullAttemptTimeout = 30 * time.Second maxPullAttemptTimeout = 5 * time.Minute @@ -56,11 +51,11 @@ var ( func init() { rootCmd.AddCommand(fetchCmd) + logging.AddFlags(fetchCmd.Flags()) fetchCmd.Flags().StringVar(&criSocket, "cri-socket", "/run/containerd/containerd.sock", "Path to CRI UNIX socket.") fetchCmd.Flags().StringVar(&dockerConfigJSONPath, "docker-config", "", "Path to docker config json file.") fetchCmd.Flags().StringVar(&imageListFile, "image-list-file", "", "Path to text file containing images to pull (one per line).") - fetchCmd.Flags().BoolVar(&debug, "debug", false, "Whether to enable debug logging.") fetchCmd.Flags().DurationVar(&imageListTimeout, "image-list-timeout", imageListTimeout, "Timeout for image list calls (for debugging).") fetchCmd.Flags().DurationVar(&initialPullAttemptTimeout, "initial-pull-attempt-timeout", initialPullAttemptTimeout, "Timeout for initial image pull call. Each subsequent attempt doubles it until max.") diff --git a/deploy/deployment.yaml.gotpl b/deploy/deployment.yaml.gotpl index 8edf071..98c6ced 100644 --- a/deploy/deployment.yaml.gotpl +++ b/deploy/deployment.yaml.gotpl @@ -18,8 +18,8 @@ kind: RoleBinding metadata: name: prefetcher-privileged subjects: - - kind: ServiceAccount - name: default +- kind: ServiceAccount + name: default roleRef: apiGroup: rbac.authorization.k8s.io kind: Role @@ -48,74 +48,74 @@ spec: {{ end }} spec: tolerations: - # Broad toleration to match stackrox collector. - - operator: "Exists" + # Broad toleration to match stackrox collector. + - operator: "Exists" initContainers: - - name: prefetch - image: {{ .Image }}:{{ .Version }} - args: - - "fetch" - {{ if .Secret }} - - "--docker-config=/tmp/pull-secret/.dockerconfigjson" - {{ end }} - - "--image-list-file=/tmp/list/images.txt" - {{ if .IsCRIO }} - - "--cri-socket=/tmp/cri/crio.sock" - {{ else }} - - "--cri-socket=/tmp/cri/containerd.sock" - {{ end }} - resources: - requests: - cpu: "20m" - memory: "16Mi" - limits: - cpu: "1" - memory: "256Mi" - volumeMounts: - - name: cri-socket-dir - mountPath: "/tmp/cri" - readOnly: true - - name: image-list - mountPath: "/tmp/list" - readOnly: true - {{ if .Secret }} - - mountPath: /tmp/pull-secret - name: pull-secret - readOnly: true - {{ end }} - securityContext: - readOnlyRootFilesystem: true - {{ if .NeedsPrivileged }} - allowPrivilegeEscalation: true - privileged: true - {{ end }} - containers: - - name: sleep - image: {{ .Image }}:{{ .Version }} - args: - - "sleep" - resources: - requests: - cpu: "5m" - memory: "16Mi" - limits: - cpu: "100m" - memory: "64Mi" - securityContext: - readOnlyRootFilesystem: true - volumes: + - name: prefetch + image: {{ .Image }}:{{ .Version }} + args: + - "fetch" + {{ if .Secret }} + - "--docker-config=/tmp/pull-secret/.dockerconfigjson" + {{ end }} + - "--image-list-file=/tmp/list/images.txt" + {{ if .IsCRIO }} + - "--cri-socket=/tmp/cri/crio.sock" + {{ else }} + - "--cri-socket=/tmp/cri/containerd.sock" + {{ end }} + resources: + requests: + cpu: "20m" + memory: "16Mi" + limits: + cpu: "1" + memory: "256Mi" + volumeMounts: - name: cri-socket-dir - hostPath: - {{ if .IsCRIO }} - path: "/var/run/crio" - {{ else }} - path: "/var/run/containerd" - {{ end }} + mountPath: "/tmp/cri" + readOnly: true - name: image-list - configMap: - name: {{ .Name }} + mountPath: "/tmp/list" + readOnly: true {{ if .Secret }} - - name: pull-secret - secret: - secretName: {{ .Secret }} + - mountPath: /tmp/pull-secret + name: pull-secret + readOnly: true {{ end }} + securityContext: + readOnlyRootFilesystem: true + {{ if .NeedsPrivileged }} + allowPrivilegeEscalation: true + privileged: true + {{ end }} + containers: + - name: sleep + image: {{ .Image }}:{{ .Version }} + args: + - "sleep" + resources: + requests: + cpu: "5m" + memory: "16Mi" + limits: + cpu: "100m" + memory: "64Mi" + securityContext: + readOnlyRootFilesystem: true + volumes: + - name: cri-socket-dir + hostPath: + {{ if .IsCRIO }} + path: "/var/run/crio" + {{ else }} + path: "/var/run/containerd" + {{ end }} + - name: image-list + configMap: + name: {{ .Name }} + {{ if .Secret }} + - name: pull-secret + secret: + secretName: {{ .Secret }} + {{ end }} diff --git a/deploy/flavor.go b/deploy/flavor.go new file mode 100644 index 0000000..dd4c7e1 --- /dev/null +++ b/deploy/flavor.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "log" + "slices" +) + +type k8sFlavorType string + +func (k *k8sFlavorType) UnmarshalText(text []byte) error { + if slices.Contains(allFlavors, string(text)) { + *k = k8sFlavorType(text) + return nil + } + return fmt.Errorf("unknown k8s flavor %q", text) +} + +func (k *k8sFlavorType) MarshalText() (text []byte, err error) { + return []byte(*k), nil +} + +func flavor(flavor string) *k8sFlavorType { + var f k8sFlavorType + if err := f.UnmarshalText([]byte(flavor)); err != nil { + log.Fatal(err) + } + return &f +} diff --git a/deploy/main.go b/deploy/main.go index c4090ce..f5dd5cb 100644 --- a/deploy/main.go +++ b/deploy/main.go @@ -2,8 +2,11 @@ package main import ( _ "embed" + "flag" + "fmt" "log" "os" + "strings" "text/template" ) @@ -16,23 +19,38 @@ type settings struct { NeedsPrivileged bool } +const ( + vanillaFlavor = "vanilla" + ocpFlavor = "ocp" +) + +var allFlavors = []string{vanillaFlavor, ocpFlavor} + const imageRepo = "quay.io/stackrox-io/image-prefetcher" //go:embed deployment.yaml.gotpl -var daemonSetTemplate string +var deploymentTemplate string + +var ( + version string + k8sFlavor k8sFlavorType + secret string +) + +func init() { + flag.StringVar(&version, "version", "v0.1.0", "Version of image prefetcher OCI image.") + flag.TextVar(&k8sFlavor, "k8s-flavor", flavor(vanillaFlavor), fmt.Sprintf("Kubernetes flavor. Accepted values: %s", strings.Join(allFlavors, ","))) + flag.StringVar(&secret, "secret", "", "Kubernetes image pull Secret to use when pulling.") +} func main() { - if len(os.Args) < 4 { - println("Usage:", os.Args[0], " vanilla|ocp [secret]") + flag.Parse() + if len(flag.Args()) < 1 { + println("Usage:", os.Args[0], "[ FLAGS ] ") os.Exit(1) } - name := os.Args[1] - version := os.Args[2] - isOcp := os.Args[3] == "ocp" - secret := "" - if len(os.Args) > 4 { - secret = os.Args[4] - } + name := flag.Arg(0) + isOcp := k8sFlavor == ocpFlavor s := settings{ Name: name, @@ -42,7 +60,7 @@ func main() { IsCRIO: isOcp, NeedsPrivileged: isOcp, } - tmpl := template.Must(template.New("deployment").Parse(daemonSetTemplate)) + tmpl := template.Must(template.New("deployment").Parse(deploymentTemplate)) if err := tmpl.Execute(os.Stdout, s); err != nil { log.Fatal(err) } diff --git a/internal/logging/main.go b/internal/logging/main.go new file mode 100644 index 0000000..9746225 --- /dev/null +++ b/internal/logging/main.go @@ -0,0 +1,22 @@ +package logging + +import ( + "log/slog" + "os" + + "github.com/spf13/pflag" +) + +var debug bool + +func GetLogger() *slog.Logger { + opts := &slog.HandlerOptions{AddSource: true} + if debug { + opts.Level = slog.LevelDebug + } + return slog.New(slog.NewTextHandler(os.Stderr, opts)) +} + +func AddFlags(flags *pflag.FlagSet) { + flags.BoolVar(&debug, "debug", false, "Whether to enable debug logging.") +} diff --git a/internal/main.go b/internal/main.go index 4908a7b..f3ac70d 100644 --- a/internal/main.go +++ b/internal/main.go @@ -29,13 +29,13 @@ func Run(logger *slog.Logger, criSocketPath string, dockerConfigJSONPath string, ctx, cancel := context.WithTimeout(context.Background(), timing.OverallTimeout) defer cancel() - clientConn, err := grpc.DialContext(ctx, "unix://"+criSocketPath, grpc.WithTransportCredentials(insecure.NewCredentials())) + criConn, err := grpc.DialContext(ctx, "unix://"+criSocketPath, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return fmt.Errorf("failed to dial CRI socket %q: %w", criSocketPath, err) } - client := criV1.NewImageServiceClient(clientConn) + criClient := criV1.NewImageServiceClient(criConn) - if err := listImagesForDebugging(ctx, logger, client, timing.ImageListTimeout, "before"); err != nil { + if err := listImagesForDebugging(ctx, logger, criClient, timing.ImageListTimeout, "before"); err != nil { return fmt.Errorf("failed to list images for debugging before pulling: %w", err) } @@ -55,12 +55,12 @@ func Run(logger *slog.Logger, criSocketPath string, dockerConfigJSONPath string, }, Auth: auth, } - go pullImageWithRetries(ctx, logger.With("image", imageName, "authNum", i), &wg, client, request, timing) + go pullImageWithRetries(ctx, logger.With("image", imageName, "authNum", i), &wg, criClient, request, timing) } } wg.Wait() logger.Info("pulling images finished") - if err := listImagesForDebugging(ctx, logger, client, timing.ImageListTimeout, "after"); err != nil { + if err := listImagesForDebugging(ctx, logger, criClient, timing.ImageListTimeout, "after"); err != nil { return fmt.Errorf("failed to list images for debugging after pulling: %w", err) } return nil