diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..817f140 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +build +deploy +docs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4312cef --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,55 @@ +name: Build +on: + schedule: + - cron: "0 10 * * *" + push: + branches: + - "**" + tags: + - "v*.*.*" + pull_request: + branches: + - "main" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/mentos1386/zdravko + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..51d548a --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 +ARG GO_VERSION=1.21.0 +FROM golang:${GO_VERSION} as builder +# Set destination for COPY +WORKDIR /app + +# Download Go modules +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . ./ + +# Build +RUN CGO_ENABLED=1 GOOS=linux go build -o /zdravko cmd/zdravko/main.go + +### +# Final production +FROM gcr.io/distroless/base-debian12:nonroot as production +COPY --from=builder /zdravko /zdravko +COPY LICENSE /LICENSE +COPY README.md /README.md + +# Zdravko Server +ENV PORT=8080 +EXPOSE 8080 +# Temporal UI Server +EXPOSE 8223 +# Temporal GRPC Server +EXPOSE 7233 + +# Volume to persist sqlite databases +VOLUME /data + +ENV DATABASE_PATH=/data/zdravko.db +ENV TEMPORAL_DATABASE_PATH=/data/temporal.db + +ENTRYPOINT ["/zdravko"] diff --git a/tailwind.config.js b/build/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to build/tailwind.config.js diff --git a/cmd/temporal/main.go b/cmd/temporal/main.go deleted file mode 100644 index 84148c2..0000000 --- a/cmd/temporal/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "log" - - "code.tjo.space/mentos1386/zdravko/internal/config" - t "code.tjo.space/mentos1386/zdravko/pkg/temporal" -) - -func backendServer(config *config.Config) { - serverConfig := t.NewServerConfig(config) - - server, err := t.NewServer(serverConfig) - if err != nil { - log.Fatalf("Unable to create server: %v", err) - } - - err = server.Start() - if err != nil { - log.Fatalf("Unable to start server: %v", err) - } - - err = server.Stop() - if err != nil { - log.Fatalf("Unable to stop server: %v", err) - } -} - -func frontendServer(config *config.Config) { - uiConfig := t.NewUiConfig(config) - - uiServer, err := t.NewUiServer(uiConfig) - if err != nil { - log.Fatalf("Unable to create UI server: %v", err) - } - - err = uiServer.Start() - if err != nil { - log.Fatalf("Unable to start UI server: %v", err) - } - - uiServer.Stop() -} - -func main() { - config := config.NewConfig() - - go func() { - frontendServer(config) - }() - backendServer(config) -} diff --git a/cmd/worker/main.go b/cmd/worker/main.go deleted file mode 100644 index d0970d9..0000000 --- a/cmd/worker/main.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "log" - - "code.tjo.space/mentos1386/zdravko/internal" - "code.tjo.space/mentos1386/zdravko/internal/activities" - "code.tjo.space/mentos1386/zdravko/internal/config" - "code.tjo.space/mentos1386/zdravko/internal/workflows" - "go.temporal.io/sdk/worker" -) - -func main() { - cfg := config.NewConfig() - - temporalClient, err := internal.ConnectToTemporal(cfg) - if err != nil { - log.Fatal(err) - } - defer temporalClient.Close() - - // Create a new Worker - // TODO: Maybe identify by region or something? - w := worker.New(temporalClient, "default", worker.Options{}) - - // Register Workflows - w.RegisterWorkflow(workflows.HealthcheckHttpWorkflowDefinition) - - // Register Activities - w.RegisterActivity(activities.HealthcheckHttpActivityDefinition) - - // Start the the Worker Process - err = w.Run(worker.InterruptCh()) - if err != nil { - log.Fatalln("Unable to start the Worker Process", err) - } -} diff --git a/cmd/zdravko/main.go b/cmd/zdravko/main.go new file mode 100644 index 0000000..c0ae0f7 --- /dev/null +++ b/cmd/zdravko/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "sync" + + "code.tjo.space/mentos1386/zdravko/internal/config" + "code.tjo.space/mentos1386/zdravko/pkg/server" + "code.tjo.space/mentos1386/zdravko/pkg/temporal" + "code.tjo.space/mentos1386/zdravko/pkg/worker" +) + +type StartableAndStoppable interface { + Name() string + Start() error + Stop() error +} + +func main() { + var startServer bool + var startWorker bool + var startTemporal bool + + flag.BoolVar(&startServer, "server", true, "Start the server") + flag.BoolVar(&startWorker, "worker", true, "Start the worker") + flag.BoolVar(&startTemporal, "temporal", true, "Start the temporal") + + flag.Parse() + + println("Starting zdravko...") + println("Server: ", startServer) + println("Worker: ", startWorker) + println("Temporal: ", startTemporal) + + cfg := config.NewConfig() + + var servers [3]StartableAndStoppable + var wg sync.WaitGroup + + if startTemporal { + log.Println("Setting up Temporal") + temporal, err := temporal.NewTemporal(cfg) + if err != nil { + log.Fatalf("Unable to create temporal: %v", err) + } + servers[0] = temporal + } + + if startServer { + log.Println("Setting up Server") + server, err := server.NewServer(cfg) + if err != nil { + log.Fatalf("Unable to create server: %v", err) + } + servers[1] = server + } + + if startWorker { + log.Println("Setting up Worker") + worker, err := worker.NewWorker(cfg) + if err != nil { + log.Fatalf("Unable to create worker: %v", err) + } + servers[2] = worker + } + + for _, s := range servers { + srv := s + println("Starting", srv.Name()) + wg.Add(1) + go func() { + defer wg.Done() + err := srv.Start() + if err != nil { + log.Fatalf("Unable to start server %s: %v", srv.Name(), err) + } + }() + } + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + go func() { + for sig := range c { + log.Printf("Received signal: %v", sig) + for _, s := range servers { + println("Stopping", s.Name()) + err := s.Stop() + if err != nil { + log.Fatalf("Unable to stop server %s: %v", s.Name(), err) + } + } + } + }() + + wg.Wait() +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..7b889fe --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 +ARG ALPINE_VERSION=3.14 + +FROM alpine:${ALPINE_VERSION} as temporal +RUN apk add ca-certificates fuse3 sqlite +COPY --from=flyio/litefs:0.5 /usr/local/bin/litefs /usr/local/bin/litefs +COPY litefs.yaml /etc/litefs.yaml + +COPY --from=ghcr.io/mentos1386/zdravko /zdravko /zdravko + +ENTRYPOINT litefs mount diff --git a/fly.toml b/deploy/fly.toml similarity index 56% rename from fly.toml rename to deploy/fly.toml index 9987e26..1312be8 100644 --- a/fly.toml +++ b/deploy/fly.toml @@ -4,32 +4,26 @@ app = 'zdravko' primary_region = 'waw' [build] - builder = 'paketobuildpacks/builder:base' - buildpacks = ['gcr.io/paketo-buildpacks/go'] + dockerfile = "Dockerfile" [env] - PORT = '8080' ROOT_URL = 'https://zdravko.mnts.dev' - DATABASE_PATH = '/data/zdravko.db' OAUTH2_ENDPOINT_TOKEN_URL = 'https://github.com/login/oauth/access_token' OAUTH2_ENDPOINT_AUTH_URL = 'https://github.com/login/oauth/authorize' OAUTH2_ENDPOINT_USER_INFO_URL = 'https://api.github.com/user' - TEMPORAL_DATABASE_PATH = '/data/temporal.db' - TEMPORAL_UI_HOST = 'temporal.process.zdravko.internal:8223' - TEMPORAL_SERVER_HOST = 'temporal.process.zdravko.internal:7233' + TEMPORAL_UI_HOST = 'server.process.zdravko.internal:8223' + TEMPORAL_SERVER_HOST = 'server.process.zdravko.internal:7233' [processes] - server = "server" - worker = "worker" - temporal = "temporal" + server = "zdravko --temporal=false --server=true --worker=false" + worker = "zdravko --temporal=false --server=false --worker=true" [[mounts]] - source = "zdravko_data" - destination = "/data" - processes = ["server", "temporal"] - initial_size = "3GB" + source = "litefs" + destination = "/var/lib/litefs" + processes = ["server"] [http_service] processes = ["server"] diff --git a/deploy/litefs.yaml b/deploy/litefs.yaml new file mode 100644 index 0000000..9fd51dd --- /dev/null +++ b/deploy/litefs.yaml @@ -0,0 +1,35 @@ +# This directory is where your application will access the database. +fuse: + dir: "/data" + +# This directory is where LiteFS will store internal data. +# You must place this directory on a persistent volume. +data: + dir: "/var/lib/litefs" + +exec: + - cmd: "/zdravko --temporal=true --server=true --worker=false" + +lease: + type: "consul" + + # Specifies if this node can become primary. The expression below evaluates + # to true on nodes that are run in the primary region. Nodes in other regions + # act as non-candidate, read-only replicas. + candidate: ${FLY_REGION == PRIMARY_REGION} + + # If true, then the node will automatically become primary after it has + # connected with the cluster and sync'd up. This makes it easier to run + # migrations on start up. + promote: true + + # The API URL that other nodes will use to connect to this node. + advertise-url: "http://${FLY_ALLOC_ID}.vm.${FLY_APP_NAME}.internal:20202" + + consul: + # The URL of the Consul cluster. + url: "${FLY_CONSUL_URL}" + + # A unique key shared by all nodes in the LiteFS cluster. + # Change this if you are running multiple clusters in a single app! + key: "${FLY_APP_NAME}/primary" diff --git a/internal/temporal.go b/internal/temporal/temporal.go similarity index 96% rename from internal/temporal.go rename to internal/temporal/temporal.go index a32cdfd..7138f70 100644 --- a/internal/temporal.go +++ b/internal/temporal/temporal.go @@ -1,4 +1,4 @@ -package internal +package temporal import ( "time" diff --git a/justfile b/justfile index 5631603..65a4cff 100644 --- a/justfile +++ b/justfile @@ -5,28 +5,22 @@ set dotenv-load STATIC_DIR := "./web/static" +# Build the application +build: + docker build -f build/Dockerfile -t ghcr.io/mentos1386/zdravko:latest . + # Run full development environment run: devbox services up -# Start temporal which is accassible at http://localhost:8233/ -run-temporal: - go build -o dist/temporal cmd/temporal/main.go - ./dist/temporal - -# Start web server accessible at http://localhost:8080/ -run-server: - go build -o dist/server cmd/server/main.go - ./dist/server - -# Run worker -run-worker: - go build -o dist/worker cmd/worker/main.go - ./dist/worker +# Start zdravko +run-zdravko: + go build -o dist/zdravko cmd/zdravko/main.go + ./dist/zdravko # Deploy the application to fly.io deploy: - fly deploy + fly deploy -c deploy/fly.toml # Start devbox shell shell: @@ -38,7 +32,7 @@ generate: go generate ./... _tailwindcss-build: - tailwindcss build -i {{STATIC_DIR}}/css/main.css -o {{STATIC_DIR}}/css/tailwind.css + tailwindcss build -c build/tailwind.config.js -i {{STATIC_DIR}}/css/main.css -o {{STATIC_DIR}}/css/tailwind.css _htmx-download: mkdir -p {{STATIC_DIR}}/js diff --git a/cmd/server/main.go b/pkg/server/server.go similarity index 71% rename from cmd/server/main.go rename to pkg/server/server.go index 11db40f..4764415 100644 --- a/cmd/server/main.go +++ b/pkg/server/server.go @@ -1,34 +1,48 @@ -package main +package server import ( + "context" "log" "net/http" - "github.com/gorilla/mux" - "code.tjo.space/mentos1386/zdravko/internal" "code.tjo.space/mentos1386/zdravko/internal/config" "code.tjo.space/mentos1386/zdravko/internal/handlers" + "code.tjo.space/mentos1386/zdravko/internal/temporal" "code.tjo.space/mentos1386/zdravko/web/static" + "github.com/gorilla/mux" ) -func main() { - cfg := config.NewConfig() +type Server struct { + server *http.Server + cfg *config.Config +} +func NewServer(cfg *config.Config) (*Server, error) { + return &Server{ + cfg: cfg, + }, nil +} + +func (s *Server) Name() string { + return "HTTP WEB and API Server" +} + +func (s *Server) Start() error { r := mux.NewRouter() - db, query, err := internal.ConnectToDatabase(cfg.DatabasePath) + db, query, err := internal.ConnectToDatabase(s.cfg.DatabasePath) if err != nil { - log.Fatal(err) + return err } log.Println("Connected to database") - temporalClient, err := internal.ConnectToTemporal(cfg) + temporalClient, err := temporal.ConnectToTemporal(s.cfg) if err != nil { - log.Fatal(err) + return err } - h := handlers.NewBaseHandler(db, query, temporalClient, cfg) + h := handlers.NewBaseHandler(db, query, temporalClient, s.cfg) // Health r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { @@ -69,6 +83,15 @@ func main() { // 404 r.PathPrefix("/").HandlerFunc(h.Error404).Methods("GET") - log.Println("Server started on", cfg.Port) - log.Fatal(http.ListenAndServe(":"+cfg.Port, r)) + s.server = &http.Server{ + Addr: ":" + s.cfg.Port, + Handler: r, + } + + return s.server.ListenAndServe() +} + +func (s *Server) Stop() error { + ctx := context.Background() + return s.server.Shutdown(ctx) } diff --git a/pkg/temporal/temporal.go b/pkg/temporal/temporal.go new file mode 100644 index 0000000..f1645c7 --- /dev/null +++ b/pkg/temporal/temporal.go @@ -0,0 +1,55 @@ +package temporal + +import ( + "code.tjo.space/mentos1386/zdravko/internal/config" + "github.com/temporalio/ui-server/v2/server" + t "go.temporal.io/server/temporal" +) + +type Temporal struct { + server t.Server + uiServer *server.Server +} + +func NewTemporal(cfg *config.Config) (*Temporal, error) { + serverConfig := NewServerConfig(cfg) + server, err := NewServer(serverConfig) + if err != nil { + return nil, err + } + + uiConfig := NewUiConfig(cfg) + uiServer, err := NewUiServer(uiConfig) + if err != nil { + return nil, err + } + + return &Temporal{ + server: server, + uiServer: uiServer, + }, nil +} + +func (t *Temporal) Name() string { + return "Temporal UI and Server" +} + +func (t *Temporal) Start() error { + go func() { + err := t.uiServer.Start() + if err != nil { + panic(err) + } + }() + return t.server.Start() +} + +func (t *Temporal) Stop() error { + err := t.server.Stop() + if err != nil { + return err + } + t.uiServer.Stop() + + return nil +} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go new file mode 100644 index 0000000..ed4f027 --- /dev/null +++ b/pkg/worker/worker.go @@ -0,0 +1,48 @@ +package worker + +import ( + "code.tjo.space/mentos1386/zdravko/internal/activities" + "code.tjo.space/mentos1386/zdravko/internal/config" + "code.tjo.space/mentos1386/zdravko/internal/temporal" + "code.tjo.space/mentos1386/zdravko/internal/workflows" + "go.temporal.io/sdk/worker" +) + +type Worker struct { + worker worker.Worker + cfg *config.Config +} + +func NewWorker(cfg *config.Config) (*Worker, error) { + return &Worker{ + cfg: cfg, + }, nil +} + +func (w *Worker) Name() string { + return "Temporal Worker" +} + +func (w *Worker) Start() error { + temporalClient, err := temporal.ConnectToTemporal(w.cfg) + if err != nil { + return err + } + + // Create a new Worker + // TODO: Maybe identify by region or something? + w.worker = worker.New(temporalClient, "default", worker.Options{}) + + // Register Workflows + w.worker.RegisterWorkflow(workflows.HealthcheckHttpWorkflowDefinition) + + // Register Activities + w.worker.RegisterActivity(activities.HealthcheckHttpActivityDefinition) + + return w.worker.Run(worker.InterruptCh()) +} + +func (w *Worker) Stop() error { + w.worker.Stop() + return nil +} diff --git a/process-compose.yml b/process-compose.yml index 39206a9..605fd15 100644 --- a/process-compose.yml +++ b/process-compose.yml @@ -5,15 +5,7 @@ processes: command: watchexec -r -e go,tmpl,css just _tailwindcss-build availability: restart: "always" - server: - command: watchexec -r -e go,tmpl,css just run-server - availability: - restart: "always" - worker: - command: watchexec -r -e go,tmpl,css just run-worker - availability: - restart: "always" - temporal: - command: just run-temporal + zdravko: + command: watchexec -r -e go,tmpl,css just run-zdravko availability: restart: "always"