From ce1b4e8534903422f2fa1a2e953d8cfe5706b715 Mon Sep 17 00:00:00 2001
From: Neil-Jocelyn Schark <neil.schark@h-da.de>
Date: Mon, 14 Oct 2024 10:02:16 +0000
Subject: [PATCH] Create basic inventory manager

See merge request danet/gosdn!1061
---
 .devcontainer/devcontainer.json               |  17 +--
 .gitlab/ci/.build-binaries.yml                |   2 +
 .gitlab/ci/.build-container-images.yml        |   9 ++
 Makefile                                      |   4 +-
 README.md                                     |  35 +++++
 applications/inventory-manager/README.md      |  35 +++++
 .../inventory-manager/config/config.go        |  20 +++
 applications/inventory-manager/example.yml    |  34 +++++
 .../inventory-manager.Dockerfile              |  16 +++
 .../inventory-manager.Dockerfile.dockerignore |  20 +++
 .../inventoryManager/inventoryManager.go      | 133 ++++++++++++++++++
 .../inventoryManager/util.go                  |  35 +++++
 applications/inventory-manager/main.go        |  68 +++++++++
 makefiles/build/Makefile                      |   3 +
 makefiles/container/Makefile                  |   3 +
 15 files changed, 424 insertions(+), 10 deletions(-)
 create mode 100644 applications/inventory-manager/README.md
 create mode 100644 applications/inventory-manager/config/config.go
 create mode 100644 applications/inventory-manager/example.yml
 create mode 100644 applications/inventory-manager/inventory-manager.Dockerfile
 create mode 100644 applications/inventory-manager/inventory-manager.Dockerfile.dockerignore
 create mode 100644 applications/inventory-manager/inventoryManager/inventoryManager.go
 create mode 100644 applications/inventory-manager/inventoryManager/util.go
 create mode 100644 applications/inventory-manager/main.go

diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 631ef5164..b8c1190a6 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -23,14 +23,15 @@
         "customizations": {
             "vscode": {
                 "extensions": [
-					"ms-azuretools.vscode-docker",
-					"golang.go",
-					"EditorConfig.EditorConfig",
-					"eamodio.gitlens",
-					"ms-vscode.makefile-tools",
-					"redhat.vscode-yaml",
-					"valentjn.vscode-ltex"
-				]
+                    "ms-azuretools.vscode-docker",
+                    "golang.go",
+                    "EditorConfig.EditorConfig",
+                    "eamodio.gitlens",
+                    "ms-vscode.makefile-tools",
+                    "redhat.vscode-yaml",
+                    "valentjn.vscode-ltex",
+                    "zxh404.vscode-proto3"
+                ]
             }
         },
         "mounts": [
diff --git a/.gitlab/ci/.build-binaries.yml b/.gitlab/ci/.build-binaries.yml
index 25d738ff4..174203fdc 100644
--- a/.gitlab/ci/.build-binaries.yml
+++ b/.gitlab/ci/.build-binaries.yml
@@ -14,5 +14,7 @@ build-all-binaries:
           - artifacts/gosdnc
           - artifacts/orchestrator
           - artifacts/venv-manager
+          - artifacts/inventory-manager
+          - artifacts/plugin-registry
         expire_in: 1 week
     <<: *build-binaries
diff --git a/.gitlab/ci/.build-container-images.yml b/.gitlab/ci/.build-container-images.yml
index 7e23a6637..da70dcba2 100644
--- a/.gitlab/ci/.build-container-images.yml
+++ b/.gitlab/ci/.build-container-images.yml
@@ -66,6 +66,15 @@ build-plugin-registry-image:
         - docker push "$PLUGIN_REGISTRY_IMAGE_NAME:$CI_COMMIT_REF_SLUG"
     <<: *build
 
+build-inventory-manager-image:
+    script:
+        - INVENTORY_MANAGER_IMAGE_NAME="${CI_REGISTRY_IMAGE}/inventory-manager"
+        - docker buildx build -t "$INVENTORY_MANAGER_IMAGE_NAME:$CI_COMMIT_SHA" -f "${CI_PROJECT_DIR}/applications/inventory-manager/inventory-manager.Dockerfile" --build-arg "GOLANG_VERSION=$GOLANG_VERSION" --build-arg "GITLAB_PROXY=${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/" .
+        - docker push "$INVENTORY_MANAGER_IMAGE_NAME:$CI_COMMIT_SHA"
+        - docker tag "$INVENTORY_MANAGER_IMAGE_NAME:$CI_COMMIT_SHA" "$INVENTORY_MANAGER_IMAGE_NAME:$CI_COMMIT_REF_SLUG"
+        - docker push "$INVENTORY_MANAGER_IMAGE_NAME:$CI_COMMIT_REF_SLUG"
+    <<: *build
+
 build-integration-test-images:
     needs: ["build-controller-image"]
     script:
diff --git a/Makefile b/Makefile
index 6fb681e59..083a9087d 100644
--- a/Makefile
+++ b/Makefile
@@ -58,9 +58,9 @@ lint: install-tools
 lint-fix: install-tools
 	./$(TOOLS_DIR)/golangci-lint run --config .golangci.yml --fix
 
-build: pre build-gosdn build-gosdnc build-plugin-registry build-venv-manager build-arista-routing-engine-app build-hostname-checker-app build-basic-interface-monitoring-app
+build: pre build-gosdn build-gosdnc build-plugin-registry build-venv-manager build-arista-routing-engine-app build-hostname-checker-app build-basic-interface-monitoring-app build-inventory-manager
 
-containerize-all: containerize-gosdn containerize-gosdnc containerize-plugin-registry
+containerize-all: containerize-gosdn containerize-gosdnc containerize-plugin-registry containerize-venv-manager containerize-arista-routing-engine-app containerize-inventory-manager
 
 generate-all-certs: pre generate-root-ca generate-gosdn-certs generate-gnmi-target-certs
 
diff --git a/README.md b/README.md
index 907eafaa5..e8e273c2e 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,40 @@ operations for managed network elements.
 
 In addition, we provide a simple Northbound-API (gRPC) for the controller [right here](https://code.fbi.h-da.de/danet/gosdn/-/tree/master/controller/api).
 
+To use the API, you can build a login method as is done in the `inventory-manager` in `utils.go`.
+You log in, create a session context with the returned token, then you can simply make API calls with this context.
+Example:
+
+In your `utils.go`
+```golang
+// Login logs in to the controller.
+func Login(conn *grpc.ClientConn, address, user, pw string) (string, error) {
+	loginResp, err := api.Login(context.Background(), address, user, pw)
+	if err != nil {
+		return "", err
+	}
+
+	return loginResp.GetToken(), nil
+}
+
+// createContextWithAuthorization creates a context with the token received after login.
+func createContextWithAuthorization(sessionToken string) context.Context {
+	md := metadata.Pairs("authorize", sessionToken)
+	return metadata.NewOutgoingContext(context.Background(), md)
+}
+```
+
+The code in your app:
+```golang
+import "code.fbi.h-da.de/danet/gosdn/controller/api"
+
+sessionToken, err := Login(conn, config.ControllerAddress, config.UserName, config.UserPW)
+ctx := createContextWithAuthorization(sessionToken)
+
+_, err := api.AddNetworkElement(i.sessionContext, i.controllerAddress, networkElement.Name, networkElement.UUID, &transportOptions, pluginUUID, pndUUID, []string{})
+```
+
+
 The gRPC services can also be reached using HTTP requests via the gRPC-Gateway. The fitting OpenAPI definitions can be found [here](https://code.fbi.h-da.de/danet/gosdn/-/tree/master/api/openapiv2?ref_type=heads). Note, that this is experimental and tested less well. If you want to use the controller in secure mode which implies it's mandatory to login and provide the received token in other requests via the HTTP header with the key-value pair:
 `"authorize: token"`.
 
@@ -335,6 +369,7 @@ The framework provides some basic code to easily set up the subscription to the
 
 Examples where the application framework is used can be found [here](https://code.fbi.h-da.de/danet/gosdn/-/tree/master/applications).
 
+
 ## Integration tests
 
 The integration tests are currently in its own folder named `integration-tests`, as they use a complete black box design.
diff --git a/applications/inventory-manager/README.md b/applications/inventory-manager/README.md
new file mode 100644
index 000000000..16a7bdc2c
--- /dev/null
+++ b/applications/inventory-manager/README.md
@@ -0,0 +1,35 @@
+# Inventory Management App
+
+## Overview
+The Inventory Management App is designed to manage connected devices of an SDN (Software-Defined Networking) controller. It provides functionalities to add, update, delete, and view devices.
+
+## Features
+- Add new devices
+- Update existing devices
+- Delete devices
+
+
+## Config file structure:
+
+```yaml
+ControllerAddress: "127.0.0.1:55055"
+PndID: "5f20f34b-cbd0-4511-9ddc-c50cf6a3b49d"
+UserName: "testuser"
+UserPW: "securepassword"
+AppName: "Inventory-Manager"
+NetworkElements:
+  - Name: "Device01"
+    UUID: "5e41c291-6121-4335-84f6-41e04b8bdaa2"
+    Address: "10.0.0.2:1337"
+    Username: "admin"
+    Password: "adminpassword"
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
+    Tls: false
+  - Name: "Device02"
+    UUID: "5e41c291-6121-4335-84f6-41e04b8bdaa1"
+    Address: "hostname:7030"
+    Username: "user"
+    Password: "userpassword"
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
+    Tls: true
+```
diff --git a/applications/inventory-manager/config/config.go b/applications/inventory-manager/config/config.go
new file mode 100644
index 000000000..4a5a1d5ca
--- /dev/null
+++ b/applications/inventory-manager/config/config.go
@@ -0,0 +1,20 @@
+package config
+
+type Config struct {
+	ControllerAddress string           `yaml:"ControllerAddress"`
+	PndID             string           `yaml:"PndID"`
+	UserName          string           `yaml:"UserName"`
+	UserPW            string           `yaml:"UserPW"`
+	AppName           string           `yaml:"AppName"`
+	NetworkElements   []NetworkElement `yaml:"NetworkElements"`
+}
+
+type NetworkElement struct {
+	Name     string `yaml:"Name"`     // e.g. "Device01"
+	UUID     string `yaml:"UUID"`     // e.g. "0d24fbcf-67db-4aac-bf0f-8902b014a7ed"
+	Address  string `yaml:"Address"`  // e.g. "10.0.0.2:1337" or "hostname:7030"
+	Username string `yaml:"Username"` // e.g. "admin"
+	Password string `yaml:"Password"` // e.g. "adminpassword"
+	Tls      bool   `yaml:"Tls"`      // e.g. false
+	PluginID string `yaml:"PluginID"` // UUID e.g. "823aad29-69be-42f0-b279-90f2c1b6a94d"
+}
diff --git a/applications/inventory-manager/example.yml b/applications/inventory-manager/example.yml
new file mode 100644
index 000000000..708298403
--- /dev/null
+++ b/applications/inventory-manager/example.yml
@@ -0,0 +1,34 @@
+ControllerAddress: "127.0.0.1:55055"
+PndID: "5f20f34b-cbd0-4511-9ddc-c50cf6a3b49d"
+UserName: "admin"
+UserPW: "TestPassword"
+AppName: "Inventory-Manager"
+NetworkElements:
+  - Name: "kms01"
+    UUID: "0ff33c82-7fe1-482b-a0ca-67565806ee4b"
+    Address: "kms01:7030"
+    Username: "admin"
+    Password: "admin"
+    Tls : false
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
+  - Name: "kms02"
+    UUID: "5e41c291-6121-4335-84f6-41e04b8bdaa2"
+    Address: "kms02:7030"
+    Username: "admin"
+    Password: "admin"
+    Tls : false
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
+  - Name: "kms03"
+    UUID: "f80db2c0-2480-46b9-b7d1-b63f954e8227"
+    Address: "kms03:7030"
+    Username: "admin"
+    Password: "admin"
+    Tls : false
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
+  - Name: "kms04"
+    UUID: "968fd594-b0e7-41f0-ba4b-de259047a933"
+    Address: "kms04:7030"
+    Username: "admin"
+    Password: "admin"
+    Tls : false
+    PluginID: "823aad29-69be-42f0-b279-90f2c1b6a94d"
diff --git a/applications/inventory-manager/inventory-manager.Dockerfile b/applications/inventory-manager/inventory-manager.Dockerfile
new file mode 100644
index 000000000..92bc8b6f9
--- /dev/null
+++ b/applications/inventory-manager/inventory-manager.Dockerfile
@@ -0,0 +1,16 @@
+ARG GOLANG_VERSION=1.23
+ARG BUILDARGS
+ARG GITLAB_PROXY
+
+FROM ${GITLAB_PROXY}golang:$GOLANG_VERSION-bookworm AS builder
+
+WORKDIR /gosdn
+
+COPY . .
+
+RUN make build-inventory-manager
+
+FROM ${GITLAB_PROXY}debian:12
+COPY --from=builder /gosdn/artifacts/inventory-manager /inventory-manager
+
+ENTRYPOINT ["/inventory-manager"]
diff --git a/applications/inventory-manager/inventory-manager.Dockerfile.dockerignore b/applications/inventory-manager/inventory-manager.Dockerfile.dockerignore
new file mode 100644
index 000000000..af624c773
--- /dev/null
+++ b/applications/inventory-manager/inventory-manager.Dockerfile.dockerignore
@@ -0,0 +1,20 @@
+.git
+.gitlab
+build
+documentation
+mocks
+test
+clab-gosdn_csbi_arista_base
+clab-gosdn_sts_demo_basic
+.cobra.yaml
+.dockerignore
+.gitlab-ci.yaml
+ARCHITECTURE.md
+CONTRIBUTING.md
+README.md
+artifacts
+build-tools
+models/YangModels
+models/arista
+models/YangModels
+*.Dockerfile
diff --git a/applications/inventory-manager/inventoryManager/inventoryManager.go b/applications/inventory-manager/inventoryManager/inventoryManager.go
new file mode 100644
index 000000000..f1943b87e
--- /dev/null
+++ b/applications/inventory-manager/inventoryManager/inventoryManager.go
@@ -0,0 +1,133 @@
+package inventorymanager
+
+import (
+	"context"
+	"time"
+
+	tpb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/transport"
+	"code.fbi.h-da.de/danet/gosdn/applications/inventory-manager/config"
+	"code.fbi.h-da.de/danet/gosdn/controller/api"
+	"github.com/google/uuid"
+	"github.com/sirupsen/logrus"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+)
+
+type InventoryManager struct {
+	pndID             string
+	controllerAddress string
+	grpcClientConn    *grpc.ClientConn
+	sessionContext    context.Context
+	sessionToken      string
+	//eventService          event.ServiceInterface
+	configNetworkElements []config.NetworkElement // NetworkElements from the config file
+}
+
+// InitializeInventoryManager sets up the inventory manager and logs in to the controller.
+func InitializeInventoryManager(config *config.Config) (*InventoryManager, error) {
+	// Create controller client object
+	newConn, err := grpc.NewClient(config.ControllerAddress, grpc.WithTransportCredentials(insecure.NewCredentials()))
+	if err != nil {
+		logrus.Errorf("Failed to create controller client with error: %s", err.Error())
+		return nil, err
+	}
+
+	var conn *grpc.ClientConn
+	var sessionToken string
+	logrus.Info("Connecting and logging in to controller ", config.ControllerAddress)
+	for {
+		sessionToken, err = Login(conn, config.ControllerAddress, config.UserName, config.UserPW)
+		if err == nil {
+			logrus.Info("Connected and logged in to Controller")
+			conn = newConn
+			break
+		}
+		logrus.Errorf("Failed to connect and log in to Controller with error: %s, retrying in 1 second", err.Error())
+		time.Sleep(1 * time.Second)
+	}
+
+	app, err := NewInventoryManager(conn, sessionToken, config.ControllerAddress, config.PndID, config.AppName, config.NetworkElements)
+	if err != nil {
+		return nil, err
+	}
+
+	return app, nil
+}
+
+func NewInventoryManager(grpcClientConn *grpc.ClientConn, sessionToken, controllerAddress, pndID, appName string, networkElements []config.NetworkElement) (*InventoryManager, error) {
+	ctx := createContextWithAuthorization(sessionToken)
+
+	// Not needed now, but later when lifecycle is implemented
+	//queueCredentials, err := getQueueCredentials(ctx, controllerAddress, appName, registrationToken)
+	//if err != nil {
+	//	return nil, err
+	//}
+	//
+	//eventService, err := event.NewEventService(
+	//	queueCredentials,
+	//	[]event.Topic{event.ManagedNetworkElement},
+	//)
+	//if err != nil {
+	//	return nil, err
+	//}
+
+	return &InventoryManager{
+		grpcClientConn: grpcClientConn,
+		sessionContext: ctx,
+		sessionToken:   sessionToken,
+		//eventService:          eventService,
+		pndID:                 pndID,
+		controllerAddress:     controllerAddress,
+		configNetworkElements: networkElements,
+	}, nil
+}
+
+// Run starts the InventoryManager. This will elad to it trying to create every network element from the config file.
+// It will try to create it indefinitely.
+// TODO: After creating a network element, it will ensure that the element exists in the controller.
+func (i *InventoryManager) Run() error {
+	i.createNetworkelements()
+	logrus.Info("Created all network elements.")
+	for {
+		// Management logic after creation should replace this sleep
+		time.Sleep(60 * time.Second)
+	}
+}
+
+func (i *InventoryManager) createNetworkelements() {
+IterateDevicesLoop:
+	for _, networkElement := range i.configNetworkElements {
+		transportOptions := tpb.TransportOption{
+			Address:  networkElement.Address,
+			Username: networkElement.Username,
+			Password: networkElement.Password,
+			Tls:      networkElement.Tls,
+			TransportOption: &tpb.TransportOption_GnmiTransportOption{
+				GnmiTransportOption: &tpb.GnmiTransportOption{},
+			},
+		}
+
+	AddDeviceLoop:
+		for {
+			pluginUUID, err := uuid.Parse(networkElement.PluginID)
+			if err != nil {
+				logrus.Errorf("No or wrong plugin UUID for device %s provided. Skipping device creation.", networkElement.Name)
+				continue IterateDevicesLoop
+			}
+			pndUUID, err := uuid.Parse(i.pndID)
+			if err != nil {
+				logrus.Errorf("No or wrong PND UUID for device %s provided. Skipping device creation.", networkElement.Name)
+				continue IterateDevicesLoop
+			}
+
+			_, err = api.AddNetworkElement(i.sessionContext, i.controllerAddress, networkElement.Name, networkElement.UUID, &transportOptions, pluginUUID, pndUUID, []string{})
+			if err != nil {
+				logrus.Errorf("Failed to create network element %s with error: %s. Sleeping one second and retrying...", networkElement.Name, err.Error())
+				time.Sleep(1 * time.Second)
+				continue AddDeviceLoop
+			}
+			break AddDeviceLoop
+		}
+		logrus.Infof("Successfully created network element %s", networkElement.Name)
+	}
+}
diff --git a/applications/inventory-manager/inventoryManager/util.go b/applications/inventory-manager/inventoryManager/util.go
new file mode 100644
index 000000000..15764b4f4
--- /dev/null
+++ b/applications/inventory-manager/inventoryManager/util.go
@@ -0,0 +1,35 @@
+package inventorymanager
+
+import (
+	"context"
+
+	"code.fbi.h-da.de/danet/gosdn/controller/api"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/metadata"
+)
+
+//type CustomJwtClaims struct {
+//	jwt.StandardClaims
+//	Username string `json:"username,omitempty"`
+//}
+
+// Login logs in to the controller.
+func Login(conn *grpc.ClientConn, address, user, pw string) (string, error) {
+	loginResp, err := api.Login(context.Background(), address, user, pw)
+	if err != nil {
+		return "", err
+	}
+
+	return loginResp.GetToken(), nil
+}
+
+// createContextWithAuthorization creates a context with the token received after login.
+func createContextWithAuthorization(sessionToken string) context.Context {
+	md := metadata.Pairs("authorize", sessionToken)
+	return metadata.NewOutgoingContext(context.Background(), md)
+}
+
+// getQueueCredentials registers an app for the event system of the controller and returns the provided credentials.
+//func getQueueCredentials(ctx context.Context, controllerAddress, appName, registrationToken string) (string, error) {
+//	return registration.Register(ctx, controllerAddress, appName, registrationToken)
+//}
diff --git a/applications/inventory-manager/main.go b/applications/inventory-manager/main.go
new file mode 100644
index 000000000..a86f5f414
--- /dev/null
+++ b/applications/inventory-manager/main.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+	"flag"
+	"log"
+	"os"
+
+	"code.fbi.h-da.de/danet/gosdn/applications/inventory-manager/config"
+	inventorymanager "code.fbi.h-da.de/danet/gosdn/applications/inventory-manager/inventoryManager"
+	"github.com/sirupsen/logrus"
+	"google.golang.org/grpc/resolver"
+	"gopkg.in/yaml.v3"
+)
+
+func main() {
+	configFile := flag.String("config", "", "app config file")
+	logLevel := flag.String("log", "info", "logrus lof level (debug, info, warn, error, fatal, panic)")
+	noGRPCPassthrough := flag.Bool("noGRPCPassthrough", false, "set the default resolve scheme for grpc to not use  passthrough, default is false")
+
+	flag.Parse()
+
+	if *noGRPCPassthrough {
+		logrus.Info("gRPC default resolver scheme is not set to passthrough. This might cause issues with the gRPC connection when no real DNS server is available as each gRPC requests requires a DNS request.")
+	} else {
+		logrus.Info("Setting gRPC default resolver scheme to passthrough. No DNS queries are being made when doing a gRPC request.")
+		resolver.SetDefaultScheme("passthrough")
+	}
+
+	// parse string, this is built-in feature of logrus
+	ll, err := logrus.ParseLevel(*logLevel)
+	if err != nil {
+		ll = logrus.InfoLevel
+		logrus.Warn("invalid log level, using default: ", ll)
+	}
+
+	// set global log level
+	logrus.Info("setting log level to ", ll)
+	logrus.SetLevel(ll)
+
+	logrus.Debugf("current config path: %s", *configFile)
+	file, err := os.ReadFile(*configFile)
+	if err != nil {
+		currentFolder, _ := os.Getwd()
+		log.Fatalf("error reading config file: %s, current folder: %s", *configFile, currentFolder)
+	}
+
+	appConfig := &config.Config{}
+	if err = yaml.Unmarshal(file, appConfig); err != nil {
+		logrus.Fatal(err)
+	}
+
+	// Just for printing the config
+	configBytes, err := yaml.Marshal(appConfig)
+	if err != nil {
+		logrus.Fatal("error marshaling config to YAML: ", err)
+	}
+	logrus.Debugf("config:\n%s", string(configBytes))
+
+	inventoryManager, err := inventorymanager.InitializeInventoryManager(appConfig)
+	if err != nil {
+		logrus.Fatal(err)
+	}
+
+	err = inventoryManager.Run()
+	if err != nil {
+		logrus.Fatal(err)
+	}
+}
diff --git a/makefiles/build/Makefile b/makefiles/build/Makefile
index 6ffa72f25..69a388bcd 100644
--- a/makefiles/build/Makefile
+++ b/makefiles/build/Makefile
@@ -35,3 +35,6 @@ build-basic-interface-monitoring-app: pre
 
 build-ws-events-app: pre
 	$(GOBUILD) -trimpath -o $(BUILD_ARTIFACTS_PATH)/ws-events ./applications/ws-events
+
+build-inventory-manager: pre
+	$(GOBUILD) -trimpath -o $(BUILD_ARTIFACTS_PATH)/inventory-manager ./applications/inventory-manager
diff --git a/makefiles/container/Makefile b/makefiles/container/Makefile
index 34b1e740e..c9c21b972 100644
--- a/makefiles/container/Makefile
+++ b/makefiles/container/Makefile
@@ -22,3 +22,6 @@ containerize-hostname-checker-app:
 
 containerize-ws-events-app:
 	docker buildx build --rm -t ws-events-app -f applications/ws-events/ws-events.Dockerfile .
+
+containerize-inventory-manager:
+	docker buildx build --rm -t venv-manager --load -f applications/inventory-manager/inventory-manager.Dockerfile .
-- 
GitLab