diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..4ecdcdb --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1 @@ +obuspa/ \ No newline at end of file diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..2a09979 --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,54 @@ +# +# file Dockerfile +# +# Base docker image with all required dependencies for OB-USP-A +# +# Based on Ubuntu 22.10 (Kinetic Kudu), which provides libmosquitto 2.0.11 and libwebsockets 4.1.6 +# This image includes some basic compilation tools (automake, autoconf) +# +# One-liner execution line (straightforward build for OB-USP-A execution): +# > docker build -f Dockerfile -t obuspa:latest . +# +# Multi-stage builds execution lines (to tag build stages): +# 1) Create the build environment image: +# > docker build -f Dockerfile -t obuspa:build-env --target build-env . +# 2) Create the OB-USP-A image, then build the application +# > docker build -f Dockerfile -t obuspa:latest --target runner . +# +FROM ubuntu:lunar AS build-env + +# Install dependencies +RUN apt-get update && apt-get -y install \ + libssl-dev \ + libcurl4-openssl-dev\ + libsqlite3-dev \ + libz-dev \ + autoconf \ + automake \ + libtool \ + libmosquitto-dev \ + libwebsockets-dev \ + pkg-config \ + make \ + && apt-get clean + +FROM build-env AS runner + +ENV MAKE_JOBS=8 +ENV OBUSPA_ARGS="-v4" + +# Copy in all of the code +# Then compile, as root. +COPY obuspa /obuspa +RUN cd /obuspa/ && \ + autoreconf -fi && \ + ./configure && \ + make -j${MAKE_JOBS} && \ + make install + +# Then delete the code +# that's no longer needed +RUN rm -rf /obuspa + +# Run obuspa with args expanded +CMD obuspa ${OBUSPA_ARGS} diff --git a/agent/Makefile b/agent/Makefile new file mode 100644 index 0000000..7dd4d16 --- /dev/null +++ b/agent/Makefile @@ -0,0 +1,33 @@ +.PHONY: help build release + +DOCKER_USER ?= oktopusp +DOCKER_APP ?= obuspa +DOCKER_TAG ?= $(shell git log --format="%h" -n 1) +GIT_REPO ?= https://github.com/BroadbandForum/obuspa + +.DEFAULT_GOAL := help + +help: + @echo "Makefile arguments:" + @echo "" + @echo "DOCKER_USER - docker user to build image" + @echo "DOCKER_APP - docker image name" + @echo "DOCKER_TAG - docker image tag" + @echo "" + @echo "Makefile commands:" + @echo "" + @echo "build - docker image build" + @echo "release - tag image as latest and push to registry" + +build: + if [ -d "obuspa" ]; then \ + git -C obuspa pull; \ + else \ + git clone ${GIT_REPO} ; \ + fi + @docker build -t ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} -f Dockerfile . + +release: build + @docker push ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} + @docker tag ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} ${DOCKER_USER}/${DOCKER_APP}:latest + @docker push ${DOCKER_USER}/${DOCKER_APP}:latest \ No newline at end of file diff --git a/agent/oktopus-mqtt-obuspa.txt b/agent/oktopus-mqtt-obuspa.txt index f49caa0..c79d9bf 100644 --- a/agent/oktopus-mqtt-obuspa.txt +++ b/agent/oktopus-mqtt-obuspa.txt @@ -33,7 +33,7 @@ Device.LocalAgent.Subscription.1.Persistent true Device.LocalAgent.MTP.1.MQTT.ResponseTopicConfigured "oktopus/usp/v1/controller" Device.LocalAgent.MTP.1.MQTT.Reference "Device.MQTT.Client.1" -Device.MQTT.Client.1.BrokerAddress "localhost" +Device.MQTT.Client.1.BrokerAddress "127.0.0.1" Device.MQTT.Client.1.ProtocolVersion "5.0" Device.MQTT.Client.1.BrokerPort "1883" Device.MQTT.Client.1.TransportProtocol "TCP/IP" diff --git a/agent/run.sh b/agent/run.sh deleted file mode 100644 index 996e4a4..0000000 --- a/agent/run.sh +++ /dev/null @@ -1 +0,0 @@ -obuspa -p -v 4 -r ./oktopus-mqtt-obuspa.txt -i lo \ No newline at end of file diff --git a/backend/services/mtp/ws/.gitignore b/backend/services/mtp/ws/.gitignore index bb855f3..ad0ec79 100644 --- a/backend/services/mtp/ws/.gitignore +++ b/backend/services/mtp/ws/.gitignore @@ -1,3 +1,2 @@ .env.local -ws *.pem \ No newline at end of file diff --git a/backend/services/mtp/ws/cmd/ws/main.go b/backend/services/mtp/ws/cmd/ws/main.go new file mode 100644 index 0000000..2a49c94 --- /dev/null +++ b/backend/services/mtp/ws/cmd/ws/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/OktopUSP/oktopus/ws/internal/config" + "github.com/OktopUSP/oktopus/ws/internal/ws" +) + +func main() { + + done := make(chan os.Signal, 1) + + conf := config.NewConfig() + + // Locks app running until it receives a stop command as Ctrl+C. + signal.Notify(done, syscall.SIGINT) + + ws.StartNewServer(conf) + + <-done + + log.Println("(⌐■_■) Websockets server is out!") +} diff --git a/backend/services/mtp/ws/internal/ws/handler/client.go b/backend/services/mtp/ws/internal/ws/handler/client.go new file mode 100644 index 0000000..e2f297e --- /dev/null +++ b/backend/services/mtp/ws/internal/ws/handler/client.go @@ -0,0 +1,219 @@ +package handler + +import ( + "log" + "net/http" + "strings" + "time" + + "github.com/OktopUSP/oktopus/ws/internal/usp_record" + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 30 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 10 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + //maxMessageSize = 512 + + // Websockets version of the protocol + wsVersion = "13" + + // USP specification version + uspVersion = "v1.usp" +) + +var ( + newline = []byte{'\n'} + space = []byte{' '} + upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, + } +) + +// Client is a middleman between the websocket connection and the hub. +type Client struct { + hub *Hub + + //Websockets client endpoint id, eid follows usp specification + eid string + + // The websocket connection. + conn *websocket.Conn + + // Buffered channel of outbound messages. + send chan message +} + +// readPump pumps messages from the websocket connection to the hub. +// +// The application runs readPump in a per-connection goroutine. The application +// ensures that there is at most one reader on a connection by executing all +// reads from this goroutine. +// cEID = controller endpoint id +func (c *Client) readPump(cEID string) { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + //c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("error: %v", err) + } + break + } + message := constructMsg(cEID, c.eid, data) + c.hub.broadcast <- message + } +} + +func constructMsg(eid string, from string, data []byte) message { + if eid == "" { + var record usp_record.Record + err := proto.Unmarshal(data, &record) + if err != nil { + log.Println(err) + } + eid = record.ToId + } + return message{ + eid: eid, + from: from, + data: data, + msgType: websocket.BinaryMessage, + } +} + +// writePump pumps messages from the hub to the websocket connection. +// +// A goroutine running writePump is started for each connection. The +// application ensures that there is at most one writer to a connection by +// executing all writes from this goroutine. +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // The hub closed the channel. + log.Println("The hub closed the channel of", c.eid) + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(message.msgType) + if err != nil { + return + } + w.Write(message.data) + + // Add queued messages to the current websocket message. + n := len(c.send) + for i := 0; i < n; i++ { + w.Write(newline) + send := <-c.send + w.Write(send.data) + } + + if err := w.Close(); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// Handle USP Controller events +func ServeController(w http.ResponseWriter, r *http.Request, token, cEID string, authEnable bool) { + if authEnable { + recv_token := r.URL.Query().Get("token") + if recv_token != token { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized")) + return + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + + client := &Client{hub: hub, eid: cEID, conn: conn, send: make(chan message)} + client.hub.register <- client + + go client.writePump() + go client.readPump("") +} + +// Handle USP Agent events, cEID = controller endpoint id +func ServeAgent(w http.ResponseWriter, r *http.Request, cEID string) { + + header := http.Header{ + "Sec-Websocket-Protocol": {uspVersion}, + "Sec-Websocket-Version": {wsVersion}, + } + + deviceid := extractDeviceId(r.Header) + if deviceid == "" { + w.WriteHeader(http.StatusBadRequest) + log.Println("Device id not found") + w.Write([]byte("Device id not found")) + return + } + + conn, err := upgrader.Upgrade(w, r, header) + if err != nil { + log.Println(err) + return + } + + client := &Client{hub: hub, eid: deviceid, conn: conn, send: make(chan message)} + client.hub.register <- client + + // Allow collection of memory referenced by the caller by doing all work in + // new goroutines. + go client.writePump() + go client.readPump(cEID) +} + +// gets device id from websockets header +func extractDeviceId(header http.Header) string { + + // Header must be like that: bbf-usp-protocol; eid="" is the same ar the record.FromId/record.ToId + // log.Println("Header sec-websocket-extensions:", header.Get("sec-websocket-extensions")) + wsHeaderExtension := header.Get("sec-websocket-extensions") + + // Split the input string by double quotes + deviceid := strings.Split(wsHeaderExtension, "\"") + if len(deviceid) < 2 { + return "" + } + + return deviceid[1] +} diff --git a/backend/services/mtp/ws/internal/ws/handler/hub.go b/backend/services/mtp/ws/internal/ws/handler/hub.go new file mode 100644 index 0000000..d4f5070 --- /dev/null +++ b/backend/services/mtp/ws/internal/ws/handler/hub.go @@ -0,0 +1,151 @@ +package handler + +import ( + "encoding/json" + "log" + + "github.com/gorilla/websocket" +) + +// Keeps the content and the destination of a websockets message +type message struct { + // Websockets client endpoint id, eid follows usp specification. + // This field is needed for us to know which agent or controller + // the message is intended to be delivered to. + eid string + data []byte + msgType int + from string +} + +// Hub maintains the set of active clients and broadcasts messages to the +// clients. +type Hub struct { + // Registered clients. + clients map[string]*Client + + // Inbound messages from the clients. + broadcast chan message + + // Register requests from the clients. + register chan *Client + + // Unregister requests from clients. + unregister chan *Client +} + +const ( + OFFLINE = "0" + ONLINE = "1" +) + +type deviceStatus struct { + Eid string + Status string +} + +// Global hub instance +var hub *Hub + +// Controller Endpoint ID +var ceid string + +func InitHandlers(eid string) { + ceid = eid + log.Println("New hub, Controller eid:", ceid) + hub = newHub() + hub.run() +} + +func newHub() *Hub { + return &Hub{ + broadcast: make(chan message), + register: make(chan *Client), + unregister: make(chan *Client), + clients: make(map[string]*Client), + } +} + +func (h *Hub) run() { + for { + select { + case client := <-h.register: + // register new eid + h.clients[client.eid] = client + if client.eid != ceid{ + log.Printf("New device connected: %s", client.eid) + data, _ := json.Marshal(deviceStatus{client.eid, ONLINE}) + msg := message{ + from: "WS server", + eid: ceid, + data: data, + msgType: websocket.TextMessage, + } + log.Printf("%++v", msg) + if c, ok := h.clients[msg.eid]; ok { + select { + // send message to receiver client + case c.send <- msg: + log.Printf("Sent a message %s --> %s", msg.from, msg.eid) + default: + // in case the msg sending fails, close the client connection + // because it means that the client is no longer active + log.Printf("Failed to send a msg to %s, disconnecting client...", msg.eid) + close(c.send) + delete(h.clients, c.eid) + } + } + }else{ + log.Printf("New controller connected: %s", client.eid) + } + + case client := <-h.unregister: + // verify if eid exists + if _, ok := h.clients[client.eid]; ok { + // delete eid from map of connections + delete(h.clients, client.eid) + // close client messages receiving channel + close(client.send) + } + log.Println("Disconnected client", client.eid) + data, _ := json.Marshal(deviceStatus{client.eid, OFFLINE}) + msg := message{ + from: "WS server", + eid: ceid, + data: data, + msgType: websocket.TextMessage, + } + if c, ok := h.clients[msg.eid]; ok { + select { + // send message to receiver client + case c.send <- msg: + log.Printf("Sent a message %s --> %s", msg.from, msg.eid) + default: + // in case the msg sending fails, close the client connection + // because it means that the client is no longer active + log.Printf("Failed to send a msg to %s, disconnecting client...", msg.eid) + close(c.send) + delete(h.clients, c.eid) + } + } + case message := <-h.broadcast: + log.Println("send message to", message.eid) + // verify if eid exists + if c, ok := h.clients[message.eid]; ok { + select { + // send message to receiver client + case c.send <- message: + log.Printf("Sent a message %s --> %s", message.from, message.eid) + default: + // in case the message sending fails, close the client connection + // because it means that the client is no longer active + log.Printf("Failed to send a message to %s, disconnecting client...", message.eid) + close(c.send) + delete(h.clients, c.eid) + } + } else { + log.Printf("Message receiver not found: %s", message.eid) + } + } + } +} diff --git a/backend/services/mtp/ws/internal/ws/ws.go b/backend/services/mtp/ws/internal/ws/ws.go new file mode 100644 index 0000000..08e4eb4 --- /dev/null +++ b/backend/services/mtp/ws/internal/ws/ws.go @@ -0,0 +1,42 @@ +package ws + +// Websockets server implementation inspired by https://github.com/gorilla/websocket/tree/main/examples/chat + +import ( + "log" + "net/http" + + "github.com/OktopUSP/oktopus/ws/internal/config" + "github.com/OktopUSP/oktopus/ws/internal/ws/handler" + "github.com/gorilla/mux" +) + +// Starts New Websockets Server +func StartNewServer(c config.Config) { + // Initialize handlers of websockets events + go handler.InitHandlers(c.ControllerEID) + + r := mux.NewRouter() + r.HandleFunc("/ws/agent", func(w http.ResponseWriter, r *http.Request) { + handler.ServeAgent(w, r, c.ControllerEID) + }) + r.HandleFunc("/ws/controller", func(w http.ResponseWriter, r *http.Request) { + handler.ServeController(w, r, c.Token, c.ControllerEID, c.Auth) + }) + + go func() { + if c.Tls { + log.Println("Websockets server running with TLS") + err := http.ListenAndServeTLS(c.Port, "cert.pem", "key.pem", r) + if err != nil { + log.Fatal("ListenAndServeTLS: ", err) + } + } else { + log.Println("Websockets server running at port", c.Port) + err := http.ListenAndServe(c.Port, r) + if err != nil { + log.Fatal("ListenAndServe: ", err) + } + } + }() +} diff --git a/build/Makefile b/build/Makefile index 9b70f89..97917fc 100644 --- a/build/Makefile +++ b/build/Makefile @@ -24,30 +24,30 @@ build: build-frontend build-backend build-backend: @make build -C ../backend/services/controller/build/ DOCKER_USER=${DOCKER_USER} - @make build -C ../backend/services/utils/socketio/build/ - @make build -C ../backend/services/mtp/adapter/build/ - @make build -C ../backend/services/mtp/ws-adapter/build/ - @make build -C ../backend/services/mtp/ws/build/ - @make build -C ../backend/services/mtp/mqtt-adapter/build/ - @make build -C ../backend/services/mtp/mqtt/build/ - @make build -C ../backend/services/mtp/stomp-adapter/build/ - @make build -C ../backend/services/mtp/stomp/build/ + @make build -C ../backend/services/utils/socketio/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/adapter/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/ws-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/ws/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/mqtt-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/mqtt/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/stomp-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make build -C ../backend/services/mtp/stomp/build/ DOCKER_USER=${DOCKER_USER} build-frontend: - @make build -C ../frontend/build + @make build -C ../frontend/build/ DOCKER_USER=${DOCKER_USER} release: release-frontend release-backend release-backend: - @make release -C ../backend/services/controller/build/ - @make release -C ../backend/services/utils/socketio/build/ - @make release -C ../backend/services/mtp/adapter/build/ - @make release -C ../backend/services/mtp/ws-adapter/build/ - @make release -C ../backend/services/mtp/ws/build/ - @make release -C ../backend/services/mtp/mqtt-adapter/build/ - @make release -C ../backend/services/mtp/mqtt/build/ - @make release -C ../backend/services/mtp/stomp-adapter/build/ - @make release -C ../backend/services/mtp/stomp/build/ + @make release -C ../backend/services/controller/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/utils/socketio/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/adapter/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/ws-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/ws/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/mqtt-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/mqtt/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/stomp-adapter/build/ DOCKER_USER=${DOCKER_USER} + @make release -C ../backend/services/mtp/stomp/build/ DOCKER_USER=${DOCKER_USER} release-frontend: - @make release -C ../frontend/build \ No newline at end of file + @make release -C ../frontend/build/ DOCKER_USER=${DOCKER_USER} \ No newline at end of file diff --git a/deploy/compose/.env.frontend b/deploy/compose/.env.frontend new file mode 100644 index 0000000..e69de29 diff --git a/deploy/compose/docker-compose.yaml b/deploy/compose/docker-compose.yaml index f966462..44ca59b 100644 --- a/deploy/compose/docker-compose.yaml +++ b/deploy/compose/docker-compose.yaml @@ -156,13 +156,10 @@ services: #/* -------------------------------- Frontend -------------------------------- */ frontend: - image: 'node:16.20.2' + image: 'oktopusp/frontend' container_name: frontend - tty: true - stdin_open: true - volumes: - - ../../frontend:/app/ - command: bash -c "cd /app/ && npm i && npm run dev" + env_file: + - .env.frontend ports: - 3000:3000 networks: diff --git a/frontend/.env b/frontend/.env index 679a4b3..32e4aa9 100644 --- a/frontend/.env +++ b/frontend/.env @@ -6,8 +6,8 @@ NEXT_PUBLIC_WS_ENPOINT="http://localhost:5000/" # -------------------------- Production Environment -------------------------- # -#NEXT_PUBLIC_REST_ENPOINT="https://oktopustr369.com/api" -#NEXT_PUBLIC_WS_ENPOINT="https://oktopustr369.com/" +#NEXT_PUBLIC_REST_ENPOINT="https://demo.oktopus.app.br/api" +#NEXT_PUBLIC_WS_ENPOINT="https://demo.oktopus.app.br/" # ---------------------------------------------------------------------------- # # ---------------------------- Mocked Environment ---------------------------- # diff --git a/frontend/.gitignore b/frontend/.gitignore index d7d9eba..656fd8a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -9,7 +9,6 @@ /coverage # production -/build out .next diff --git a/frontend/build/Dockerfile b/frontend/build/Dockerfile new file mode 100644 index 0000000..de5d174 --- /dev/null +++ b/frontend/build/Dockerfile @@ -0,0 +1,11 @@ +FROM node:16.20.2-alpine + +WORKDIR /app + +COPY ../ . + +RUN npm install + +RUN npm run build + +ENTRYPOINT [ "npm", "run", "start" ] \ No newline at end of file diff --git a/frontend/build/Makefile b/frontend/build/Makefile new file mode 100644 index 0000000..147c1e6 --- /dev/null +++ b/frontend/build/Makefile @@ -0,0 +1,61 @@ +.PHONY: help build push start stop release remove delete run logs bash + +DOCKER_USER ?= oktopusp +DOCKER_APP ?= frontend +DOCKER_TAG ?= $(shell git log --format="%h" -n 1) +CONTAINER_SHELL ?= /bin/sh + +.DEFAULT_GOAL := help + +help: + @echo "Makefile arguments:" + @echo "" + @echo "DOCKER_USER - docker user to build image" + @echo "DOCKER_APP - docker image name" + @echo "DOCKER_TAG - docker image tag" + @echo "CONTAINER_SHELL - container shell e.g:'/bin/bash'" + @echo "" + @echo "Makefile commands:" + @echo "" + @echo "build - docker image build" + @echo "push - push docker image to registry" + @echo "run - create and start docker container with the image" + @echo "start - start existent docker container with the image" + @echo "stop - stop docker container running the image" + @echo "remove - remove docker container running the image" + @echo "delete - delete docker image" + @echo "logs - show logs of docker container" + @echo "bash - access container shell" + @echo "release - tag image as latest and push to registry" + +build: + @docker build -t ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} -f Dockerfile ../ + +run: + @docker run -d --name ${DOCKER_USER}-${DOCKER_APP} ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} + +stop: + @docker stop ${DOCKER_USER}-${DOCKER_APP} + +remove: stop + @docker rm ${DOCKER_USER}-${DOCKER_APP} + +delete: + @docker rmi ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} + +start: + @docker start ${DOCKER_USER}-${DOCKER_APP} + +push: + @docker push ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} + +logs: + @docker logs -f ${DOCKER_USER}-${DOCKER_APP} + +bash: + @docker exec -it ${DOCKER_USER}-${DOCKER_APP} ${CONTAINER_SHELL} + +release: build + @docker push ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} + @docker tag ${DOCKER_USER}/${DOCKER_APP}:${DOCKER_TAG} ${DOCKER_USER}/${DOCKER_APP}:latest + @docker push ${DOCKER_USER}/${DOCKER_APP}:latest \ No newline at end of file