Repository: crazy-max/diun
Pull request #380 (2021-05-19 07:21:57)

Bump github.com/panjf2000/ants/v2 from 2.4.4 to 2.4.5
Bumps [github.com/panjf2000/ants/v2](https://github.com/panjf2000/ants) from 2.4.4 to 2.4.5.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/panjf2000/ants/commit/bb601305c61b44f71d14abd83cafb81430441f65"><code>bb60130</code></a> go mod tidy</li>
<li><a href="https://github.com/panjf2000/ants/commit/1a31e9a96be41b39fc2dda5f274a692fcc69a2c6"><code>1a31e9a</code></a> Ignore staticcheck lint in test</li>
<li><a href="https://github.com/panjf2000/ants/commit/a71395c7c8fdc6cd223250cd62dd995d830cb063"><code>a71395c</code></a> Improve the internal spin-lock</li>
<li><a href="https://github.com/panjf2000/ants/commit/a2ad870d2dd95931970ebf33c3442915645c0997"><code>a2ad870</code></a> Returns -1 from Free() by unlimited pool</li>
<li>See full diff in <a href="https://github.com/panjf2000/ants/compare/v2.4.4...v2.4.5">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/panjf2000/ants/v2&package-manager=go_modules&previous-version=2.4.4&new-version=2.4.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

codecov[bot]: # [Codecov](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax) Report
> Merging [#380](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax) (e24379c) into [master](https://codecov.io/gh/crazy-max/diun/commit/8c42cb59b00dd797e3fe955f80e7957972415db7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax) (8c42cb5) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/crazy-max/diun/pull/380/graphs/tree.svg?width=650&height=150&src=pr&token=phc5KYIjsV&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax)](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax)

```diff
@@           Coverage Diff           @@
##           master     #380   +/-   ##
=======================================
  Coverage   70.95%   70.95%           
=======================================
  Files           8        8           
  Lines         241      241           
=======================================
  Hits          171      171           
  Misses         44       44           
  Partials       26       26           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax). Last update [8c42cb5...e24379c](https://codecov.io/gh/crazy-max/diun/pull/380?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CrazyMax).


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b4a83952..4acf74e6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -25,6 +25,21 @@ env:
   GHCR_SLUG: ghcr.io/crazy-max/diun
 
 jobs:
+  validate:
+    runs-on: ubuntu-latest
+    steps:
+      -
+        name: Checkout
+        uses: actions/checkout@v2
+      -
+        name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v1
+      -
+        name: Validate
+        uses: docker/bake-action@v1
+        with:
+          targets: validate
+
   test:
     runs-on: ubuntu-latest
     steps:
@@ -49,7 +64,9 @@ jobs:
 
   build:
     runs-on: ubuntu-latest
-    needs: [ test ]
+    needs:
+      - validate
+      - test
     steps:
       -
         name: Checkout
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
deleted file mode 100644
index 0ffa1500..00000000
--- a/.github/workflows/validate.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: validate
-
-on:
-  push:
-    branches:
-      - 'master'
-    tags:
-      - 'v*'
-      - 'dockerfile/*'
-  pull_request:
-    branches:
-      - 'master'
-
-jobs:
-  validate:
-    runs-on: ubuntu-latest
-    steps:
-      -
-        name: Checkout
-        uses: actions/checkout@v2
-      -
-        name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v1
-      -
-        name: Validate
-        uses: docker/bake-action@v1
-        with:
-          targets: validate
diff --git a/Dockerfile b/Dockerfile
index e3c0a036..305893b3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,7 @@ RUN --mount=type=bind,target=/src,rw \
     --dist "/out" \
     --hooks="go mod tidy" \
     --hooks="go mod download" \
-    --main="./cmd/main.go" \
+    --main="./cmd" \
     --ldflags="-s -w -X 'main.version={{.Version}}'" \
     --files="CHANGELOG.md" \
     --files="LICENSE" \
@@ -44,3 +44,4 @@ ENV PROFILER_PATH="/profiler" \
 
 VOLUME [ "/data" ]
 ENTRYPOINT [ "diun" ]
+CMD [ "serve" ]
diff --git a/cmd/cli.go b/cmd/cli.go
new file mode 100644
index 00000000..441abd80
--- /dev/null
+++ b/cmd/cli.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+	"github.com/crazy-max/diun/v4/pb"
+	"google.golang.org/grpc"
+)
+
+// CliHandler is a cli interface
+type CliHandler interface {
+	BeforeApply() error
+}
+
+// CliGlobals holds globals cli attributes
+type CliGlobals struct {
+	CliHandler `kong:"-"`
+
+	conn     *grpc.ClientConn      `kong:"-"`
+	imageSvc pb.ImageServiceClient `kong:"-"`
+	notifSvc pb.NotifServiceClient `kong:"-"`
+
+	GRPCAuthority string `kong:"name='grpc-authority',default='127.0.0.1:42286',help='Link to Diun gRPC API.'"`
+}
+
+// BeforeApply is a hook that run cli cmd are executed.
+func (s *CliGlobals) BeforeApply() (err error) {
+	s.conn, err = grpc.Dial(s.GRPCAuthority, grpc.WithInsecure())
+	if err != nil {
+		return err
+	}
+
+	s.imageSvc = pb.NewImageServiceClient(s.conn)
+	s.notifSvc = pb.NewNotifServiceClient(s.conn)
+	return
+}
diff --git a/cmd/image.go b/cmd/image.go
new file mode 100644
index 00000000..69caaded
--- /dev/null
+++ b/cmd/image.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/crazy-max/diun/v4/pb"
+	"github.com/docker/go-units"
+	"github.com/jedib0t/go-pretty/v6/table"
+	"github.com/tidwall/pretty"
+	"google.golang.org/protobuf/encoding/protojson"
+)
+
+// ImageCmd holds image command
+type ImageCmd struct {
+	List    ImageListCmd    `kong:"cmd,default='1',help='List images in database.'"`
+	Inspect ImageInspectCmd `kong:"cmd,help='Display information of an image in database.'"`
+	Remove  ImageRemoveCmd  `kong:"cmd,help='Remove an image manifest from database.'"`
+	//Prune   ImagePruneCmd   `kong:"cmd,help='Remove unused manifests from the database.'"`
+}
+
+// ImageListCmd holds image list command
+type ImageListCmd struct {
+	CliGlobals
+	Raw bool `kong:"name='raw',default='false',help='JSON output.'"`
+}
+
+func (s *ImageListCmd) Run(ctx *Context) error {
+	defer s.conn.Close()
+
+	il, err := s.imageSvc.ImageList(context.Background(), &pb.ImageListRequest{})
+	if err != nil {
+		return err
+	}
+
+	sort.Slice(il.Images, func(i, j int) bool {
+		return strings.Map(unicode.ToUpper, il.Images[i].Name) < strings.Map(unicode.ToUpper, il.Images[j].Name)
+	})
+
+	if s.Raw {
+		b, _ := protojson.Marshal(il)
+		fmt.Println(string(pretty.Pretty(b)))
+		return nil
+	}
+
+	t := table.NewWriter()
+	t.SetOutputMirror(os.Stdout)
+	t.AppendHeader(table.Row{"Name", "Manifests Count", "Latest Tag", "Latest Created", "Latest Digest"})
+	for _, image := range il.Images {
+		t.AppendRow(table.Row{image.Name, image.ManifestsCount, image.Latest.Tag, image.Latest.Created.AsTime().Format(time.RFC3339), image.Latest.Digest})
+	}
+	t.AppendFooter(table.Row{"Total", len(il.Images)})
+	t.Render()
+
+	return nil
+}
+
+// ImageInspectCmd holds image inspect command
+type ImageInspectCmd struct {
+	CliGlobals
+	Image string `kong:"name='image',required,help='Image to inspect.'"`
+	Raw   bool   `kong:"name='raw',default='false',help='JSON output.'"`
+}
+
+func (s *ImageInspectCmd) Run(ctx *Context) error {
+	defer s.conn.Close()
+
+	ii, err := s.imageSvc.ImageInspect(context.Background(), &pb.ImageInspectRequest{
+		Name: s.Image,
+	})
+	if err != nil {
+		return err
+	}
+
+	sort.Slice(ii.Image.Manifests, func(i, j int) bool {
+		return ii.Image.Manifests[i].Created.AsTime().After(ii.Image.Manifests[j].Created.AsTime())
+	})
+
+	if s.Raw {
+		b, _ := protojson.Marshal(ii)
+		fmt.Println(string(pretty.Pretty(b)))
+		return nil
+	}
+
+	t := table.NewWriter()
+	t.SetOutputMirror(os.Stdout)
+	t.AppendHeader(table.Row{"Tag", "Created", "Digest"})
+	for _, image := range ii.Image.Manifests {
+		t.AppendRow(table.Row{image.Tag, image.Created.AsTime().Format(time.RFC3339), image.Digest})
+	}
+	t.AppendFooter(table.Row{"Total", len(ii.Image.Manifests)})
+	t.Render()
+
+	return nil
+}
+
+// ImageRemoveCmd holds image remove command
+type ImageRemoveCmd struct {
+	CliGlobals
+	Image string `kong:"name='image',required,help='Image to remove.'"`
+	All   bool   `kong:"name='all',default='false',help='Remove all manifests from the database.'"`
+}
+
+func (s *ImageRemoveCmd) Run(ctx *Context) error {
+	defer s.conn.Close()
+
+	removed, err := s.imageSvc.ImageRemove(context.Background(), &pb.ImageRemoveRequest{
+		Name: s.Image,
+	})
+	if err != nil {
+		return err
+	}
+
+	t := table.NewWriter()
+	t.SetOutputMirror(os.Stdout)
+	t.AppendHeader(table.Row{"Tag", "Created", "Digest", "Size"})
+	var totalSize int64
+	for _, image := range removed.Manifests {
+		t.AppendRow(table.Row{image.Tag, image.Created.AsTime().Format(time.RFC3339), image.Digest, units.HumanSize(float64(image.Size))})
+		totalSize += image.Size
+	}
+	t.AppendFooter(table.Row{"Total", fmt.Sprintf("%d (%s)", len(removed.Manifests), units.HumanSize(float64(totalSize)))})
+	t.Render()
+
+	return nil
+}
diff --git a/cmd/main.go b/cmd/main.go
index cefd7ff7..6e59a76d 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -3,48 +3,48 @@ package main
 import (
 	"fmt"
 	"os"
-	"os/signal"
-	"path"
 	"runtime"
 	"strings"
-	"syscall"
 	_ "time/tzdata"
 
 	"github.com/alecthomas/kong"
-	"github.com/crazy-max/diun/v4/internal/app"
-	"github.com/crazy-max/diun/v4/internal/config"
-	"github.com/crazy-max/diun/v4/internal/logging"
 	"github.com/crazy-max/diun/v4/internal/model"
-	"github.com/pkg/profile"
 	"github.com/rs/zerolog/log"
 )
 
 var (
-	diun    *app.Diun
-	cli     model.Cli
 	version = "dev"
-	meta    = model.Meta{
-		ID:     "diun",
-		Name:   "Diun",
-		Desc:   "Docker image update notifier",
-		URL:    "https://github.com/crazy-max/diun",
-		Logo:   "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png",
-		Author: "CrazyMax",
+	cli     struct {
+		Version kong.VersionFlag
+		Serve   ServeCmd `kong:"cmd,help='Starts Diun server.'"`
+		Image   ImageCmd `kong:"cmd,help='Manage image manifests.'"`
+		Notif   NotifCmd `kong:"cmd,help='Manage notifications.'"`
 	}
 )
 
+type Context struct {
+	Meta model.Meta
+}
+
 func main() {
 	var err error
 	runtime.GOMAXPROCS(runtime.NumCPU())
 
-	meta.Version = version
+	meta := model.Meta{
+		ID:      "diun",
+		Name:    "Diun",
+		Desc:    "Docker image update notifier",
+		URL:     "https://github.com/crazy-max/diun",
+		Logo:    "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png",
+		Author:  "CrazyMax",
+		Version: version,
+	}
 	meta.UserAgent = fmt.Sprintf("%s/%s go/%s %s", meta.ID, meta.Version, runtime.Version()[2:], strings.Title(runtime.GOOS))
 	if meta.Hostname, err = os.Hostname(); err != nil {
 		log.Fatal().Err(err).Msg("Cannot resolve hostname")
 	}
 
-	// Parse command line
-	_ = kong.Parse(&cli,
+	ctx := kong.Parse(&cli,
 		kong.Name(meta.ID),
 		kong.Description(fmt.Sprintf("%s. More info: %s", meta.Desc, meta.URL)),
 		kong.UsageOnError(),
@@ -56,69 +56,5 @@ func main() {
 			Summary: true,
 		}))
 
-	// Init
-	logging.Configure(&cli)
-	log.Info().Str("version", version).Msgf("Starting %s", meta.Name)
-
-	// Handle os signals
-	channel := make(chan os.Signal)
-	signal.Notify(channel, os.Interrupt, syscall.SIGTERM)
-	go func() {
-		sig := <-channel
-		diun.Close()
-		log.Warn().Msgf("Caught signal %v", sig)
-		os.Exit(0)
-	}()
-
-	// Load configuration
-	cfg, err := config.Load(cli)
-	if err != nil {
-		log.Fatal().Err(err).Msg("Cannot load configuration")
-	}
-	log.Debug().Msg(cfg.String())
-
-	// Profiler
-	if len(cli.Profiler) > 0 && len(cli.ProfilerPath) > 0 {
-		profilerPath := path.Clean(cli.ProfilerPath)
-		if err = os.MkdirAll(profilerPath, os.ModePerm); err != nil {
-			log.Fatal().Err(err).Msg("Cannot create profiler folder")
-		}
-		profilePath := profile.ProfilePath(profilerPath)
-		switch cli.Profiler {
-		case "cpu":
-			defer profile.Start(profile.CPUProfile, profilePath).Stop()
-		case "mem":
-			defer profile.Start(profile.MemProfile, profilePath).Stop()
-		case "alloc":
-			defer profile.Start(profile.MemProfileAllocs, profilePath).Stop()
-		case "heap":
-			defer profile.Start(profile.MemProfileHeap, profilePath).Stop()
-		case "routines":
-			defer profile.Start(profile.GoroutineProfile, profilePath).Stop()
-		case "mutex":
-			defer profile.Start(profile.MutexProfile, profilePath).Stop()
-		case "threads":
-			defer profile.Start(profile.ThreadcreationProfile, profilePath).Stop()
-		case "block":
-			defer profile.Start(profile.BlockProfile, profilePath).Stop()
-		default:
-			log.Fatal().Msgf("Unknown profiler: %s", cli.Profiler)
-		}
-	}
-
-	// Init
-	if diun, err = app.New(meta, cli, cfg); err != nil {
-		log.Fatal().Err(err).Msgf("Cannot initialize %s", meta.Name)
-	}
-
-	// Test notif
-	if cli.TestNotif {
-		diun.TestNotif()
-		return
-	}
-
-	// Start
-	if err = diun.Start(); err != nil {
-		log.Fatal().Err(err).Msgf("Cannot start %s", meta.Name)
-	}
+	ctx.FatalIfErrorf(ctx.Run(&Context{Meta: meta}))
 }
diff --git a/cmd/notif.go b/cmd/notif.go
new file mode 100644
index 00000000..af38da8b
--- /dev/null
+++ b/cmd/notif.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/crazy-max/diun/v4/pb"
+)
+
+// NotifCmd holds notif command
+type NotifCmd struct {
+	Test NotifTestCmd `kong:"cmd,help='Test notification settings.'"`
+}
+
+// NotifTestCmd holds notif test command
+type NotifTestCmd struct {
+	CliGlobals
+}
+
+func (s *NotifTestCmd) Run(ctx *Context) error {
+	defer s.conn.Close()
+
+	nt, err := s.notifSvc.NotifTest(context.Background(), &pb.NotifTestRequest{})
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(nt.Message)
+	return nil
+}
diff --git a/cmd/serve.go b/cmd/serve.go
new file mode 100644
index 00000000..f9ad208b
--- /dev/null
+++ b/cmd/serve.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+	"os"
+	"os/signal"
+	"path"
+	"syscall"
+
+	"github.com/crazy-max/diun/v4/internal/app"
+	"github.com/crazy-max/diun/v4/internal/config"
+	"github.com/crazy-max/diun/v4/internal/logging"
+	"github.com/pkg/profile"
+	"github.com/rs/zerolog/log"
+)
+
+// ServeCmd holds serve command args and flags
+type ServeCmd struct {
+	Cfgfile       string `kong:"name='config',env='CONFIG',help='Diun configuration file.'"`
+	ProfilerPath  string `kong:"name='profiler-path',env='PROFILER_PATH',help='Base path where profiling files are written.'"`
+	Profiler      string `kong:"name='profiler',env='PROFILER',help='Profiler to use.'"`
+	LogLevel      string `kong:"name='log-level',env='LOG_LEVEL',default='info',help='Set log level.'"`
+	LogJSON       bool   `kong:"name='log-json',env='LOG_JSON',default='false',help='Enable JSON logging output.'"`
+	LogCaller     bool   `kong:"name='log-caller',env='LOG_CALLER',default='false',help='Add file:line of the caller to log output.'"`
+	LogNoColor    bool   `kong:"name='log-nocolor',env='LOG_NOCOLOR',default='false',help='Disables the colorized output.'"`
+	GRPCAuthority string `kong:"name='grpc-authority',env='GRPC_AUTHORITY',default=':42286',help='Address used to expose the gRPC server.'"`
+}
+
+func (s *ServeCmd) Run(ctx *Context) error {
+	var diun *app.Diun
+
+	// Logging
+	logging.Configure(logging.Options{
+		LogLevel:   s.LogLevel,
+		LogJSON:    s.LogJSON,
+		LogCaller:  s.LogCaller,
+		LogNoColor: s.LogNoColor,
+	})
+	log.Info().Str("version", version).Msgf("Starting %s", ctx.Meta.Name)
+
+	// Handle os signals
+	channel := make(chan os.Signal)
+	signal.Notify(channel, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		sig := <-channel
+		diun.Close()
+		log.Warn().Msgf("Caught signal %v", sig)
+		os.Exit(0)
+	}()
+
+	// Load configuration
+	cfg, err := config.Load(s.Cfgfile)
+	if err != nil {
+		log.Fatal().Err(err).Msg("Cannot load configuration")
+	}
+	log.Debug().Msg(cfg.String())
+
+	// Profiler
+	if len(s.Profiler) > 0 && len(s.ProfilerPath) > 0 {
+		profilerPath := path.Clean(s.ProfilerPath)
+		if err = os.MkdirAll(profilerPath, os.ModePerm); err != nil {
+			log.Fatal().Err(err).Msg("Cannot create profiler folder")
+		}
+		profilePath := profile.ProfilePath(profilerPath)
+		switch s.Profiler {
+		case "cpu":
+			defer profile.Start(profile.CPUProfile, profilePath).Stop()
+		case "mem":
+			defer profile.Start(profile.MemProfile, profilePath).Stop()
+		case "alloc":
+			defer profile.Start(profile.MemProfileAllocs, profilePath).Stop()
+		case "heap":
+			defer profile.Start(profile.MemProfileHeap, profilePath).Stop()
+		case "routines":
+			defer profile.Start(profile.GoroutineProfile, profilePath).Stop()
+		case "mutex":
+			defer profile.Start(profile.MutexProfile, profilePath).Stop()
+		case "threads":
+			defer profile.Start(profile.ThreadcreationProfile, profilePath).Stop()
+		case "block":
+			defer profile.Start(profile.BlockProfile, profilePath).Stop()
+		default:
+			log.Fatal().Msgf("Unknown profiler: %s", s.Profiler)
+		}
+	}
+
+	// Init
+	diun, err = app.New(ctx.Meta, cfg, s.GRPCAuthority)
+	if err != nil {
+		log.Fatal().Err(err).Msgf("Cannot initialize %s", ctx.Meta.Name)
+	}
+
+	// Start
+	err = diun.Start()
+	if err != nil {
+		log.Fatal().Err(err).Msgf("Cannot start %s", ctx.Meta.Name)
+	}
+
+	return nil
+}
diff --git a/docker-bake.hcl b/docker-bake.hcl
index 81180511..89f9cbe3 100644
--- a/docker-bake.hcl
+++ b/docker-bake.hcl
@@ -9,6 +9,17 @@ target "go-version" {
   }
 }
 
+// protoc version
+variable "PROTOC_VERSION" {
+  default = "3.17.0"
+}
+
+target "protoc-version" {
+  args = {
+    PROTOC_VERSION = PROTOC_VERSION
+  }
+}
+
 // GitHub reference as defined in GitHub Actions (eg. refs/head/master))
 variable "GITHUB_REF" {
   default = ""
@@ -30,7 +41,7 @@ group "default" {
 }
 
 group "validate" {
-  targets = ["lint", "vendor-validate"]
+  targets = ["lint", "vendor-validate", "gen-validate"]
 }
 
 target "lint" {
@@ -52,6 +63,19 @@ target "vendor-update" {
   output = ["."]
 }
 
+target "gen-validate" {
+  inherits = ["go-version", "protoc-version"]
+  dockerfile = "./hack/gen.Dockerfile"
+  target = "validate"
+}
+
+target "gen-update" {
+  inherits = ["go-version", "protoc-version"]
+  dockerfile = "./hack/gen.Dockerfile"
+  target = "update"
+  output = ["."]
+}
+
 target "test" {
   inherits = ["go-version"]
   dockerfile = "./hack/test.Dockerfile"
diff --git a/docs/config/index.md b/docs/config/index.md
index b79f96b9..d63b8b57 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -22,7 +22,7 @@ At startup, Diun searches for a file named `diun.yml` (or `diun.yaml`) in:
 * `$HOME/.config/`
 * `.` _(the working directory)_
 
-You can override this using the [`--config` flag or `CONFIG` env var](../usage/cli.md).
+You can override this using the [`--config` flag or `CONFIG` env var with `serve` command](../usage/command-line.md#serve).
 
 ??? example "diun.yml"
     ```yaml
diff --git a/docs/config/regopts.md b/docs/config/regopts.md
index 825925a1..09ad8ab7 100644
--- a/docs/config/regopts.md
+++ b/docs/config/regopts.md
@@ -20,13 +20,11 @@ regopts:
     passwordFile: /run/secrets/password
 ```
 
-`myregistry` will be used as a `name` selector (default) if referenced by its [name](#name).
-
-`docker.io` will be used as an `image` selector. If an image is on DockerHub (`docker.io` domain), this registry options will
-be selected if not referenced as a `regopt` name.
-
-`docker.io/crazymax` will be used as an `image` selector. If an image is on DockerHub and in `crazymax` namespace, this registry options will
-be selected if not referenced as a `regopt` name.
+* `myregistry` will be used as a `name` selector (default) if referenced by its [name](#name).
+* `docker.io` will be used as an `image` selector. If an image is on DockerHub (`docker.io` domain), this registry
+options will be selected if not referenced as a `regopt` name.
+* `docker.io/crazymax` will be used as an `image` selector. If an image is on DockerHub and in `crazymax` namespace,
+this registry options will be selected if not referenced as a `regopt` name.
 
 ## Configuration
 
diff --git a/docs/config/watch.md b/docs/config/watch.md
index 960d2e3a..b6809d07 100644
--- a/docs/config/watch.md
+++ b/docs/config/watch.md
@@ -60,7 +60,7 @@ Send notification at the very first analysis of an image. (default `false`)
 ### `compareDigest`
 
 Compare the digest of an image with the registry before downloading the image manifest. It is strongly
-recommended to leave this value at `true`, especially with [Docker Hub which imposes a rate-limit](../faq.md#docker-hub-rate-limits)
+recommended leaving this value at `true`, especially with [Docker Hub which imposes a rate-limit](../faq.md#docker-hub-rate-limits)
 on image pull. (default `true`)
 
 !!! example "Config file"
@@ -74,7 +74,7 @@ on image pull. (default `true`)
 
 ### `healthchecks`
 
-Healthchecks allows to monitor Diun watcher by sending start and success notification
+Healthchecks allows monitoring Diun watcher by sending start and success notification
 events to [healthchecks.io](https://healthchecks.io/).
 
 !!! tip
diff --git a/docs/faq.md b/docs/faq.md
index cff80357..56f646c5 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -14,16 +14,16 @@ watch:
 
 ## Test notifications
 
-Through the [command line](usage/cli.md) with:
+Through the [command line](usage/command-line.md#notif-test) with:
 
 ```shell
-diun --config ./diun.yml --test-notif
+diun notif test
 ```
 
 Or within a container:
 
 ```shell
-docker-compose exec diun diun --test-notif
+docker-compose exec diun diun notif test
 ```
 
 ## field docker|swarm uses unsupported type: invalid
@@ -127,7 +127,8 @@ Or you can tweak the [`schedule` setting](config/watch.md#schedule) with somethi
 
 ## Profiling
 
-Diun provides a simple way to manage runtime/pprof profiling through [`--profiler-path` and `--profiler` flags](usage/cli.md#options):
+Diun provides a simple way to manage runtime/pprof profiling through the
+[`--profiler-path` and `--profiler` flags with `serve` command](usage/command-line.md#serve):
 
 ```yaml
 version: "3.5"
diff --git a/docs/install/binary.md b/docs/install/binary.md
index b4104eea..31669caf 100644
--- a/docs/install/binary.md
+++ b/docs/install/binary.md
@@ -25,8 +25,8 @@ And extract diun:
 wget -qO- {{ config.repo_url }}releases/download/v{{ git.tag | trim('v') }}/diun_{{ git.tag | trim('v') }}_linux_x86_64.tar.gz | tar -zxvf - diun
 ```
 
-After getting the binary, it can be tested with [`./diun --help`](../usage/cli.md) command and moved to a permanent
-location.
+After getting the binary, it can be tested with [`./diun --help`](../usage/command-line.md#global-options) command
+and moved to a permanent location.
 
 ## Server configuration
 
diff --git a/docs/migration/v3-to-v4.md b/docs/migration/v3-to-v4.md
index 44cd773f..c662044e 100644
--- a/docs/migration/v3-to-v4.md
+++ b/docs/migration/v3-to-v4.md
@@ -233,7 +233,8 @@ Following the transposition of the configuration into environment variables, the
 is no longer loaded by default in the official Docker image.
 
 If you want to load a configuration file through the Docker image you will have to declare the
-[`CONFIG` environment variable](../usage/cli.md#environment-variables) pointing to the assigned configuration file:
+[`CONFIG` environment variable with `serve` command](../usage/command-line.md#serve) pointing to the assigned
+configuration file:
 
 !!! tip
     This is no longer required since version 4.2.0. Now configuration file can be loaded from
diff --git a/docs/migration/v4.0-to-v4.17.md b/docs/migration/v4.0-to-v4.17.md
new file mode 100644
index 00000000..d027e7ed
--- /dev/null
+++ b/docs/migration/v4.0-to-v4.17.md
@@ -0,0 +1,15 @@
+# Diun v4.0 to v4.17
+
+## New CLI
+
+CLI has changed since 4.17 and includes [`serve` command](../usage/command-line.md#serve) to start Diun:
+
+!!! example "v4.0"
+    ```shell
+    diun --config diun.yml
+    ```
+
+!!! example "v4.17"
+    ```shell
+    diun serve --config diun.yml
+    ```
diff --git a/docs/usage/cli.md b/docs/usage/cli.md
deleted file mode 100644
index 71878788..00000000
--- a/docs/usage/cli.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Command Line
-
-## Usage
-
-```shell
-diun [options]
-```
-
-## Options
-
-```
-$ diun --help
-Usage: diun
-
-Docker image update notifier. More info: https://github.com/crazy-max/diun
-
-Flags:
-  -h, --help                    Show context-sensitive help.
-      --version
-      --config=STRING           Diun configuration file ($CONFIG).
-      --profiler-path=STRING    Base path where profiling files are written
-                                ($PROFILER_PATH).
-      --profiler=STRING         Profiler to use ($PROFILER).
-      --log-level="info"        Set log level ($LOG_LEVEL).
-      --log-json                Enable JSON logging output ($LOG_JSON).
-      --log-caller              Add file:line of the caller to log output
-                                ($LOG_CALLER).
-      --log-nocolor             Disables the colorized output ($LOG_NOCOLOR).
-      --test-notif              Test notification settings.
-```
-
-## Environment variables
-
-Following environment variables can be used in place:
-
-| Name               | Default       | Description   |
-|--------------------|---------------|---------------|
-| `CONFIG`           |               | Diun configuration file |
-| `PROFILER_PATH`    |               | Base path where profiling files are written |
-| `PROFILER`         |               | [Profiler](../faq.md#profiling) to use |
-| `LOG_LEVEL`        | `info`        | Log level output |
-| `LOG_JSON`         | `false`       | Enable JSON logging output |
-| `LOG_CALLER`       | `false`       | Enable to add `file:line` of the caller |
-| `LOG_NOCOLOR`      | `false`       | Disables the colorized output |
diff --git a/docs/usage/command-line.md b/docs/usage/command-line.md
new file mode 100644
index 00000000..22c22417
--- /dev/null
+++ b/docs/usage/command-line.md
@@ -0,0 +1,124 @@
+# Command Line
+
+## Usage
+
+```shell
+diun [global options] command [command or global options] [arguments...]
+```
+
+## Global options
+
+All global options can be placed at the command level.
+
+* `--help`, `-h`: Show context-sensitive help.
+* `--version`: Show version and exit.
+
+## Commands
+
+### `serve`
+
+Starts Diun server.
+
+* `--config <path>`: Diun configuration file
+* `--profiler-path <path>`: Base path where profiling files are written
+* `--profiler <string>`: Profiler to use
+* `--log-level <string>`: Set log level (default `info`)
+* `--log-json`: Enable JSON logging output
+* `--log-caller`: Add `file:line` of the caller to log output
+* `--log-nocolor`: Disables the colorized output
+* `--grpc-authority <string>`: Address used to expose the gRPC server (default `:42286`)
+
+Examples:
+
+```shell
+diun serve --config diun.yml --log-level debug
+```
+
+Following environment variables can also be used in place:
+
+| Name               | Default       | Description   |
+|--------------------|---------------|---------------|
+| `CONFIG`           |               | Diun configuration file |
+| `PROFILER_PATH`    |               | Base path where profiling files are written |
+| `PROFILER`         |               | [Profiler](../faq.md#profiling) to use |
+| `LOG_LEVEL`        | `info`        | Log level output |
+| `LOG_JSON`         | `false`       | Enable JSON logging output |
+| `LOG_CALLER`       | `false`       | Enable to add `file:line` of the caller |
+| `LOG_NOCOLOR`      | `false`       | Disables the colorized output |
+| `GRPC_AUTHORITY`   | `:42286`      | Address used to expose the gRPC server |
+
+### `image list`
+
+!!! note
+    Diun needs to be started through [`serve`](#serve) command to be able to use this command.
+
+List images in database.
+
+* `--raw`: JSON output
+* `--grpc-authority <string>`: Link to Diun gRPC API (default `127.0.0.1:42286`)
+
+Examples:
+
+```shell
+diun image list
+```
+```shell
+diun image list --raw
+```
+
+### `image inspect`
+
+!!! note
+    Diun needs to be started through [`serve`](#serve) command to be able to use this command.
+
+Display information of an image in database.
+
+* `--image`: Image to inspect (**required**)
+* `--raw`: JSON output
+* `--grpc-authority <string>`: Link to Diun gRPC API (default `127.0.0.1:42286`)
+
+Examples:
+
+```shell
+diun image inspect alpine
+```
+```shell
+diun image inspect drone/drone --raw
+```
+
+### `image remove`
+
+!!! note
+    Diun needs to be started through [`serve`](#serve) command to be able to use this command.
+
+Remove an image manifest from database.
+
+* `--image`: Image to remove (**required**)
+* `--grpc-authority <string>`: Link to Diun gRPC API (default `127.0.0.1:42286`)
+
+Examples:
+
+```shell
+diun image remove alpine:latest
+```
+```shell
+diun image inspect drone/drone
+```
+
+!!! warning
+    All manifest for an image will be removed if no tag is specified
+
+### `notif test`
+
+!!! note
+    Diun needs to be started through [`serve`](#serve) command to be able to use this command.
+
+Test notification settings.
+
+* `--grpc-authority <string>`: Link to Diun gRPC API (default `127.0.0.1:42286`)
+
+Examples:
+
+```shell
+diun notif test
+```
diff --git a/docs/user-guides/blog-posts.md b/docs/user-guides/blog-posts.md
index e49e7ad1..f926c2ce 100644
--- a/docs/user-guides/blog-posts.md
+++ b/docs/user-guides/blog-posts.md
@@ -4,7 +4,10 @@ Tutorials made by the community.
 
 <!-- please, keep A-Z sorting -->
 - [Diun - Docker image update notifier](https://crazymax.medium.com/diun-docker-image-update-notifier-78f09e29b469)
+- [Diun - friendly docker image update notifier](https://www.blackvoid.club/diun-friendly-docker-image-update-notifier/)
+- [Docker Container Building via GitHub Actions with Diun](https://alexsguardian.net/2020/09/21/docker-container-building-via-github-actions-with-diun/)
 - [Monitoring For Docker Image Updates With Diun](https://spad.uk/monitoring-for-docker-image-updates-with-diun/)
+- [Updating Docker containers safely](https://cwl.cc/2021/05/updating-docker-containers-safely.html)
 
 !!! tip
     Click on the "Edit" button to add your page here.
diff --git a/go.mod b/go.mod
index eb895aec..cfa24c65 100644
--- a/go.mod
+++ b/go.mod
@@ -1,23 +1,25 @@
 module github.com/crazy-max/diun/v4
 
-go 1.15
+go 1.16
 
 require (
 	github.com/alecthomas/kong v0.2.16
 	github.com/bmatcuk/doublestar/v3 v3.0.0
-	github.com/containerd/containerd v1.5.0
+	github.com/containerd/containerd v1.5.2
 	github.com/containers/image/v5 v5.12.0
 	github.com/crazy-max/gohealthchecks v0.3.0
 	github.com/crazy-max/gonfig v0.4.0
 	github.com/docker/docker v20.10.6+incompatible
 	github.com/docker/go-connections v0.4.0
+	github.com/docker/go-units v0.4.0
 	github.com/eclipse/paho.mqtt.golang v1.3.3
 	github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df
-	github.com/go-playground/validator/v10 v10.5.0
+	github.com/go-playground/validator/v10 v10.6.1
 	github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
 	github.com/gregdel/pushover v0.0.0-20201104094836-ddbe0c1d3a38
 	github.com/hako/durafmt v0.0.0-20190612201238-650ed9f29a84
 	github.com/imdario/mergo v0.3.12
+	github.com/jedib0t/go-pretty/v6 v6.2.1
 	github.com/matcornic/hermes/v2 v2.1.0
 	github.com/matrix-org/gomatrix v0.0.0-20200501121722-e5578b12c752
 	github.com/microcosm-cc/bluemonday v1.0.9
@@ -25,17 +27,21 @@ require (
 	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
 	github.com/nlopes/slack v0.6.0
 	github.com/opencontainers/go-digest v1.0.0
-	github.com/panjf2000/ants/v2 v2.4.4
+	github.com/panjf2000/ants/v2 v2.4.5
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/profile v1.6.0
 	github.com/robfig/cron/v3 v3.0.1
-	github.com/rs/zerolog v1.21.0
+	github.com/rs/zerolog v1.22.0
 	github.com/russross/blackfriday/v2 v2.1.0
 	github.com/sirupsen/logrus v1.8.1
 	github.com/streadway/amqp v0.0.0-20200108173154-1c71cc93ed71
 	github.com/stretchr/testify v1.7.0
 	github.com/technoweenie/multipartstreamer v1.0.1 // indirect
+	github.com/tidwall/pretty v1.1.0
 	go.etcd.io/bbolt v1.3.5
+	google.golang.org/grpc v1.37.0
+	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
+	google.golang.org/protobuf v1.26.0
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
 	gopkg.in/yaml.v2 v2.4.0
diff --git a/go.sum b/go.sum
index 55a501d1..c965e6f0 100644
--- a/go.sum
+++ b/go.sum
@@ -298,8 +298,9 @@ github.com/cilium/ebpf v0.2.0 h1:Fv93L3KKckEcEHR3oApXVzyBTDA8WAm6VXhPE00N3f8=
 github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs=
 github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
 github.com/codahale/hdrhistogram v0.0.0-20160425231609-f8ad88b59a58 h1:hHWif/4GirK3P5uvCyyj941XSVIQDzuJhbEguCICdPE=
@@ -458,8 +459,9 @@ github.com/emicklei/go-restful v2.9.5+incompatible h1:spTtZBk5DYEvbxMVutUuTyh1Ao
 github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4 h1:rEvIZUSZ3fx39WIi3JkQqQBitGwpELBIYWeBVh6wn+E=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d h1:QyzYnTnPE15SQyUeqU6qLbWxMkwyAyu+vGksa0b7j00=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
@@ -478,6 +480,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fzipp/gocyclo v0.3.1 h1:A9UeX3HJSXTBzvHzhqoYVuE0eAhe+aM8XBCCwsPMZOc=
+github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7 h1:LofdAjjjqCSXMwLGgOgnE+rdPuvX9DxCqaHwKy7i/ko=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -531,8 +535,8 @@ github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8c
 github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
 github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.5.0 h1:X9rflw/KmpACwT8zdrm1upefpvdy6ur8d1kWyq6sg3E=
-github.com/go-playground/validator/v10 v10.5.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
+github.com/go-playground/validator/v10 v10.6.1 h1:W6TRDXt4WcWp4c4nf/G+6BkGdhiIo0k417gfr+V6u4I=
+github.com/go-playground/validator/v10 v10.6.1/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
@@ -616,8 +620,9 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -667,8 +672,9 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-containerregistry v0.0.0-20191010200024-a3d713f9b7f8/go.mod h1:KyKXa9ciM8+lgMXwOVsXi7UxGrsf9mM61Mzs+xKUrKE=
 github.com/google/go-containerregistry v0.1.2 h1:YjFNKqxzWUVZND8d4ItF9wuYlE75WQfECE7yKX/Nu3o=
 github.com/google/go-containerregistry v0.1.2/go.mod h1:GPivBPgdAyd2SU+vf6EpsgOtWDuPqjW0hJZt4rNdTZ4=
@@ -856,6 +862,8 @@ github.com/jarcoal/httpmock v1.0.5 h1:cHtVEcTxRSX4J0je7mWPfc9BpDpqzXSJ5HbymZmyHc
 github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
 github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
 github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
+github.com/jedib0t/go-pretty/v6 v6.2.1 h1:O/3XdNfyWSyVLLIt1EeDhfP8AhNMjtBSh0MuZ4frg6U=
+github.com/jedib0t/go-pretty/v6 v6.2.1/go.mod h1:+nE9fyyHGil+PuISTCrp7avEdo6bqoMwqZnuiK2r2a0=
 github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1 h1:ujPKutqRlJtcfWk6toYVYagwra7HQHbXOaS171b4Tg8=
 github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
 github.com/jingyugao/rowserrcheck v0.0.0-20191204022205-72ab7603b68a h1:GmsqmapfzSJkm28dhRoHz2tLRbJmqhU86IPgBtN3mmk=
@@ -977,6 +985,7 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
@@ -1132,8 +1141,8 @@ github.com/openzipkin/zipkin-go v0.1.6 h1:yXiysv1CSK7Q5yjGy1710zZGnsbMUIjluWBxtL
 github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
 github.com/ostreedev/ostree-go v0.0.0-20190702140239-759a8c1ac913 h1:TnbXhKzrTOyuvWrjI8W6pcoI9XPbLHFXCdN2dtUw7Rw=
 github.com/ostreedev/ostree-go v0.0.0-20190702140239-759a8c1ac913/go.mod h1:J6OG6YJVEWopen4avK3VNQSnALmmjvniMmni/YFYAwc=
-github.com/panjf2000/ants/v2 v2.4.4 h1:kebk2KSiXHGeiYS6b+w2RqNN5+IKoqlBNd7cuC7MvQI=
-github.com/panjf2000/ants/v2 v2.4.4/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
+github.com/panjf2000/ants/v2 v2.4.5 h1:kcGvjXB7ea0MrzzszpnlVFthhYKoFxLi75nRbsq01HY=
+github.com/panjf2000/ants/v2 v2.4.5/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
@@ -1152,6 +1161,7 @@ github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pkg/profile v1.5.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
 github.com/pkg/profile v1.6.0 h1:hUDfIISABYI59DyeB3OTay/HxSRwTQ8rB/H83k6r5dM=
 github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
@@ -1217,8 +1227,8 @@ github.com/rogpeppe/go-internal v1.5.2 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25
 github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
-github.com/rs/zerolog v1.21.0 h1:Q3vdXlfLNT+OftyBHsU0Y445MD+8m8axjKgf2si0QcM=
-github.com/rs/zerolog v1.21.0/go.mod h1:ZPhntP/xmq1nnND05hhpAh2QMhSsA4UN3MGZ6O2J3hM=
+github.com/rs/zerolog v1.22.0 h1:XrVUjV4K+izZpKXZHlPrYQiDtmdGiCylnT4i43AAWxg=
+github.com/rs/zerolog v1.22.0/go.mod h1:ZPhntP/xmq1nnND05hhpAh2QMhSsA4UN3MGZ6O2J3hM=
 github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c h1:ht7N4d/B7Ezf58nvMNVF3OlvDlz9pp+WHVcRNS0nink=
 github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto=
 github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
@@ -1348,6 +1358,8 @@ github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6
 github.com/tetafro/godot v0.3.7/go.mod h1:/7NLHhv08H1+8DNj0MElpAACw1ajsCuf3TKNQxA5S+0=
 github.com/tetafro/godot v0.4.2 h1:Dib7un+rYJFUi8vN0Bk6EHheKy6fv6ZzFURHw75g6m8=
 github.com/tetafro/godot v0.4.2/go.mod h1:/7NLHhv08H1+8DNj0MElpAACw1ajsCuf3TKNQxA5S+0=
+github.com/tidwall/pretty v1.1.0 h1:K3hMW5epkdAVwibsQEfR/7Zj0Qgt4DxtNumTq/VloO8=
+github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk=
 github.com/timakin/bodyclose v0.0.0-20200424151742-cb6215831a94 h1:ig99OeTyDwQWhPe2iw9lwfQVF1KB3Q4fpP3X7/2VBG8=
 github.com/timakin/bodyclose v0.0.0-20200424151742-cb6215831a94/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk=
@@ -1611,6 +1623,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1868,8 +1881,11 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
 google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.33.2 h1:EQyQC3sa8M+p6Ulc8yy9SWSS2GVwyRc83gAbG8lrl4o=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.37.0 h1:uSZWeQJX5j11bIQ4AJoj+McDBo29cY1MCoC1wO3ts+c=
+google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1879,8 +1895,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
diff --git a/hack/docs.Dockerfile b/hack/docs.Dockerfile
index 3fb91471..207e2b11 100644
--- a/hack/docs.Dockerfile
+++ b/hack/docs.Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1.2
 
-FROM squidfunk/mkdocs-material:7.1.3 AS base
+FROM squidfunk/mkdocs-material:7.1.5 AS base
 RUN apk add --no-cache \
     git \
     git-fast-import \
diff --git a/hack/gen.Dockerfile b/hack/gen.Dockerfile
new file mode 100644
index 00000000..44b4a9f1
--- /dev/null
+++ b/hack/gen.Dockerfile
@@ -0,0 +1,40 @@
+# syntax=docker/dockerfile:1.2
+ARG GO_VERSION
+ARG PROTOC_VERSION
+ARG GLIBC_VERSION=2.33-r0
+
+FROM golang:${GO_VERSION}-alpine AS base
+ARG GLIBC_VERSION
+RUN apk add --no-cache curl file git unzip \
+  && curl -sSL "https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub" -o "/etc/apk/keys/sgerrand.rsa.pub" \
+  && curl -sSL "https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk" -o "glibc.apk" \
+  && apk add glibc.apk \
+  && rm /etc/apk/keys/sgerrand.rsa.pub glibc.apk
+ARG PROTOC_VERSION
+RUN curl -sSL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" -o "protoc.zip" \
+  && unzip "protoc.zip" -d "/usr/local" \
+  && protoc --version \
+  && rm "protoc.zip"
+WORKDIR /src
+
+FROM base AS gomod
+RUN --mount=type=bind,target=.,rw \
+  --mount=type=cache,target=/go/pkg/mod \
+  go mod tidy && go mod download && go install -v $(sed -n -e 's|^\s*_\s*"\(.*\)".*$|\1| p' tools.go)
+
+FROM gomod AS generate
+RUN --mount=type=bind,target=.,rw \
+  --mount=type=cache,target=/go/pkg/mod \
+  go generate ./... && mkdir /out && cp -Rf pb /out
+
+FROM scratch AS update
+COPY --from=generate /out /
+
+FROM generate AS validate
+RUN --mount=type=bind,target=.,rw \
+  git add -A && cp -rf /out/* .; \
+  if [ -n "$(git status --porcelain)" ]; then \
+    echo >&2 'ERROR: Generate result differs. Please update with "docker buildx bake gen-update"'; \
+    git status --porcelain; \
+    exit 1; \
+  fi
diff --git a/internal/app/diun.go b/internal/app/diun.go
index 2b4cdd73..aa37b660 100644
--- a/internal/app/diun.go
+++ b/internal/app/diun.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/crazy-max/diun/v4/internal/config"
 	"github.com/crazy-max/diun/v4/internal/db"
+	"github.com/crazy-max/diun/v4/internal/grpc"
 	"github.com/crazy-max/diun/v4/internal/logging"
 	"github.com/crazy-max/diun/v4/internal/model"
 	"github.com/crazy-max/diun/v4/internal/notif"
@@ -16,7 +17,6 @@ import (
 	filePrd "github.com/crazy-max/diun/v4/internal/provider/file"
 	kubernetesPrd "github.com/crazy-max/diun/v4/internal/provider/kubernetes"
 	swarmPrd "github.com/crazy-max/diun/v4/internal/provider/swarm"
-	"github.com/crazy-max/diun/v4/pkg/registry"
 	"github.com/crazy-max/gohealthchecks"
 	"github.com/hako/durafmt"
 	"github.com/panjf2000/ants/v2"
@@ -27,12 +27,15 @@ import (
 
 // Diun represents an active diun object
 type Diun struct {
-	meta   model.Meta
-	cfg    *config.Config
+	meta model.Meta
+	cfg  *config.Config
+
+	db    *db.Client
+	grpc  *grpc.Client
+	hc    *gohealthchecks.Client
+	notif *notif.Client
+
 	cron   *cron.Cron
-	db     *db.Client
-	hc     *gohealthchecks.Client
-	notif  *notif.Client
 	jobID  cron.EntryID
 	locker uint32
 	pool   *ants.PoolWithFunc
@@ -40,7 +43,7 @@ type Diun struct {
 }
 
 // New creates new diun instance
-func New(meta model.Meta, cli model.Cli, cfg *config.Config) (*Diun, error) {
+func New(meta model.Meta, cfg *config.Config, grpcAuthority string) (*Diun, error) {
 	var err error
 
 	diun := &Diun{
@@ -56,11 +59,14 @@ func New(meta model.Meta, cli model.Cli, cfg *config.Config) (*Diun, error) {
 		return nil, err
 	}
 
-	if !cli.TestNotif {
-		diun.db, err = db.New(*cfg.Db)
-		if err != nil {
-			return nil, err
-		}
+	diun.db, err = db.New(*cfg.Db)
+	if err != nil {
+		return nil, err
+	}
+
+	diun.grpc, err = grpc.New(grpcAuthority, diun.db, diun.notif)
+	if err != nil {
+		return nil, err
 	}
 
 	if cfg.Watch.Healthchecks != nil {
@@ -89,6 +95,13 @@ func (di *Diun) Start() error {
 		return err
 	}
 
+	// Start GRPC server
+	go func() {
+		if err := di.grpc.Start(); err != nil {
+			log.Fatal().Err(err).Msg("Failed to start GRPC server")
+		}
+	}()
+
 	// Run on startup
 	di.Run()
 
@@ -157,7 +170,7 @@ func (di *Diun) Run() {
 		di.createJob(job)
 	}
 
-	// Dokcerfile provider
+	// Dockerfile provider
 	for _, job := range dockerfilePrd.New(di.cfg.Providers.Dockerfile).ListJob() {
 		di.createJob(job)
 	}
@@ -178,51 +191,8 @@ func (di *Diun) Close() {
 	if di.cron != nil {
 		di.cron.Stop()
 	}
+	di.grpc.Stop()
 	if err := di.db.Close(); err != nil {
 		log.Warn().Err(err).Msg("Cannot close database")
 	}
 }
-
-// TestNotif test the notification settings
-func (di *Diun) TestNotif() {
-	createdAt, _ := time.Parse("2006-01-02T15:04:05Z", "2020-03-26T12:23:56Z")
-	image, _ := registry.ParseImage(registry.ParseImageOptions{
-		Name: "diun/testnotif:latest",
-	})
-	image.HubLink = ""
-
-	log.Info().Msg("Testing notification settings...")
-	di.notif.Send(model.NotifEntry{
-		Status:   "new",
-		Provider: "file",
-		Image:    image,
-		Manifest: registry.Manifest{
-			Name:          "diun/testnotif",
-			Tag:           "latest",
-			MIMEType:      "application/vnd.docker.distribution.manifest.list.v2+json",
-			Digest:        "sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01",
-			Created:       &createdAt,
-			DockerVersion: "",
-			Labels: map[string]string{
-				"maintainer":                      "CrazyMax",
-				"org.label-schema.build-date":     "2020-03-26T12:23:56Z",
-				"org.label-schema.description":    "Docker image update notifier",
-				"org.label-schema.name":           "Diun",
-				"org.label-schema.schema-version": "1.0",
-				"org.label-schema.url":            "https://github.com/crazy-max/diun",
-				"org.label-schema.vcs-ref":        "e13f097c",
-				"org.label-schema.vcs-url":        "https://github.com/crazy-max/diun",
-				"org.label-schema.vendor":         "CrazyMax",
-				"org.label-schema.version":        "x.x.x",
-			},
-			Layers: []string{
-				"sha256:aad63a9339440e7c3e1fff2b988991b9bfb81280042fa7f39a5e327023056819",
-				"sha256:166c6f165b73185ede72415d780538a55c0c8e854bd177925bc007193e5b0d1b",
-				"sha256:e05682efa9cc9d6239b2b9252fe0dc1e58d6e1585679733bb94a6549d49e9b10",
-				"sha256:c6a5bfed445b3ed7e85523cd73c6532ac9f9b72bb588ca728fd5b33987ca6538",
-				"sha256:df2140efb8abeb727ef0b27ff158b7010a7941eb1cfdade505f510a6e1eaf016",
-			},
-			Platform: "linux/amd64",
-		},
-	})
-}
diff --git a/internal/config/config.go b/internal/config/config.go
index 8a1e775e..d7f61258 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -22,14 +22,14 @@ type Config struct {
 }
 
 // Load returns Config struct
-func Load(cli model.Cli) (*Config, error) {
+func Load(config string) (*Config, error) {
 	cfg := Config{
 		Db:    (&model.Db{}).GetDefaults(),
 		Watch: (&model.Watch{}).GetDefaults(),
 	}
 
 	fileLoader := gonfig.NewFileLoader(gonfig.FileLoaderConfig{
-		Filename: cli.Cfgfile,
+		Filename: config,
 		Finder: gonfig.Finder{
 			BasePaths:  []string{"/etc/diun/diun", "$XDG_CONFIG_HOME/diun", "$HOME/.config/diun", "./diun"},
 			Extensions: []string{"yaml", "yml"},
@@ -54,14 +54,14 @@ func Load(cli model.Cli) (*Config, error) {
 		log.Info().Msgf("Configuration loaded from %d environment variable(s)", len(envLoader.GetVars()))
 	}
 
-	if err := cfg.validate(cli); err != nil {
+	if err := cfg.validate(); err != nil {
 		return nil, err
 	}
 
 	return &cfg, nil
 }
 
-func (cfg *Config) validate(cli model.Cli) error {
+func (cfg *Config) validate() error {
 	if len(cfg.Db.Path) > 0 {
 		if err := os.MkdirAll(path.Dir(cfg.Db.Path), os.ModePerm); err != nil {
 			return errors.Wrap(err, "Cannot create database destination folder")
@@ -72,11 +72,7 @@ func (cfg *Config) validate(cli model.Cli) error {
 		return errors.New("Healthchecks UUID is required")
 	}
 
-	if cfg.Notif == nil && cli.TestNotif {
-		return errors.New("At least one notifier is required")
-	}
-
-	if cfg.Providers == nil && !cli.TestNotif {
+	if cfg.Providers == nil {
 		return errors.New("At least one provider is required")
 	}
 
diff --git a/internal/config/fixtures/config.err.notif.yml b/internal/config/fixtures/config.err.notif.yml
deleted file mode 100644
index f5b0b209..00000000
--- a/internal/config/fixtures/config.err.notif.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-providers:
-  docker: {}
diff --git a/internal/db/manifest.go b/internal/db/manifest.go
index e11fa951..3010aba5 100644
--- a/internal/db/manifest.go
+++ b/internal/db/manifest.go
@@ -3,6 +3,7 @@ package db
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 
 	"github.com/crazy-max/diun/v4/pkg/registry"
 	bolt "go.etcd.io/bbolt"
@@ -25,6 +26,25 @@ func (c *Client) First(image registry.Image) (bool, error) {
 	return !found, err
 }
 
+// ListManifest return a list of Docker images manifests
+func (c *Client) ListManifest() ([]registry.Manifest, error) {
+	var manifests []registry.Manifest
+
+	err := c.View(func(tx *bolt.Tx) error {
+		c := tx.Bucket([]byte(bucketManifest)).Cursor()
+		for k, v := c.First(); k != nil; k, v = c.Next() {
+			var manifest registry.Manifest
+			if err := json.Unmarshal(v, &manifest); err != nil {
+				return err
+			}
+			manifests = append(manifests, manifest)
+		}
+		return nil
+	})
+
+	return manifests, err
+}
+
 // GetManifest returns Docker image manifest
 func (c *Client) GetManifest(image registry.Image) (registry.Manifest, error) {
 	var manifest registry.Manifest
@@ -43,11 +63,37 @@ func (c *Client) GetManifest(image registry.Image) (registry.Manifest, error) {
 // PutManifest add Docker image manifest in db
 func (c *Client) PutManifest(image registry.Image, manifest registry.Manifest) error {
 	entryBytes, _ := json.Marshal(manifest)
-
-	err := c.Update(func(tx *bolt.Tx) error {
+	return c.Update(func(tx *bolt.Tx) error {
 		b := tx.Bucket([]byte(bucketManifest))
 		return b.Put([]byte(image.String()), entryBytes)
 	})
+}
+
+// DeleteManifest deletes a Docker image manifest
+func (c *Client) DeleteManifest(manifest registry.Manifest) error {
+	return c.Update(func(tx *bolt.Tx) error {
+		return tx.Bucket([]byte(bucketManifest)).Delete([]byte(fmt.Sprintf("%s:%s", manifest.Name, manifest.Tag)))
+	})
+}
+
+// ListImage return a list of Docker images with their linked manifests
+func (c *Client) ListImage() (map[string][]registry.Manifest, error) {
+	images := make(map[string][]registry.Manifest)
+
+	err := c.View(func(tx *bolt.Tx) error {
+		c := tx.Bucket([]byte(bucketManifest)).Cursor()
+		for k, v := c.First(); k != nil; k, v = c.Next() {
+			var manifest registry.Manifest
+			if err := json.Unmarshal(v, &manifest); err != nil {
+				return err
+			}
+			if _, ok := images[manifest.Name]; !ok {
+				images[manifest.Name] = []registry.Manifest{}
+			}
+			images[manifest.Name] = append(images[manifest.Name], manifest)
+		}
+		return nil
+	})
 
-	return err
+	return images, err
 }
diff --git a/internal/db/metadata.go b/internal/db/metadata.go
index b4ecf5bc..2d26dbf1 100644
--- a/internal/db/metadata.go
+++ b/internal/db/metadata.go
@@ -29,11 +29,8 @@ func (c *Client) ReadMetadata() error {
 // WriteMetadata writes db metadata
 func (c *Client) WriteMetadata(metadata Metadata) error {
 	entryBytes, _ := json.Marshal(metadata)
-
-	err := c.Update(func(tx *bolt.Tx) error {
+	return c.Update(func(tx *bolt.Tx) error {
 		b := tx.Bucket([]byte(bucketMetadata))
 		return b.Put([]byte(metadataKey), entryBytes)
 	})
-
-	return err
 }
diff --git a/internal/db/migrate.go b/internal/db/migrate.go
index 4d2b402a..7a386d43 100644
--- a/internal/db/migrate.go
+++ b/internal/db/migrate.go
@@ -75,9 +75,5 @@ func (c *Client) migration2() error {
 		}
 	}
 
-	if err := tx.Commit(); err != nil {
-		return err
-	}
-
-	return nil
+	return tx.Commit()
 }
diff --git a/internal/grpc/client.go b/internal/grpc/client.go
new file mode 100644
index 00000000..f58699e5
--- /dev/null
+++ b/internal/grpc/client.go
@@ -0,0 +1,58 @@
+package grpc
+
+import (
+	"net"
+
+	"github.com/crazy-max/diun/v4/internal/db"
+	grpclogger "github.com/crazy-max/diun/v4/internal/grpc/logger"
+	"github.com/crazy-max/diun/v4/internal/notif"
+	"github.com/crazy-max/diun/v4/pb"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
+	"google.golang.org/grpc"
+)
+
+// Client represents an active grpc object
+type Client struct {
+	server    *grpc.Server
+	authority string
+	db        *db.Client
+	notif     *notif.Client
+	pb.UnimplementedImageServiceServer
+	pb.UnimplementedNotifServiceServer
+}
+
+// New creates a new grpc instance
+func New(authority string, db *db.Client, notif *notif.Client) (*Client, error) {
+	grpclogger.SetGrpcLogger(log.Level(zerolog.ErrorLevel))
+
+	c := &Client{
+		authority: authority,
+		db:        db,
+		notif:     notif,
+	}
+
+	c.server = grpc.NewServer()
+	pb.RegisterImageServiceServer(c.server, c)
+	pb.RegisterNotifServiceServer(c.server, c)
+
+	return c, nil
+}
+
+// Start runs the grpc server
+func (c *Client) Start() error {
+	var err error
+
+	lis, err := net.Listen("tcp", c.authority)
+	if err != nil {
+		return errors.Wrap(err, "Cannot create gRPC listener")
+	}
+
+	return c.server.Serve(lis)
+}
+
+// Stop stops the grpc server
+func (c *Client) Stop() {
+	c.server.GracefulStop()
+}
diff --git a/internal/grpc/image.go b/internal/grpc/image.go
new file mode 100644
index 00000000..e02158fa
--- /dev/null
+++ b/internal/grpc/image.go
@@ -0,0 +1,123 @@
+package grpc
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/containers/image/v5/docker/reference"
+	"github.com/crazy-max/diun/v4/pb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func (c *Client) ImageList(ctx context.Context, request *pb.ImageListRequest) (*pb.ImageListResponse, error) {
+	images, err := c.db.ListImage()
+	if err != nil {
+		return nil, err
+	}
+
+	var ilr []*pb.ImageListResponse_Image
+	for name, manifests := range images {
+		latest := &manifests[0]
+		for _, manifest := range manifests {
+			if manifest.Created.After(*latest.Created) {
+				latest = &manifest
+			}
+		}
+		ilr = append(ilr, &pb.ImageListResponse_Image{
+			Name:           name,
+			ManifestsCount: int64(len(manifests)),
+			Latest: &pb.Manifest{
+				Tag:      latest.Tag,
+				MimeType: latest.MIMEType,
+				Digest:   latest.Digest.String(),
+				Created:  timestamppb.New(*latest.Created),
+				Labels:   latest.Labels,
+				Platform: latest.Platform,
+			},
+		})
+	}
+
+	return &pb.ImageListResponse{
+		Images: ilr,
+	}, nil
+}
+
+func (c *Client) ImageInspect(ctx context.Context, request *pb.ImageInspectRequest) (*pb.ImageInspectResponse, error) {
+	ref, err := reference.ParseNormalizedNamed(request.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	images, err := c.db.ListImage()
+	if err != nil {
+		return nil, err
+	}
+
+	if _, ok := images[ref.Name()]; !ok {
+		return nil, fmt.Errorf("%s not found in database", ref.Name())
+	}
+
+	iir := &pb.ImageInspectResponse_Image{
+		Name:      ref.Name(),
+		Manifests: []*pb.Manifest{},
+	}
+	for _, manifest := range images[ref.Name()] {
+		iir.Manifests = append(iir.Manifests, &pb.Manifest{
+			Tag:      manifest.Tag,
+			MimeType: manifest.MIMEType,
+			Digest:   manifest.Digest.String(),
+			Created:  timestamppb.New(*manifest.Created),
+			Labels:   manifest.Labels,
+			Platform: manifest.Platform,
+		})
+	}
+
+	return &pb.ImageInspectResponse{
+		Image: iir,
+	}, nil
+}
+
+func (c *Client) ImageRemove(ctx context.Context, request *pb.ImageRemoveRequest) (*pb.ImageRemoveResponse, error) {
+	ref, err := reference.ParseNormalizedNamed(request.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	images, err := c.db.ListImage()
+	if err != nil {
+		return nil, err
+	}
+
+	if _, ok := images[ref.Name()]; !ok {
+		return nil, fmt.Errorf("%s not found in database", ref.Name())
+	}
+
+	var tag string
+	if tagged, ok := ref.(reference.Tagged); ok {
+		tag = tagged.Tag()
+	}
+
+	var removed []*pb.Manifest
+	for _, manifest := range images[ref.Name()] {
+		if len(tag) == 0 || manifest.Tag == tag {
+			if err = c.db.DeleteManifest(manifest); err != nil {
+				return nil, err
+			}
+			b, _ := json.Marshal(manifest)
+			removed = append(removed, &pb.Manifest{
+				Tag:      manifest.Tag,
+				MimeType: manifest.MIMEType,
+				Digest:   manifest.Digest.String(),
+				Created:  timestamppb.New(*manifest.Created),
+				Labels:   manifest.Labels,
+				Platform: manifest.Platform,
+				Size:     int64(len(b)),
+			})
+		}
+	}
+
+	return &pb.ImageRemoveResponse{
+		Manifests: removed,
+	}, nil
+}
diff --git a/internal/grpc/logger/logger.go b/internal/grpc/logger/logger.go
new file mode 100644
index 00000000..93a15649
--- /dev/null
+++ b/internal/grpc/logger/logger.go
@@ -0,0 +1,94 @@
+package logger
+
+import (
+	"fmt"
+
+	"github.com/rs/zerolog"
+	"google.golang.org/grpc/grpclog"
+)
+
+func SetGrpcLogger(logger zerolog.Logger) {
+	grpclog.SetLoggerV2(wrap(logger))
+}
+
+func wrap(l zerolog.Logger) *bridge {
+	return &bridge{l}
+}
+
+type bridge struct {
+	zerolog.Logger
+}
+
+func (b *bridge) Info(args ...interface{}) {
+	b.Logger.Info().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Infoln(args ...interface{}) {
+	b.Logger.Info().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Infof(format string, args ...interface{}) {
+	b.Logger.Info().Msgf(format, args...)
+}
+
+func (b *bridge) Warning(args ...interface{}) {
+	b.Logger.Warn().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Warningln(args ...interface{}) {
+	b.Logger.Warn().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Warningf(format string, args ...interface{}) {
+	b.Logger.Warn().Msgf(format, args...)
+}
+
+func (b *bridge) Error(args ...interface{}) {
+	b.Logger.Error().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Errorln(args ...interface{}) {
+	b.Logger.Error().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Errorf(format string, args ...interface{}) {
+	b.Logger.Error().Msgf(format, args...)
+}
+
+func (b *bridge) Fatal(args ...interface{}) {
+	b.Logger.Fatal().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Fatalln(args ...interface{}) {
+	b.Logger.Fatal().Msg(fmt.Sprint(args...))
+}
+
+func (b *bridge) Fatalf(format string, args ...interface{}) {
+	b.Logger.Fatal().Msgf(format, args...)
+}
+
+func (b *bridge) V(verbosity int) bool {
+	// verbosity values:
+	// 0 = info
+	// 1 = warning
+	// 2 = error
+	// 3 = fatal
+	switch b.GetLevel() {
+	case zerolog.PanicLevel:
+		return verbosity > 3
+	case zerolog.FatalLevel:
+		return verbosity == 3
+	case zerolog.ErrorLevel:
+		return verbosity == 2
+	case zerolog.WarnLevel:
+		return verbosity == 1
+	case zerolog.InfoLevel:
+		return verbosity == 0
+	case zerolog.DebugLevel:
+		return true
+	case zerolog.TraceLevel:
+		return true
+	default:
+		return false
+	}
+}
diff --git a/internal/grpc/notif.go b/internal/grpc/notif.go
new file mode 100644
index 00000000..cf0a3eb6
--- /dev/null
+++ b/internal/grpc/notif.go
@@ -0,0 +1,72 @@
+package grpc
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/crazy-max/diun/v4/internal/model"
+	"github.com/crazy-max/diun/v4/pb"
+	"github.com/crazy-max/diun/v4/pkg/registry"
+)
+
+func (c *Client) NotifTest(ctx context.Context, request *pb.NotifTestRequest) (*pb.NotifTestResponse, error) {
+	createdAt, _ := time.Parse("2006-01-02T15:04:05Z", "2020-03-26T12:23:56Z")
+	image, _ := registry.ParseImage(registry.ParseImageOptions{
+		Name: "diun/testnotif:latest",
+	})
+	image.HubLink = ""
+
+	entry := model.NotifEntry{
+		Status:   "new",
+		Provider: "file",
+		Image:    image,
+		Manifest: registry.Manifest{
+			Name:          "diun/testnotif",
+			Tag:           "latest",
+			MIMEType:      "application/vnd.docker.distribution.manifest.list.v2+json",
+			Digest:        "sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01",
+			Created:       &createdAt,
+			DockerVersion: "",
+			Labels: map[string]string{
+				"maintainer":                      "CrazyMax",
+				"org.label-schema.build-date":     "2020-03-26T12:23:56Z",
+				"org.label-schema.description":    "Docker image update notifier",
+				"org.label-schema.name":           "Diun",
+				"org.label-schema.schema-version": "1.0",
+				"org.label-schema.url":            "https://github.com/crazy-max/diun",
+				"org.label-schema.vcs-ref":        "e13f097c",
+				"org.label-schema.vcs-url":        "https://github.com/crazy-max/diun",
+				"org.label-schema.vendor":         "CrazyMax",
+				"org.label-schema.version":        "x.x.x",
+			},
+			Layers: []string{
+				"sha256:aad63a9339440e7c3e1fff2b988991b9bfb81280042fa7f39a5e327023056819",
+				"sha256:166c6f165b73185ede72415d780538a55c0c8e854bd177925bc007193e5b0d1b",
+				"sha256:e05682efa9cc9d6239b2b9252fe0dc1e58d6e1585679733bb94a6549d49e9b10",
+				"sha256:c6a5bfed445b3ed7e85523cd73c6532ac9f9b72bb588ca728fd5b33987ca6538",
+				"sha256:df2140efb8abeb727ef0b27ff158b7010a7941eb1cfdade505f510a6e1eaf016",
+			},
+			Platform: "linux/amd64",
+		},
+	}
+
+	if len(c.notif.List()) == 0 {
+		return &pb.NotifTestResponse{
+			Message: "No notifier available",
+		}, nil
+	}
+
+	var sent []string
+	for _, n := range c.notif.List() {
+		if err := n.Send(entry); err != nil {
+			return nil, err
+		}
+		sent = append(sent, n.Name())
+	}
+
+	return &pb.NotifTestResponse{
+		Message: fmt.Sprintf("Notifcation sent for %s notifier(s)", strings.Join(sent, ", ")),
+	}, nil
+}
diff --git a/internal/logging/logger.go b/internal/logging/logger.go
index ad91ede3..686e43e0 100644
--- a/internal/logging/logger.go
+++ b/internal/logging/logger.go
@@ -6,21 +6,30 @@ import (
 	"os"
 	"time"
 
-	"github.com/crazy-max/diun/v4/internal/model"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 	"github.com/sirupsen/logrus"
 )
 
+type Options struct {
+	LogLevel   string
+	LogJSON    bool
+	LogCaller  bool
+	LogNoColor bool
+}
+
 // Configure configures logger
-func Configure(cli *model.Cli) {
+func Configure(opts Options) {
 	var err error
 	var w io.Writer
 
-	if !cli.LogJSON {
+	// Adds support for NO_COLOR. More info https://no-color.org/
+	_, noColor := os.LookupEnv("NO_COLOR")
+
+	if !opts.LogJSON {
 		w = zerolog.ConsoleWriter{
 			Out:        os.Stdout,
-			NoColor:    cli.LogNoColor,
+			NoColor:    noColor || opts.LogNoColor,
 			TimeFormat: time.RFC1123,
 		}
 	} else {
@@ -28,20 +37,20 @@ func Configure(cli *model.Cli) {
 	}
 
 	ctx := zerolog.New(w).With().Timestamp()
-	if cli.LogCaller {
+	if opts.LogCaller {
 		ctx = ctx.Caller()
 	}
 
 	log.Logger = ctx.Logger()
 
-	logLevel, err := zerolog.ParseLevel(cli.LogLevel)
+	logLevel, err := zerolog.ParseLevel(opts.LogLevel)
 	if err != nil {
 		log.Fatal().Err(err).Msgf("Unknown log level")
 	} else {
 		zerolog.SetGlobalLevel(logLevel)
 	}
 
-	logrusLevel, err := logrus.ParseLevel(cli.LogLevel)
+	logrusLevel, err := logrus.ParseLevel(opts.LogLevel)
 	if err != nil {
 		log.Fatal().Err(err).Msgf("Unknown log level")
 	} else {
diff --git a/internal/model/cli.go b/internal/model/cli.go
deleted file mode 100644
index c0d32b34..00000000
--- a/internal/model/cli.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package model
-
-import "github.com/alecthomas/kong"
-
-// Cli holds command line args, flags and cmds
-type Cli struct {
-	Version      kong.VersionFlag
-	Cfgfile      string `kong:"name='config',env='CONFIG',help='Diun configuration file.'"`
-	ProfilerPath string `kong:"name='profiler-path',env='PROFILER_PATH',help='Base path where profiling files are written.'"`
-	Profiler     string `kong:"name='profiler',env='PROFILER',help='Profiler to use.'"`
-	LogLevel     string `kong:"name='log-level',env='LOG_LEVEL',default='info',help='Set log level.'"`
-	LogJSON      bool   `kong:"name='log-json',env='LOG_JSON',default='false',help='Enable JSON logging output.'"`
-	LogCaller    bool   `kong:"name='log-caller',env='LOG_CALLER',default='false',help='Add file:line of the caller to log output.'"`
-	LogNoColor   bool   `kong:"name='log-nocolor',env='LOG_NOCOLOR',default='false',help='Disables the colorized output.'"`
-	TestNotif    bool   `kong:"name='test-notif',default='false',help='Test notification settings.'"`
-}
diff --git a/internal/notif/client.go b/internal/notif/client.go
index 8a3fb271..534379e9 100644
--- a/internal/notif/client.go
+++ b/internal/notif/client.go
@@ -95,3 +95,8 @@ func (c *Client) Send(entry model.NotifEntry) {
 		}
 	}
 }
+
+// List returns created notifiers
+func (c *Client) List() []notifier.Notifier {
+	return c.notifiers
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index c3ac9f65..0347f580 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -72,6 +72,7 @@ plugins:
         - "_overrides/*"
   - git-revision-date-localized:
       type: iso_datetime
+      enable_creation_date: true
   - macros
   - search:
       prebuild_index: python
@@ -89,7 +90,7 @@ nav:
     - From binary: install/binary.md
     - Linux service: install/linux-service.md
   - Usage:
-    - Command line: usage/cli.md
+    - Command line: usage/command-line.md
     - Basic example: usage/basic-example.md
   - Configuration:
     - Overview: config/index.md
@@ -124,6 +125,7 @@ nav:
   - FAQ: faq.md
   - Changelog: changelog.md
   - Migration:
+    - Diun v4.0 to v4.17: migration/v4.0-to-v4.17.md
     - Diun v3 to v4: migration/v3-to-v4.md
     - Diun v2 to v3: migration/v2-to-v3.md
     - Diun v1 to v2: migration/v1-to-v2.md
diff --git a/pb/gen.go b/pb/gen.go
new file mode 100644
index 00000000..9fbf0ce7
--- /dev/null
+++ b/pb/gen.go
@@ -0,0 +1,3 @@
+package pb
+
+//go:generate protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. image.proto notif.proto
diff --git a/pb/image.pb.go b/pb/image.pb.go
new file mode 100644
index 00000000..bfd0adb5
--- /dev/null
+++ b/pb/image.pb.go
@@ -0,0 +1,764 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.26.0
+// 	protoc        v3.17.0
+// source: image.proto
+
+package pb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type Manifest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Tag      string                 `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
+	MimeType string                 `protobuf:"bytes,2,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"`
+	Digest   string                 `protobuf:"bytes,3,opt,name=digest,proto3" json:"digest,omitempty"`
+	Created  *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created,proto3" json:"created,omitempty"`
+	Labels   map[string]string      `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+	Platform string                 `protobuf:"bytes,6,opt,name=platform,proto3" json:"platform,omitempty"`
+	Size     int64                  `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"`
+}
+
+func (x *Manifest) Reset() {
+	*x = Manifest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Manifest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Manifest) ProtoMessage() {}
+
+func (x *Manifest) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Manifest.ProtoReflect.Descriptor instead.
+func (*Manifest) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Manifest) GetTag() string {
+	if x != nil {
+		return x.Tag
+	}
+	return ""
+}
+
+func (x *Manifest) GetMimeType() string {
+	if x != nil {
+		return x.MimeType
+	}
+	return ""
+}
+
+func (x *Manifest) GetDigest() string {
+	if x != nil {
+		return x.Digest
+	}
+	return ""
+}
+
+func (x *Manifest) GetCreated() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Created
+	}
+	return nil
+}
+
+func (x *Manifest) GetLabels() map[string]string {
+	if x != nil {
+		return x.Labels
+	}
+	return nil
+}
+
+func (x *Manifest) GetPlatform() string {
+	if x != nil {
+		return x.Platform
+	}
+	return ""
+}
+
+func (x *Manifest) GetSize() int64 {
+	if x != nil {
+		return x.Size
+	}
+	return 0
+}
+
+type ImageListRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *ImageListRequest) Reset() {
+	*x = ImageListRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageListRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageListRequest) ProtoMessage() {}
+
+func (x *ImageListRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageListRequest.ProtoReflect.Descriptor instead.
+func (*ImageListRequest) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{1}
+}
+
+type ImageListResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Images []*ImageListResponse_Image `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"`
+}
+
+func (x *ImageListResponse) Reset() {
+	*x = ImageListResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageListResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageListResponse) ProtoMessage() {}
+
+func (x *ImageListResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageListResponse.ProtoReflect.Descriptor instead.
+func (*ImageListResponse) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ImageListResponse) GetImages() []*ImageListResponse_Image {
+	if x != nil {
+		return x.Images
+	}
+	return nil
+}
+
+type ImageInspectRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *ImageInspectRequest) Reset() {
+	*x = ImageInspectRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageInspectRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageInspectRequest) ProtoMessage() {}
+
+func (x *ImageInspectRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageInspectRequest.ProtoReflect.Descriptor instead.
+func (*ImageInspectRequest) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ImageInspectRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+type ImageInspectResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Image *ImageInspectResponse_Image `protobuf:"bytes,1,opt,name=image,proto3" json:"image,omitempty"`
+}
+
+func (x *ImageInspectResponse) Reset() {
+	*x = ImageInspectResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageInspectResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageInspectResponse) ProtoMessage() {}
+
+func (x *ImageInspectResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageInspectResponse.ProtoReflect.Descriptor instead.
+func (*ImageInspectResponse) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ImageInspectResponse) GetImage() *ImageInspectResponse_Image {
+	if x != nil {
+		return x.Image
+	}
+	return nil
+}
+
+type ImageRemoveRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *ImageRemoveRequest) Reset() {
+	*x = ImageRemoveRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageRemoveRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageRemoveRequest) ProtoMessage() {}
+
+func (x *ImageRemoveRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageRemoveRequest.ProtoReflect.Descriptor instead.
+func (*ImageRemoveRequest) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ImageRemoveRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+type ImageRemoveResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Manifests []*Manifest `protobuf:"bytes,1,rep,name=manifests,proto3" json:"manifests,omitempty"`
+}
+
+func (x *ImageRemoveResponse) Reset() {
+	*x = ImageRemoveResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageRemoveResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageRemoveResponse) ProtoMessage() {}
+
+func (x *ImageRemoveResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageRemoveResponse.ProtoReflect.Descriptor instead.
+func (*ImageRemoveResponse) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ImageRemoveResponse) GetManifests() []*Manifest {
+	if x != nil {
+		return x.Manifests
+	}
+	return nil
+}
+
+type ImageListResponse_Image struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name           string    `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	ManifestsCount int64     `protobuf:"varint,2,opt,name=manifestsCount,proto3" json:"manifestsCount,omitempty"`
+	Latest         *Manifest `protobuf:"bytes,3,opt,name=latest,proto3" json:"latest,omitempty"`
+}
+
+func (x *ImageListResponse_Image) Reset() {
+	*x = ImageListResponse_Image{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageListResponse_Image) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageListResponse_Image) ProtoMessage() {}
+
+func (x *ImageListResponse_Image) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageListResponse_Image.ProtoReflect.Descriptor instead.
+func (*ImageListResponse_Image) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{2, 0}
+}
+
+func (x *ImageListResponse_Image) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ImageListResponse_Image) GetManifestsCount() int64 {
+	if x != nil {
+		return x.ManifestsCount
+	}
+	return 0
+}
+
+func (x *ImageListResponse_Image) GetLatest() *Manifest {
+	if x != nil {
+		return x.Latest
+	}
+	return nil
+}
+
+type ImageInspectResponse_Image struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name      string      `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	Manifests []*Manifest `protobuf:"bytes,2,rep,name=manifests,proto3" json:"manifests,omitempty"`
+}
+
+func (x *ImageInspectResponse_Image) Reset() {
+	*x = ImageInspectResponse_Image{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_image_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImageInspectResponse_Image) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageInspectResponse_Image) ProtoMessage() {}
+
+func (x *ImageInspectResponse_Image) ProtoReflect() protoreflect.Message {
+	mi := &file_image_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageInspectResponse_Image.ProtoReflect.Descriptor instead.
+func (*ImageInspectResponse_Image) Descriptor() ([]byte, []int) {
+	return file_image_proto_rawDescGZIP(), []int{4, 0}
+}
+
+func (x *ImageInspectResponse_Image) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ImageInspectResponse_Image) GetManifests() []*Manifest {
+	if x != nil {
+		return x.Manifests
+	}
+	return nil
+}
+
+var File_image_proto protoreflect.FileDescriptor
+
+var file_image_proto_rawDesc = []byte{
+	0x0a, 0x0b, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70,
+	0x62, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x22, 0xa4, 0x02, 0x0a, 0x08, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12,
+	0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61,
+	0x67, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16,
+	0x0a, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
+	0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
+	0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x06,
+	0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70,
+	0x62, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c,
+	0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1a,
+	0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69,
+	0x7a, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x1a, 0x39,
+	0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
+	0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
+	0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x12, 0x0a, 0x10, 0x49, 0x6d, 0x61,
+	0x67, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb3, 0x01,
+	0x0a, 0x11, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x06, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4c, 0x69,
+	0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65,
+	0x52, 0x06, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x69, 0x0a, 0x05, 0x49, 0x6d, 0x61, 0x67,
+	0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x0e, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73,
+	0x74, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6d,
+	0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x24, 0x0a,
+	0x06, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e,
+	0x70, 0x62, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x74,
+	0x65, 0x73, 0x74, 0x22, 0x29, 0x0a, 0x13, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x73, 0x70,
+	0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x95,
+	0x01, 0x0a, 0x14, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67,
+	0x65, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x1a, 0x47, 0x0a,
+	0x05, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2a, 0x0a, 0x09, 0x6d, 0x61,
+	0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e,
+	0x70, 0x62, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x09, 0x6d, 0x61, 0x6e,
+	0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x22, 0x28, 0x0a, 0x12, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52,
+	0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x22, 0x41, 0x0a, 0x13, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x09, 0x6d, 0x61, 0x6e, 0x69, 0x66,
+	0x65, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x2e,
+	0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x09, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65,
+	0x73, 0x74, 0x73, 0x32, 0xd1, 0x01, 0x0a, 0x0c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x53, 0x65, 0x72,
+	0x76, 0x69, 0x63, 0x65, 0x12, 0x3a, 0x0a, 0x09, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4c, 0x69, 0x73,
+	0x74, 0x12, 0x14, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4c, 0x69, 0x73, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61,
+	0x67, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x43, 0x0a, 0x0c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74,
+	0x12, 0x17, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x73, 0x70, 0x65,
+	0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x62, 0x2e, 0x49,
+	0x6d, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0b, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65,
+	0x6d, 0x6f, 0x76, 0x65, 0x12, 0x16, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52,
+	0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x70,
+	0x62, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x1e, 0x5a, 0x1c, 0x67, 0x69, 0x74, 0x68, 0x75,
+	0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x72, 0x61, 0x7a, 0x79, 0x2d, 0x6d, 0x61, 0x78, 0x2f,
+	0x64, 0x69, 0x75, 0x6e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_image_proto_rawDescOnce sync.Once
+	file_image_proto_rawDescData = file_image_proto_rawDesc
+)
+
+func file_image_proto_rawDescGZIP() []byte {
+	file_image_proto_rawDescOnce.Do(func() {
+		file_image_proto_rawDescData = protoimpl.X.CompressGZIP(file_image_proto_rawDescData)
+	})
+	return file_image_proto_rawDescData
+}
+
+var file_image_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_image_proto_goTypes = []interface{}{
+	(*Manifest)(nil),                   // 0: pb.Manifest
+	(*ImageListRequest)(nil),           // 1: pb.ImageListRequest
+	(*ImageListResponse)(nil),          // 2: pb.ImageListResponse
+	(*ImageInspectRequest)(nil),        // 3: pb.ImageInspectRequest
+	(*ImageInspectResponse)(nil),       // 4: pb.ImageInspectResponse
+	(*ImageRemoveRequest)(nil),         // 5: pb.ImageRemoveRequest
+	(*ImageRemoveResponse)(nil),        // 6: pb.ImageRemoveResponse
+	nil,                                // 7: pb.Manifest.LabelsEntry
+	(*ImageListResponse_Image)(nil),    // 8: pb.ImageListResponse.Image
+	(*ImageInspectResponse_Image)(nil), // 9: pb.ImageInspectResponse.Image
+	(*timestamppb.Timestamp)(nil),      // 10: google.protobuf.Timestamp
+}
+var file_image_proto_depIdxs = []int32{
+	10, // 0: pb.Manifest.created:type_name -> google.protobuf.Timestamp
+	7,  // 1: pb.Manifest.labels:type_name -> pb.Manifest.LabelsEntry
+	8,  // 2: pb.ImageListResponse.images:type_name -> pb.ImageListResponse.Image
+	9,  // 3: pb.ImageInspectResponse.image:type_name -> pb.ImageInspectResponse.Image
+	0,  // 4: pb.ImageRemoveResponse.manifests:type_name -> pb.Manifest
+	0,  // 5: pb.ImageListResponse.Image.latest:type_name -> pb.Manifest
+	0,  // 6: pb.ImageInspectResponse.Image.manifests:type_name -> pb.Manifest
+	1,  // 7: pb.ImageService.ImageList:input_type -> pb.ImageListRequest
+	3,  // 8: pb.ImageService.ImageInspect:input_type -> pb.ImageInspectRequest
+	5,  // 9: pb.ImageService.ImageRemove:input_type -> pb.ImageRemoveRequest
+	2,  // 10: pb.ImageService.ImageList:output_type -> pb.ImageListResponse
+	4,  // 11: pb.ImageService.ImageInspect:output_type -> pb.ImageInspectResponse
+	6,  // 12: pb.ImageService.ImageRemove:output_type -> pb.ImageRemoveResponse
+	10, // [10:13] is the sub-list for method output_type
+	7,  // [7:10] is the sub-list for method input_type
+	7,  // [7:7] is the sub-list for extension type_name
+	7,  // [7:7] is the sub-list for extension extendee
+	0,  // [0:7] is the sub-list for field type_name
+}
+
+func init() { file_image_proto_init() }
+func file_image_proto_init() {
+	if File_image_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_image_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Manifest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageListRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageListResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageInspectRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageInspectResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageRemoveRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageRemoveResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageListResponse_Image); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_image_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImageInspectResponse_Image); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_image_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   10,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_image_proto_goTypes,
+		DependencyIndexes: file_image_proto_depIdxs,
+		MessageInfos:      file_image_proto_msgTypes,
+	}.Build()
+	File_image_proto = out.File
+	file_image_proto_rawDesc = nil
+	file_image_proto_goTypes = nil
+	file_image_proto_depIdxs = nil
+}
diff --git a/pb/image.proto b/pb/image.proto
new file mode 100644
index 00000000..9871297a
--- /dev/null
+++ b/pb/image.proto
@@ -0,0 +1,53 @@
+syntax = "proto3";
+option go_package = "github.com/crazy-max/diun/pb";
+
+package pb;
+
+import "google/protobuf/timestamp.proto";
+
+message Manifest {
+  string tag = 1;
+  string mime_type = 2;
+  string digest = 3;
+  google.protobuf.Timestamp created = 4;
+  map<string, string> labels = 5;
+  string platform = 6;
+  int64 size = 7;
+}
+
+message ImageListRequest {}
+
+message ImageListResponse {
+  message Image {
+    string name = 1;
+    int64 manifestsCount = 2;
+    Manifest latest = 3;
+  }
+  repeated Image images = 1;
+}
+
+message ImageInspectRequest {
+  string name = 1;
+}
+
+message ImageInspectResponse {
+  message Image {
+    string name = 1;
+    repeated Manifest manifests = 2;
+  }
+  Image image = 1;
+}
+
+message ImageRemoveRequest {
+  string name = 1;
+}
+
+message ImageRemoveResponse {
+  repeated Manifest manifests = 1;
+}
+
+service ImageService {
+  rpc ImageList(ImageListRequest) returns (ImageListResponse) {}
+  rpc ImageInspect(ImageInspectRequest) returns (ImageInspectResponse) {}
+  rpc ImageRemove(ImageRemoveRequest) returns (ImageRemoveResponse) {}
+}
diff --git a/pb/image_grpc.pb.go b/pb/image_grpc.pb.go
new file mode 100644
index 00000000..d2028311
--- /dev/null
+++ b/pb/image_grpc.pb.go
@@ -0,0 +1,173 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+
+package pb
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// ImageServiceClient is the client API for ImageService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type ImageServiceClient interface {
+	ImageList(ctx context.Context, in *ImageListRequest, opts ...grpc.CallOption) (*ImageListResponse, error)
+	ImageInspect(ctx context.Context, in *ImageInspectRequest, opts ...grpc.CallOption) (*ImageInspectResponse, error)
+	ImageRemove(ctx context.Context, in *ImageRemoveRequest, opts ...grpc.CallOption) (*ImageRemoveResponse, error)
+}
+
+type imageServiceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewImageServiceClient(cc grpc.ClientConnInterface) ImageServiceClient {
+	return &imageServiceClient{cc}
+}
+
+func (c *imageServiceClient) ImageList(ctx context.Context, in *ImageListRequest, opts ...grpc.CallOption) (*ImageListResponse, error) {
+	out := new(ImageListResponse)
+	err := c.cc.Invoke(ctx, "/pb.ImageService/ImageList", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *imageServiceClient) ImageInspect(ctx context.Context, in *ImageInspectRequest, opts ...grpc.CallOption) (*ImageInspectResponse, error) {
+	out := new(ImageInspectResponse)
+	err := c.cc.Invoke(ctx, "/pb.ImageService/ImageInspect", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *imageServiceClient) ImageRemove(ctx context.Context, in *ImageRemoveRequest, opts ...grpc.CallOption) (*ImageRemoveResponse, error) {
+	out := new(ImageRemoveResponse)
+	err := c.cc.Invoke(ctx, "/pb.ImageService/ImageRemove", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ImageServiceServer is the server API for ImageService service.
+// All implementations must embed UnimplementedImageServiceServer
+// for forward compatibility
+type ImageServiceServer interface {
+	ImageList(context.Context, *ImageListRequest) (*ImageListResponse, error)
+	ImageInspect(context.Context, *ImageInspectRequest) (*ImageInspectResponse, error)
+	ImageRemove(context.Context, *ImageRemoveRequest) (*ImageRemoveResponse, error)
+	mustEmbedUnimplementedImageServiceServer()
+}
+
+// UnimplementedImageServiceServer must be embedded to have forward compatible implementations.
+type UnimplementedImageServiceServer struct {
+}
+
+func (UnimplementedImageServiceServer) ImageList(context.Context, *ImageListRequest) (*ImageListResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ImageList not implemented")
+}
+func (UnimplementedImageServiceServer) ImageInspect(context.Context, *ImageInspectRequest) (*ImageInspectResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ImageInspect not implemented")
+}
+func (UnimplementedImageServiceServer) ImageRemove(context.Context, *ImageRemoveRequest) (*ImageRemoveResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ImageRemove not implemented")
+}
+func (UnimplementedImageServiceServer) mustEmbedUnimplementedImageServiceServer() {}
+
+// UnsafeImageServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to ImageServiceServer will
+// result in compilation errors.
+type UnsafeImageServiceServer interface {
+	mustEmbedUnimplementedImageServiceServer()
+}
+
+func RegisterImageServiceServer(s grpc.ServiceRegistrar, srv ImageServiceServer) {
+	s.RegisterService(&ImageService_ServiceDesc, srv)
+}
+
+func _ImageService_ImageList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ImageListRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ImageServiceServer).ImageList(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/pb.ImageService/ImageList",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ImageServiceServer).ImageList(ctx, req.(*ImageListRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _ImageService_ImageInspect_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ImageInspectRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ImageServiceServer).ImageInspect(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/pb.ImageService/ImageInspect",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ImageServiceServer).ImageInspect(ctx, req.(*ImageInspectRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _ImageService_ImageRemove_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ImageRemoveRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ImageServiceServer).ImageRemove(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/pb.ImageService/ImageRemove",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ImageServiceServer).ImageRemove(ctx, req.(*ImageRemoveRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// ImageService_ServiceDesc is the grpc.ServiceDesc for ImageService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var ImageService_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "pb.ImageService",
+	HandlerType: (*ImageServiceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "ImageList",
+			Handler:    _ImageService_ImageList_Handler,
+		},
+		{
+			MethodName: "ImageInspect",
+			Handler:    _ImageService_ImageInspect_Handler,
+		},
+		{
+			MethodName: "ImageRemove",
+			Handler:    _ImageService_ImageRemove_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "image.proto",
+}
diff --git a/pb/notif.pb.go b/pb/notif.pb.go
new file mode 100644
index 00000000..636f6190
--- /dev/null
+++ b/pb/notif.pb.go
@@ -0,0 +1,202 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.26.0
+// 	protoc        v3.17.0
+// source: notif.proto
+
+package pb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type NotifTestRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *NotifTestRequest) Reset() {
+	*x = NotifTestRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_notif_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *NotifTestRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*NotifTestRequest) ProtoMessage() {}
+
+func (x *NotifTestRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_notif_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use NotifTestRequest.ProtoReflect.Descriptor instead.
+func (*NotifTestRequest) Descriptor() ([]byte, []int) {
+	return file_notif_proto_rawDescGZIP(), []int{0}
+}
+
+type NotifTestResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *NotifTestResponse) Reset() {
+	*x = NotifTestResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_notif_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *NotifTestResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*NotifTestResponse) ProtoMessage() {}
+
+func (x *NotifTestResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_notif_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use NotifTestResponse.ProtoReflect.Descriptor instead.
+func (*NotifTestResponse) Descriptor() ([]byte, []int) {
+	return file_notif_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *NotifTestResponse) GetMessage() string {
+	if x != nil {
+		return x.Message
+	}
+	return ""
+}
+
+var File_notif_proto protoreflect.FileDescriptor
+
+var file_notif_proto_rawDesc = []byte{
+	0x0a, 0x0b, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70,
+	0x62, 0x22, 0x12, 0x0a, 0x10, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x2d, 0x0a, 0x11, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x54, 0x65,
+	0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65,
+	0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73,
+	0x73, 0x61, 0x67, 0x65, 0x32, 0x4a, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x53, 0x65, 0x72,
+	0x76, 0x69, 0x63, 0x65, 0x12, 0x3a, 0x0a, 0x09, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x54, 0x65, 0x73,
+	0x74, 0x12, 0x14, 0x2e, 0x70, 0x62, 0x2e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x54, 0x65, 0x73, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x62, 0x2e, 0x4e, 0x6f, 0x74,
+	0x69, 0x66, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x42, 0x1e, 0x5a, 0x1c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63,
+	0x72, 0x61, 0x7a, 0x79, 0x2d, 0x6d, 0x61, 0x78, 0x2f, 0x64, 0x69, 0x75, 0x6e, 0x2f, 0x70, 0x62,
+	0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_notif_proto_rawDescOnce sync.Once
+	file_notif_proto_rawDescData = file_notif_proto_rawDesc
+)
+
+func file_notif_proto_rawDescGZIP() []byte {
+	file_notif_proto_rawDescOnce.Do(func() {
+		file_notif_proto_rawDescData = protoimpl.X.CompressGZIP(file_notif_proto_rawDescData)
+	})
+	return file_notif_proto_rawDescData
+}
+
+var file_notif_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_notif_proto_goTypes = []interface{}{
+	(*NotifTestRequest)(nil),  // 0: pb.NotifTestRequest
+	(*NotifTestResponse)(nil), // 1: pb.NotifTestResponse
+}
+var file_notif_proto_depIdxs = []int32{
+	0, // 0: pb.NotifService.NotifTest:input_type -> pb.NotifTestRequest
+	1, // 1: pb.NotifService.NotifTest:output_type -> pb.NotifTestResponse
+	1, // [1:2] is the sub-list for method output_type
+	0, // [0:1] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_notif_proto_init() }
+func file_notif_proto_init() {
+	if File_notif_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_notif_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*NotifTestRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_notif_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*NotifTestResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_notif_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_notif_proto_goTypes,
+		DependencyIndexes: file_notif_proto_depIdxs,
+		MessageInfos:      file_notif_proto_msgTypes,
+	}.Build()
+	File_notif_proto = out.File
+	file_notif_proto_rawDesc = nil
+	file_notif_proto_goTypes = nil
+	file_notif_proto_depIdxs = nil
+}
diff --git a/pb/notif.proto b/pb/notif.proto
new file mode 100644
index 00000000..45cf46f4
--- /dev/null
+++ b/pb/notif.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+option go_package = "github.com/crazy-max/diun/pb";
+
+package pb;
+
+message NotifTestRequest {}
+
+message NotifTestResponse {
+  string message = 1;
+}
+
+service NotifService {
+  rpc NotifTest(NotifTestRequest) returns (NotifTestResponse) {}
+}
diff --git a/pb/notif_grpc.pb.go b/pb/notif_grpc.pb.go
new file mode 100644
index 00000000..3a6f1cbc
--- /dev/null
+++ b/pb/notif_grpc.pb.go
@@ -0,0 +1,101 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+
+package pb
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// NotifServiceClient is the client API for NotifService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type NotifServiceClient interface {
+	NotifTest(ctx context.Context, in *NotifTestRequest, opts ...grpc.CallOption) (*NotifTestResponse, error)
+}
+
+type notifServiceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewNotifServiceClient(cc grpc.ClientConnInterface) NotifServiceClient {
+	return &notifServiceClient{cc}
+}
+
+func (c *notifServiceClient) NotifTest(ctx context.Context, in *NotifTestRequest, opts ...grpc.CallOption) (*NotifTestResponse, error) {
+	out := new(NotifTestResponse)
+	err := c.cc.Invoke(ctx, "/pb.NotifService/NotifTest", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// NotifServiceServer is the server API for NotifService service.
+// All implementations must embed UnimplementedNotifServiceServer
+// for forward compatibility
+type NotifServiceServer interface {
+	NotifTest(context.Context, *NotifTestRequest) (*NotifTestResponse, error)
+	mustEmbedUnimplementedNotifServiceServer()
+}
+
+// UnimplementedNotifServiceServer must be embedded to have forward compatible implementations.
+type UnimplementedNotifServiceServer struct {
+}
+
+func (UnimplementedNotifServiceServer) NotifTest(context.Context, *NotifTestRequest) (*NotifTestResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method NotifTest not implemented")
+}
+func (UnimplementedNotifServiceServer) mustEmbedUnimplementedNotifServiceServer() {}
+
+// UnsafeNotifServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to NotifServiceServer will
+// result in compilation errors.
+type UnsafeNotifServiceServer interface {
+	mustEmbedUnimplementedNotifServiceServer()
+}
+
+func RegisterNotifServiceServer(s grpc.ServiceRegistrar, srv NotifServiceServer) {
+	s.RegisterService(&NotifService_ServiceDesc, srv)
+}
+
+func _NotifService_NotifTest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(NotifTestRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NotifServiceServer).NotifTest(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/pb.NotifService/NotifTest",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NotifServiceServer).NotifTest(ctx, req.(*NotifTestRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// NotifService_ServiceDesc is the grpc.ServiceDesc for NotifService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var NotifService_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "pb.NotifService",
+	HandlerType: (*NotifServiceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "NotifTest",
+			Handler:    _NotifService_NotifTest_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "notif.proto",
+}
diff --git a/tools.go b/tools.go
new file mode 100644
index 00000000..07d59d1d
--- /dev/null
+++ b/tools.go
@@ -0,0 +1,8 @@
+// +build tools
+
+package tools
+
+import (
+	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
+	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
+)


Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 365b37ae..3314ea29 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -18,55 +18,33 @@ import (
 func TestLoadFile(t *testing.T) {
 	cases := []struct {
 		name     string
-		cli      model.Cli
+		cfg      string
 		wantData *config.Config
 		wantErr  bool
 	}{
 		{
-			name: "Failed on non-existing file",
-			cli: model.Cli{
-				TestNotif: false,
-			},
+			name:    "Failed on non-existing file",
+			cfg:     "",
 			wantErr: true,
 		},
 		{
-			name: "Fail on wrong file format",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.invalid.yml",
-				TestNotif: false,
-			},
+			name:    "Fail on wrong file format",
+			cfg:     "./fixtures/config.invalid.yml",
 			wantErr: true,
 		},
 		{
-			name: "Fail on no UUID for Healthchecks",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.err.hc.yml",
-				TestNotif: false,
-			},
+			name:    "Fail on no UUID for Healthchecks",
+			cfg:     "./fixtures/config.err.hc.yml",
 			wantErr: true,
 		},
 		{
-			name: "Fail on no notifier if test notif",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.err.notif.yml",
-				TestNotif: true,
-			},
-			wantErr: true,
-		},
-		{
-			name: "Fail on no provider",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.err.provider.yml",
-				TestNotif: false,
-			},
+			name:    "Fail on no provider",
+			cfg:     "./fixtures/config.err.provider.yml",
 			wantErr: true,
 		},
 		{
 			name: "Success",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.test.yml",
-				TestNotif: false,
-			},
+			cfg:  "./fixtures/config.test.yml",
 			wantData: &config.Config{
 				Db: &model.Db{
 					Path: "diun.db",
@@ -218,7 +196,7 @@ func TestLoadFile(t *testing.T) {
 	}
 	for _, tt := range cases {
 		t.Run(tt.name, func(t *testing.T) {
-			cfg, err := config.Load(tt.cli)
+			cfg, err := config.Load(tt.cfg)
 			if tt.wantErr {
 				require.Error(t, err)
 				return
@@ -237,7 +215,7 @@ func TestLoadEnv(t *testing.T) {
 
 	testCases := []struct {
 		desc     string
-		cli      model.Cli
+		cfg      string
 		environ  []string
 		expected interface{}
 		wantErr  bool
@@ -365,7 +343,7 @@ func TestLoadEnv(t *testing.T) {
 				}
 			}
 
-			cfg, err := config.Load(tt.cli)
+			cfg, err := config.Load(tt.cfg)
 			if tt.wantErr {
 				require.Error(t, err)
 				return
@@ -382,17 +360,14 @@ func TestLoadMixed(t *testing.T) {
 
 	testCases := []struct {
 		desc     string
-		cli      model.Cli
+		cfg      string
 		environ  []string
 		expected interface{}
 		wantErr  bool
 	}{
 		{
 			desc: "env vars and invalid file",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.invalid.yml",
-				TestNotif: false,
-			},
+			cfg:  "./fixtures/config.invalid.yml",
 			environ: []string{
 				"DIUN_PROVIDERS_DOCKER=true",
 			},
@@ -401,10 +376,7 @@ func TestLoadMixed(t *testing.T) {
 		},
 		{
 			desc: "docker provider (file) and notif mails (envs)",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.docker.yml",
-				TestNotif: false,
-			},
+			cfg:  "./fixtures/config.docker.yml",
 			environ: []string{
 				"DIUN_NOTIF_MAIL_HOST=127.0.0.1",
 				"DIUN_NOTIF_MAIL_PORT=25",
@@ -441,10 +413,7 @@ func TestLoadMixed(t *testing.T) {
 		},
 		{
 			desc: "file provider and notif webhook env override",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.file.yml",
-				TestNotif: false,
-			},
+			cfg:  "./fixtures/config.file.yml",
 			environ: []string{
 				"DIUN_NOTIF_WEBHOOK_ENDPOINT=http://webhook.foo.com/sd54qad89azd5a",
 				"DIUN_NOTIF_WEBHOOK_HEADERS_AUTHORIZATION=Token78910",
@@ -488,7 +457,7 @@ func TestLoadMixed(t *testing.T) {
 				}
 			}
 
-			cfg, err := config.Load(tt.cli)
+			cfg, err := config.Load(tt.cfg)
 			if tt.wantErr {
 				require.Error(t, err)
 				return
@@ -503,19 +472,16 @@ func TestLoadMixed(t *testing.T) {
 func TestValidation(t *testing.T) {
 	cases := []struct {
 		name string
-		cli  model.Cli
+		cfg  string
 	}{
 		{
 			name: "Success",
-			cli: model.Cli{
-				Cfgfile:   "./fixtures/config.validate.yml",
-				TestNotif: false,
-			},
+			cfg:  "./fixtures/config.validate.yml",
 		},
 	}
 	for _, tt := range cases {
 		t.Run(tt.name, func(t *testing.T) {
-			cfg, err := config.Load(tt.cli)
+			cfg, err := config.Load(tt.cfg)
 			require.NoError(t, err)
 
 			dec, err := env.Encode("DIUN_", cfg)