feat(workers): initial worker authentication and provisioning

This commit is contained in:
Tine 2024-02-18 22:37:17 +01:00
parent 2b6e1ca09b
commit 478232bcda
Signed by: mentos1386
SSH key fingerprint: SHA256:MNtTsLbihYaWF8j1fkOHfkKNlnN1JQfxEU/rBU8nCGw
29 changed files with 916 additions and 103 deletions

8
.github/workflows/deploy.yaml vendored Normal file
View file

@ -0,0 +1,8 @@
name: Deploy
on:
push:
branches:
- main
jobs:

3
.gitignore vendored
View file

@ -5,5 +5,8 @@ zdravko.db
temporal.db temporal.db
temporal.db-journal temporal.db-journal
# Keys
*.pem
# Config # Config
.env .env

View file

@ -29,6 +29,9 @@ Demo is available at https://zdravko.fly.dev.
# Configure # Configure
cp example.env .env cp example.env .env
# Generate JWT key
just generate-jwt-key
# Start development environment # Start development environment
just run just run
``` ```

View file

@ -14,9 +14,6 @@ COPY . ./
# Build # Build
RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/zdravko cmd/zdravko/main.go RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/zdravko cmd/zdravko/main.go
# Prepare the data directory
RUN mkdir -p /data
### ###
# Final production # Final production
FROM gcr.io/distroless/base-debian12:latest as production FROM gcr.io/distroless/base-debian12:latest as production
@ -34,7 +31,6 @@ EXPOSE 7233
# Volume to persist sqlite databases # Volume to persist sqlite databases
VOLUME /data VOLUME /data
COPY --from=builder --chown=nonroot:nonroot /data /data
ENV DATABASE_PATH=/data/zdravko.db ENV DATABASE_PATH=/data/zdravko.db
ENV TEMPORAL_DATABASE_PATH=/data/temporal.db ENV TEMPORAL_DATABASE_PATH=/data/temporal.db

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"sync" "sync"
"syscall"
"code.tjo.space/mentos1386/zdravko/internal/config" "code.tjo.space/mentos1386/zdravko/internal/config"
"code.tjo.space/mentos1386/zdravko/pkg/server" "code.tjo.space/mentos1386/zdravko/pkg/server"
@ -85,7 +86,7 @@ func main() {
} }
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
for sig := range c { for sig := range c {
log.Printf("Received signal: %v", sig) log.Printf("Received signal: %v", sig)
@ -97,7 +98,7 @@ func main() {
println("Stopping", srv.Name()) println("Stopping", srv.Name())
err := srv.Stop() err := srv.Stop()
if err != nil { if err != nil {
log.Fatalf("Unable to stop server %s: %v", srv.Name(), err) log.Printf("Unable to stop server %s: %v", srv.Name(), err)
} }
} }
} }

9
go.mod
View file

@ -3,8 +3,13 @@ module code.tjo.space/mentos1386/zdravko
go 1.21.6 go 1.21.6
require ( require (
github.com/go-playground/validator/v10 v10.18.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/sessions v1.2.2 github.com/gorilla/sessions v1.2.2
github.com/gosimple/slug v1.13.1
github.com/spf13/viper v1.18.2
github.com/temporalio/ui-server/v2 v2.23.0 github.com/temporalio/ui-server/v2 v2.23.0
go.temporal.io/sdk v1.26.0-rc.2 go.temporal.io/sdk v1.26.0-rc.2
go.temporal.io/server v1.22.4 go.temporal.io/server v1.22.4
@ -45,11 +50,9 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.18.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gocql/gocql v1.5.2 // indirect github.com/gocql/gocql v1.5.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/mock v1.7.0-rc.1 // indirect
@ -60,7 +63,6 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gosimple/slug v1.13.1 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect github.com/gosimple/unidecode v1.0.1 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect
@ -109,7 +111,6 @@ require (
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect github.com/stretchr/testify v1.8.4 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect

8
go.sum
View file

@ -63,7 +63,6 @@ github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpA
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/crossdock/crossdock-go v0.0.0-20160816171116-049aabb0122b/go.mod h1:v9FBN7gdVTpiD/+LZ7Po0UKvROyT87uLVxTHVky/dlQ= github.com/crossdock/crossdock-go v0.0.0-20160816171116-049aabb0122b/go.mod h1:v9FBN7gdVTpiD/+LZ7Po0UKvROyT87uLVxTHVky/dlQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -87,6 +86,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@ -105,6 +106,8 @@ github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@ -126,6 +129,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@ -302,7 +307,6 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -4,7 +4,6 @@ import (
"log" "log"
"os" "os"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -16,12 +15,17 @@ type Config struct {
DatabasePath string `validate:"required"` DatabasePath string `validate:"required"`
SessionSecret string `validate:"required"` SessionSecret string `validate:"required"`
Jwt Jwt `validate:"required"`
OAuth2 OAuth2 `validate:"required"` OAuth2 OAuth2 `validate:"required"`
Temporal Temporal `validate:"required"` Temporal Temporal `validate:"required"`
HealthChecks []Healthcheck Worker Worker `validate:"required"`
CronJobs []CronJob }
type Jwt struct {
PrivateKey string `validate:"required"`
PublicKey string `validate:"required"`
} }
type OAuth2 struct { type OAuth2 struct {
@ -41,30 +45,8 @@ type Temporal struct {
ServerHost string `validate:"required"` ServerHost string `validate:"required"`
} }
type HealthCheckHTTP struct { type Worker struct {
URL string `validate:"required,url"` Token string `validate:"required"`
Method string `validate:"required,oneof=GET POST PUT"`
}
type HealthCheckTCP struct {
Host string `validate:"required,hostname"`
Port int `validate:"required,gte=1,lte=65535"`
}
type Healthcheck struct {
Name string `validate:"required"`
Retries int `validate:"optional,gte=0"`
Schedule string `validate:"required,cron"`
Timeout time.Duration `validate:"required"`
HTTP HealthCheckHTTP `validate:"required"`
TCP HealthCheckTCP `validate:"required"`
}
type CronJob struct {
Name string `validate:"required"`
Schedule string `validate:"required,cron"`
Buffer time.Duration `validate:"required"`
} }
func GetEnvOrDefault(key, def string) string { func GetEnvOrDefault(key, def string) string {
@ -93,6 +75,8 @@ func NewConfig() *Config {
viper.SetDefault("temporal.listenaddress", GetEnvOrDefault("TEMPORAL_LISTEN_ADDRESS", "0.0.0.0")) viper.SetDefault("temporal.listenaddress", GetEnvOrDefault("TEMPORAL_LISTEN_ADDRESS", "0.0.0.0"))
viper.SetDefault("temporal.uihost", GetEnvOrDefault("TEMPORAL_UI_HOST", "127.0.0.1:8223")) viper.SetDefault("temporal.uihost", GetEnvOrDefault("TEMPORAL_UI_HOST", "127.0.0.1:8223"))
viper.SetDefault("temporal.serverhost", GetEnvOrDefault("TEMPORAL_SERVER_HOST", "127.0.0.1:7233")) viper.SetDefault("temporal.serverhost", GetEnvOrDefault("TEMPORAL_SERVER_HOST", "127.0.0.1:7233"))
viper.SetDefault("jwt.privatekey", os.Getenv("JWT_PRIVATE_KEY"))
viper.SetDefault("jwt.publickey", os.Getenv("JWT_PUBLIC_KEY"))
viper.SetDefault("oauth2.clientid", os.Getenv("OAUTH2_CLIENT_ID")) viper.SetDefault("oauth2.clientid", os.Getenv("OAUTH2_CLIENT_ID"))
viper.SetDefault("oauth2.clientsecret", os.Getenv("OAUTH2_CLIENT_SECRET")) viper.SetDefault("oauth2.clientsecret", os.Getenv("OAUTH2_CLIENT_SECRET"))
viper.SetDefault("oauth2.scopes", GetEnvOrDefault("OAUTH2_ENDPOINT_SCOPES", "openid profile email")) viper.SetDefault("oauth2.scopes", GetEnvOrDefault("OAUTH2_ENDPOINT_SCOPES", "openid profile email"))
@ -100,6 +84,7 @@ func NewConfig() *Config {
viper.SetDefault("oauth2.endpointauthurl", os.Getenv("OAUTH2_ENDPOINT_AUTH_URL")) viper.SetDefault("oauth2.endpointauthurl", os.Getenv("OAUTH2_ENDPOINT_AUTH_URL"))
viper.SetDefault("oauth2.endpointuserinfourl", os.Getenv("OAUTH2_ENDPOINT_USER_INFO_URL")) viper.SetDefault("oauth2.endpointuserinfourl", os.Getenv("OAUTH2_ENDPOINT_USER_INFO_URL"))
viper.SetDefault("oauth2.endpointlogouturl", GetEnvOrDefault("OAUTH2_ENDPOINT_LOGOUT_URL", "")) viper.SetDefault("oauth2.endpointlogouturl", GetEnvOrDefault("OAUTH2_ENDPOINT_LOGOUT_URL", ""))
viper.SetDefault("worker.token", os.Getenv("WORKER_TOKEN"))
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {

View file

@ -15,6 +15,7 @@ func ConnectToDatabase(path string) (*gorm.DB, *query.Query, error) {
} }
err = db.AutoMigrate( err = db.AutoMigrate(
models.Worker{},
models.HealthcheckHttp{}, models.HealthcheckHttp{},
models.HealthcheckHttpHistory{}, models.HealthcheckHttpHistory{},
models.HealthcheckTcp{}, models.HealthcheckTcp{},

View file

@ -35,6 +35,7 @@ var SettingsPages = []*components.Page{
{Path: "/settings/healthchecks/create", Title: "Healthchecks Create", Breadcrumb: "Create"}, {Path: "/settings/healthchecks/create", Title: "Healthchecks Create", Breadcrumb: "Create"},
{Path: "/settings/cronjobs", Title: "Cronjobs", Breadcrumb: "Cronjobs"}, {Path: "/settings/cronjobs", Title: "Cronjobs", Breadcrumb: "Cronjobs"},
{Path: "/settings/workers", Title: "Workers", Breadcrumb: "Workers"}, {Path: "/settings/workers", Title: "Workers", Breadcrumb: "Workers"},
{Path: "/settings/workers/create", Title: "Workers Create", Breadcrumb: "Create"},
{Path: "/temporal", Title: "Temporal", Breadcrumb: "Temporal"}, {Path: "/temporal", Title: "Temporal", Breadcrumb: "Temporal"},
{Path: "/oauth2/logout", Title: "Logout", Breadcrumb: "Logout"}, {Path: "/oauth2/logout", Title: "Logout", Breadcrumb: "Logout"},
} }

View file

@ -10,6 +10,7 @@ import (
"code.tjo.space/mentos1386/zdravko/internal/services" "code.tjo.space/mentos1386/zdravko/internal/services"
"code.tjo.space/mentos1386/zdravko/web/templates" "code.tjo.space/mentos1386/zdravko/web/templates"
"code.tjo.space/mentos1386/zdravko/web/templates/components" "code.tjo.space/mentos1386/zdravko/web/templates/components"
"github.com/go-playground/validator/v10"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gosimple/slug" "github.com/gosimple/slug"
) )
@ -130,7 +131,12 @@ func (h *BaseHandler) SettingsHealthchecksCreatePOST(w http.ResponseWriter, r *h
Method: r.FormValue("method"), Method: r.FormValue("method"),
} }
err := services.CreateHealthcheckHttp( err := validator.New(validator.WithRequiredStructEnabled()).Struct(healthcheckHttp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
err = services.CreateHealthcheckHttp(
ctx, ctx,
h.db, h.db,
healthcheckHttp, healthcheckHttp,

View file

@ -0,0 +1,164 @@
package handlers
import (
"context"
"fmt"
"net/http"
"text/template"
"code.tjo.space/mentos1386/zdravko/internal/jwt"
"code.tjo.space/mentos1386/zdravko/internal/models"
"code.tjo.space/mentos1386/zdravko/internal/services"
"code.tjo.space/mentos1386/zdravko/web/templates"
"code.tjo.space/mentos1386/zdravko/web/templates/components"
"github.com/go-playground/validator/v10"
"github.com/gorilla/mux"
"github.com/gosimple/slug"
)
type SettingsWorkers struct {
*Settings
Workers []*models.Worker
WorkersLength int
}
type SettingsWorker struct {
*Settings
Worker *models.Worker
}
func (h *BaseHandler) SettingsWorkersGET(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
ts, err := template.ParseFS(templates.Templates,
"components/base.tmpl",
"components/settings.tmpl",
"pages/settings_workers.tmpl",
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
workers, err := h.query.Worker.WithContext(context.Background()).Find()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
err = ts.ExecuteTemplate(w, "base", &SettingsWorkers{
Settings: NewSettings(
user,
GetPageByTitle(SettingsPages, "Workers"),
[]*components.Page{GetPageByTitle(SettingsPages, "Workers")},
),
Workers: workers,
WorkersLength: len(workers),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *BaseHandler) SettingsWorkersDescribeGET(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
vars := mux.Vars(r)
slug := vars["slug"]
ts, err := template.ParseFS(templates.Templates,
"components/base.tmpl",
"components/settings.tmpl",
"pages/settings_workers_describe.tmpl",
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
worker, err := services.GetWorker(context.Background(), h.query, slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
err = ts.ExecuteTemplate(w, "base", &SettingsWorker{
Settings: NewSettings(
user,
GetPageByTitle(SettingsPages, "Workers"),
[]*components.Page{
GetPageByTitle(SettingsPages, "Workers"),
{
Path: fmt.Sprintf("/settings/workers/%s", slug),
Title: "Describe",
Breadcrumb: worker.Name,
},
}),
Worker: worker,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *BaseHandler) SettingsWorkersCreateGET(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
ts, err := template.ParseFS(templates.Templates,
"components/base.tmpl",
"components/settings.tmpl",
"pages/settings_workers_create.tmpl",
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = ts.ExecuteTemplate(w, "base", NewSettings(
user,
GetPageByTitle(SettingsPages, "Workers"),
[]*components.Page{
GetPageByTitle(SettingsPages, "Workers"),
GetPageByTitle(SettingsPages, "Workers Create"),
},
))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *BaseHandler) SettingsWorkersCreatePOST(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
ctx := context.Background()
worker := &models.Worker{
Name: r.FormValue("name"),
Slug: slug.Make(r.FormValue("name")),
}
err := validator.New(validator.WithRequiredStructEnabled()).Struct(worker)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
err = services.CreateWorker(
ctx,
h.db,
worker,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
http.Redirect(w, r, "/settings/workers", http.StatusSeeOther)
}
func (h *BaseHandler) SettingsWorkersTokenGET(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
vars := mux.Vars(r)
slug := vars["slug"]
worker, err := services.GetWorker(context.Background(), h.query, slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// Allow write access to default namespace
token, err := jwt.NewToken(h.config, []string{"default:write"}, worker.Slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token": "` + token + `"}`))
}

64
internal/jwt/jwt.go Normal file
View file

@ -0,0 +1,64 @@
package jwt
import (
"crypto/rsa"
"crypto/sha256"
"encoding/hex"
"time"
"code.tjo.space/mentos1386/zdravko/internal/config"
"github.com/golang-jwt/jwt/v5"
)
func JwtPublicKeyID(key *rsa.PublicKey) string {
hash := sha256.Sum256(key.N.Bytes())
return hex.EncodeToString(hash[:])
}
func JwtPrivateKey(c *config.Config) (*rsa.PrivateKey, error) {
return jwt.ParseRSAPrivateKeyFromPEM([]byte(c.Jwt.PrivateKey))
}
func JwtPublicKey(c *config.Config) (*rsa.PublicKey, error) {
return jwt.ParseRSAPublicKeyFromPEM([]byte(c.Jwt.PublicKey))
}
// Ref: https://docs.temporal.io/self-hosted-guide/security#authorization
func NewToken(config *config.Config, permissions []string, subject string) (string, error) {
privateKey, err := JwtPrivateKey(config)
if err != nil {
return "", err
}
publicKey, err := JwtPublicKey(config)
if err != nil {
return "", err
}
type WorkerClaims struct {
jwt.RegisteredClaims
Permissions []string `json:"permissions"`
}
// Create claims with multiple fields populated
claims := WorkerClaims{
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * 30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "zdravko",
Subject: subject,
},
permissions,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = JwtPublicKeyID(publicKey)
signedToken, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return signedToken, nil
}

View file

@ -11,27 +11,34 @@ type OAuth2State struct {
Expiry time.Time Expiry time.Time
} }
type Worker struct {
gorm.Model
Name string `gorm:"unique" validate:"required"`
Slug string `gorm:"unique"`
Status string
}
type Healthcheck struct { type Healthcheck struct {
gorm.Model gorm.Model
Slug string `gorm:"unique"` Slug string `gorm:"unique"`
Name string `gorm:"unique"` Name string `gorm:"unique" validate:"required"`
Status string // UP, DOWN Status string // UP, DOWN
UptimePercentage float64 UptimePercentage float64
Schedule string Schedule string `validate:"required,cron"`
} }
type HealthcheckHttp struct { type HealthcheckHttp struct {
gorm.Model gorm.Model
Healthcheck Healthcheck
Url string Url string `validate:"required,url"`
Method string Method string `validate:"required,oneof=GET POST"`
} }
type HealthcheckTcp struct { type HealthcheckTcp struct {
gorm.Model gorm.Model
Healthcheck Healthcheck
Hostname string Hostname string `validate:"required,hostname"`
Port int Port int `validate:"required,gte=1,lte=65535"`
} }
type Cronjob struct { type Cronjob struct {

View file

@ -24,6 +24,7 @@ var (
HealthcheckTcp *healthcheckTcp HealthcheckTcp *healthcheckTcp
HealthcheckTcpHistory *healthcheckTcpHistory HealthcheckTcpHistory *healthcheckTcpHistory
OAuth2State *oAuth2State OAuth2State *oAuth2State
Worker *worker
) )
func SetDefault(db *gorm.DB, opts ...gen.DOOption) { func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
@ -35,6 +36,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
HealthcheckTcp = &Q.HealthcheckTcp HealthcheckTcp = &Q.HealthcheckTcp
HealthcheckTcpHistory = &Q.HealthcheckTcpHistory HealthcheckTcpHistory = &Q.HealthcheckTcpHistory
OAuth2State = &Q.OAuth2State OAuth2State = &Q.OAuth2State
Worker = &Q.Worker
} }
func Use(db *gorm.DB, opts ...gen.DOOption) *Query { func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
@ -47,6 +49,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
HealthcheckTcp: newHealthcheckTcp(db, opts...), HealthcheckTcp: newHealthcheckTcp(db, opts...),
HealthcheckTcpHistory: newHealthcheckTcpHistory(db, opts...), HealthcheckTcpHistory: newHealthcheckTcpHistory(db, opts...),
OAuth2State: newOAuth2State(db, opts...), OAuth2State: newOAuth2State(db, opts...),
Worker: newWorker(db, opts...),
} }
} }
@ -60,6 +63,7 @@ type Query struct {
HealthcheckTcp healthcheckTcp HealthcheckTcp healthcheckTcp
HealthcheckTcpHistory healthcheckTcpHistory HealthcheckTcpHistory healthcheckTcpHistory
OAuth2State oAuth2State OAuth2State oAuth2State
Worker worker
} }
func (q *Query) Available() bool { return q.db != nil } func (q *Query) Available() bool { return q.db != nil }
@ -74,6 +78,7 @@ func (q *Query) clone(db *gorm.DB) *Query {
HealthcheckTcp: q.HealthcheckTcp.clone(db), HealthcheckTcp: q.HealthcheckTcp.clone(db),
HealthcheckTcpHistory: q.HealthcheckTcpHistory.clone(db), HealthcheckTcpHistory: q.HealthcheckTcpHistory.clone(db),
OAuth2State: q.OAuth2State.clone(db), OAuth2State: q.OAuth2State.clone(db),
Worker: q.Worker.clone(db),
} }
} }
@ -95,6 +100,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
HealthcheckTcp: q.HealthcheckTcp.replaceDB(db), HealthcheckTcp: q.HealthcheckTcp.replaceDB(db),
HealthcheckTcpHistory: q.HealthcheckTcpHistory.replaceDB(db), HealthcheckTcpHistory: q.HealthcheckTcpHistory.replaceDB(db),
OAuth2State: q.OAuth2State.replaceDB(db), OAuth2State: q.OAuth2State.replaceDB(db),
Worker: q.Worker.replaceDB(db),
} }
} }
@ -106,6 +112,7 @@ type queryCtx struct {
HealthcheckTcp IHealthcheckTcpDo HealthcheckTcp IHealthcheckTcpDo
HealthcheckTcpHistory IHealthcheckTcpHistoryDo HealthcheckTcpHistory IHealthcheckTcpHistoryDo
OAuth2State IOAuth2StateDo OAuth2State IOAuth2StateDo
Worker IWorkerDo
} }
func (q *Query) WithContext(ctx context.Context) *queryCtx { func (q *Query) WithContext(ctx context.Context) *queryCtx {
@ -117,6 +124,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
HealthcheckTcp: q.HealthcheckTcp.WithContext(ctx), HealthcheckTcp: q.HealthcheckTcp.WithContext(ctx),
HealthcheckTcpHistory: q.HealthcheckTcpHistory.WithContext(ctx), HealthcheckTcpHistory: q.HealthcheckTcpHistory.WithContext(ctx),
OAuth2State: q.OAuth2State.WithContext(ctx), OAuth2State: q.OAuth2State.WithContext(ctx),
Worker: q.Worker.WithContext(ctx),
} }
} }

View file

@ -0,0 +1,412 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package query
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"code.tjo.space/mentos1386/zdravko/internal/models"
)
func newWorker(db *gorm.DB, opts ...gen.DOOption) worker {
_worker := worker{}
_worker.workerDo.UseDB(db, opts...)
_worker.workerDo.UseModel(&models.Worker{})
tableName := _worker.workerDo.TableName()
_worker.ALL = field.NewAsterisk(tableName)
_worker.ID = field.NewUint(tableName, "id")
_worker.CreatedAt = field.NewTime(tableName, "created_at")
_worker.UpdatedAt = field.NewTime(tableName, "updated_at")
_worker.DeletedAt = field.NewField(tableName, "deleted_at")
_worker.Name = field.NewString(tableName, "name")
_worker.Slug = field.NewString(tableName, "slug")
_worker.Status = field.NewString(tableName, "status")
_worker.fillFieldMap()
return _worker
}
type worker struct {
workerDo workerDo
ALL field.Asterisk
ID field.Uint
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Name field.String
Slug field.String
Status field.String
fieldMap map[string]field.Expr
}
func (w worker) Table(newTableName string) *worker {
w.workerDo.UseTable(newTableName)
return w.updateTableName(newTableName)
}
func (w worker) As(alias string) *worker {
w.workerDo.DO = *(w.workerDo.As(alias).(*gen.DO))
return w.updateTableName(alias)
}
func (w *worker) updateTableName(table string) *worker {
w.ALL = field.NewAsterisk(table)
w.ID = field.NewUint(table, "id")
w.CreatedAt = field.NewTime(table, "created_at")
w.UpdatedAt = field.NewTime(table, "updated_at")
w.DeletedAt = field.NewField(table, "deleted_at")
w.Name = field.NewString(table, "name")
w.Slug = field.NewString(table, "slug")
w.Status = field.NewString(table, "status")
w.fillFieldMap()
return w
}
func (w *worker) WithContext(ctx context.Context) IWorkerDo { return w.workerDo.WithContext(ctx) }
func (w worker) TableName() string { return w.workerDo.TableName() }
func (w worker) Alias() string { return w.workerDo.Alias() }
func (w worker) Columns(cols ...field.Expr) gen.Columns { return w.workerDo.Columns(cols...) }
func (w *worker) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := w.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (w *worker) fillFieldMap() {
w.fieldMap = make(map[string]field.Expr, 7)
w.fieldMap["id"] = w.ID
w.fieldMap["created_at"] = w.CreatedAt
w.fieldMap["updated_at"] = w.UpdatedAt
w.fieldMap["deleted_at"] = w.DeletedAt
w.fieldMap["name"] = w.Name
w.fieldMap["slug"] = w.Slug
w.fieldMap["status"] = w.Status
}
func (w worker) clone(db *gorm.DB) worker {
w.workerDo.ReplaceConnPool(db.Statement.ConnPool)
return w
}
func (w worker) replaceDB(db *gorm.DB) worker {
w.workerDo.ReplaceDB(db)
return w
}
type workerDo struct{ gen.DO }
type IWorkerDo interface {
gen.SubQuery
Debug() IWorkerDo
WithContext(ctx context.Context) IWorkerDo
WithResult(fc func(tx gen.Dao)) gen.ResultInfo
ReplaceDB(db *gorm.DB)
ReadDB() IWorkerDo
WriteDB() IWorkerDo
As(alias string) gen.Dao
Session(config *gorm.Session) IWorkerDo
Columns(cols ...field.Expr) gen.Columns
Clauses(conds ...clause.Expression) IWorkerDo
Not(conds ...gen.Condition) IWorkerDo
Or(conds ...gen.Condition) IWorkerDo
Select(conds ...field.Expr) IWorkerDo
Where(conds ...gen.Condition) IWorkerDo
Order(conds ...field.Expr) IWorkerDo
Distinct(cols ...field.Expr) IWorkerDo
Omit(cols ...field.Expr) IWorkerDo
Join(table schema.Tabler, on ...field.Expr) IWorkerDo
LeftJoin(table schema.Tabler, on ...field.Expr) IWorkerDo
RightJoin(table schema.Tabler, on ...field.Expr) IWorkerDo
Group(cols ...field.Expr) IWorkerDo
Having(conds ...gen.Condition) IWorkerDo
Limit(limit int) IWorkerDo
Offset(offset int) IWorkerDo
Count() (count int64, err error)
Scopes(funcs ...func(gen.Dao) gen.Dao) IWorkerDo
Unscoped() IWorkerDo
Create(values ...*models.Worker) error
CreateInBatches(values []*models.Worker, batchSize int) error
Save(values ...*models.Worker) error
First() (*models.Worker, error)
Take() (*models.Worker, error)
Last() (*models.Worker, error)
Find() ([]*models.Worker, error)
FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*models.Worker, err error)
FindInBatches(result *[]*models.Worker, batchSize int, fc func(tx gen.Dao, batch int) error) error
Pluck(column field.Expr, dest interface{}) error
Delete(...*models.Worker) (info gen.ResultInfo, err error)
Update(column field.Expr, value interface{}) (info gen.ResultInfo, err error)
UpdateSimple(columns ...field.AssignExpr) (info gen.ResultInfo, err error)
Updates(value interface{}) (info gen.ResultInfo, err error)
UpdateColumn(column field.Expr, value interface{}) (info gen.ResultInfo, err error)
UpdateColumnSimple(columns ...field.AssignExpr) (info gen.ResultInfo, err error)
UpdateColumns(value interface{}) (info gen.ResultInfo, err error)
UpdateFrom(q gen.SubQuery) gen.Dao
Attrs(attrs ...field.AssignExpr) IWorkerDo
Assign(attrs ...field.AssignExpr) IWorkerDo
Joins(fields ...field.RelationField) IWorkerDo
Preload(fields ...field.RelationField) IWorkerDo
FirstOrInit() (*models.Worker, error)
FirstOrCreate() (*models.Worker, error)
FindByPage(offset int, limit int) (result []*models.Worker, count int64, err error)
ScanByPage(result interface{}, offset int, limit int) (count int64, err error)
Scan(result interface{}) (err error)
Returning(value interface{}, columns ...string) IWorkerDo
UnderlyingDB() *gorm.DB
schema.Tabler
}
func (w workerDo) Debug() IWorkerDo {
return w.withDO(w.DO.Debug())
}
func (w workerDo) WithContext(ctx context.Context) IWorkerDo {
return w.withDO(w.DO.WithContext(ctx))
}
func (w workerDo) ReadDB() IWorkerDo {
return w.Clauses(dbresolver.Read)
}
func (w workerDo) WriteDB() IWorkerDo {
return w.Clauses(dbresolver.Write)
}
func (w workerDo) Session(config *gorm.Session) IWorkerDo {
return w.withDO(w.DO.Session(config))
}
func (w workerDo) Clauses(conds ...clause.Expression) IWorkerDo {
return w.withDO(w.DO.Clauses(conds...))
}
func (w workerDo) Returning(value interface{}, columns ...string) IWorkerDo {
return w.withDO(w.DO.Returning(value, columns...))
}
func (w workerDo) Not(conds ...gen.Condition) IWorkerDo {
return w.withDO(w.DO.Not(conds...))
}
func (w workerDo) Or(conds ...gen.Condition) IWorkerDo {
return w.withDO(w.DO.Or(conds...))
}
func (w workerDo) Select(conds ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Select(conds...))
}
func (w workerDo) Where(conds ...gen.Condition) IWorkerDo {
return w.withDO(w.DO.Where(conds...))
}
func (w workerDo) Order(conds ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Order(conds...))
}
func (w workerDo) Distinct(cols ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Distinct(cols...))
}
func (w workerDo) Omit(cols ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Omit(cols...))
}
func (w workerDo) Join(table schema.Tabler, on ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Join(table, on...))
}
func (w workerDo) LeftJoin(table schema.Tabler, on ...field.Expr) IWorkerDo {
return w.withDO(w.DO.LeftJoin(table, on...))
}
func (w workerDo) RightJoin(table schema.Tabler, on ...field.Expr) IWorkerDo {
return w.withDO(w.DO.RightJoin(table, on...))
}
func (w workerDo) Group(cols ...field.Expr) IWorkerDo {
return w.withDO(w.DO.Group(cols...))
}
func (w workerDo) Having(conds ...gen.Condition) IWorkerDo {
return w.withDO(w.DO.Having(conds...))
}
func (w workerDo) Limit(limit int) IWorkerDo {
return w.withDO(w.DO.Limit(limit))
}
func (w workerDo) Offset(offset int) IWorkerDo {
return w.withDO(w.DO.Offset(offset))
}
func (w workerDo) Scopes(funcs ...func(gen.Dao) gen.Dao) IWorkerDo {
return w.withDO(w.DO.Scopes(funcs...))
}
func (w workerDo) Unscoped() IWorkerDo {
return w.withDO(w.DO.Unscoped())
}
func (w workerDo) Create(values ...*models.Worker) error {
if len(values) == 0 {
return nil
}
return w.DO.Create(values)
}
func (w workerDo) CreateInBatches(values []*models.Worker, batchSize int) error {
return w.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (w workerDo) Save(values ...*models.Worker) error {
if len(values) == 0 {
return nil
}
return w.DO.Save(values)
}
func (w workerDo) First() (*models.Worker, error) {
if result, err := w.DO.First(); err != nil {
return nil, err
} else {
return result.(*models.Worker), nil
}
}
func (w workerDo) Take() (*models.Worker, error) {
if result, err := w.DO.Take(); err != nil {
return nil, err
} else {
return result.(*models.Worker), nil
}
}
func (w workerDo) Last() (*models.Worker, error) {
if result, err := w.DO.Last(); err != nil {
return nil, err
} else {
return result.(*models.Worker), nil
}
}
func (w workerDo) Find() ([]*models.Worker, error) {
result, err := w.DO.Find()
return result.([]*models.Worker), err
}
func (w workerDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*models.Worker, err error) {
buf := make([]*models.Worker, 0, batchSize)
err = w.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (w workerDo) FindInBatches(result *[]*models.Worker, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return w.DO.FindInBatches(result, batchSize, fc)
}
func (w workerDo) Attrs(attrs ...field.AssignExpr) IWorkerDo {
return w.withDO(w.DO.Attrs(attrs...))
}
func (w workerDo) Assign(attrs ...field.AssignExpr) IWorkerDo {
return w.withDO(w.DO.Assign(attrs...))
}
func (w workerDo) Joins(fields ...field.RelationField) IWorkerDo {
for _, _f := range fields {
w = *w.withDO(w.DO.Joins(_f))
}
return &w
}
func (w workerDo) Preload(fields ...field.RelationField) IWorkerDo {
for _, _f := range fields {
w = *w.withDO(w.DO.Preload(_f))
}
return &w
}
func (w workerDo) FirstOrInit() (*models.Worker, error) {
if result, err := w.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*models.Worker), nil
}
}
func (w workerDo) FirstOrCreate() (*models.Worker, error) {
if result, err := w.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*models.Worker), nil
}
}
func (w workerDo) FindByPage(offset int, limit int) (result []*models.Worker, count int64, err error) {
result, err = w.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = w.Offset(-1).Limit(-1).Count()
return
}
func (w workerDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = w.Count()
if err != nil {
return
}
err = w.Offset(offset).Limit(limit).Scan(result)
return
}
func (w workerDo) Scan(result interface{}) (err error) {
return w.DO.Scan(result)
}
func (w workerDo) Delete(models ...*models.Worker) (result gen.ResultInfo, err error) {
return w.DO.Delete(models)
}
func (w *workerDo) withDO(do gen.Dao) *workerDo {
w.DO = *do.(*gen.DO)
return w
}

View file

@ -0,0 +1,21 @@
package services
import (
"context"
"log"
"code.tjo.space/mentos1386/zdravko/internal/models"
"code.tjo.space/mentos1386/zdravko/internal/models/query"
"gorm.io/gorm"
)
func CreateWorker(ctx context.Context, db *gorm.DB, worker *models.Worker) error {
return db.WithContext(ctx).Create(worker).Error
}
func GetWorker(ctx context.Context, q *query.Query, slug string) (*models.Worker, error) {
log.Println("GetWorker")
return q.Worker.WithContext(ctx).Where(
q.Worker.Slug.Eq(slug),
).First()
}

View file

@ -1,18 +1,52 @@
package temporal package temporal
import ( import (
"context"
"time" "time"
"code.tjo.space/mentos1386/zdravko/internal/config" "code.tjo.space/mentos1386/zdravko/internal/config"
"code.tjo.space/mentos1386/zdravko/internal/jwt"
"code.tjo.space/mentos1386/zdravko/pkg/retry" "code.tjo.space/mentos1386/zdravko/pkg/retry"
"go.temporal.io/sdk/client" "go.temporal.io/sdk/client"
) )
func ConnectToTemporal(cfg *config.Config) (client.Client, error) { type AuthHeadersProvider struct {
Token string
}
func (p *AuthHeadersProvider) GetHeaders(ctx context.Context) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + p.Token,
}, nil
}
func ConnectServerToTemporal(cfg *config.Config) (client.Client, error) {
// For server we generate new token with admin permissions
token, err := jwt.NewToken(cfg, []string{"temporal-system:admin", "default:admin"}, "server")
if err != nil {
return nil, err
}
provider := &AuthHeadersProvider{token}
// Try to connect to the Temporal Server // Try to connect to the Temporal Server
return retry.Retry(5, 6*time.Second, func() (client.Client, error) { return retry.Retry(5, 6*time.Second, func() (client.Client, error) {
return client.Dial(client.Options{ return client.Dial(client.Options{
HostPort: cfg.Temporal.ServerHost, HostPort: cfg.Temporal.ServerHost,
HeadersProvider: provider,
})
})
}
func ConnectWorkerToTemporal(cfg *config.Config) (client.Client, error) {
provider := &AuthHeadersProvider{cfg.Worker.Token}
// Try to connect to the Temporal Server
return retry.Retry(5, 6*time.Second, func() (client.Client, error) {
return client.Dial(client.Options{
HostPort: cfg.Temporal.ServerHost,
HeadersProvider: provider,
Namespace: "default",
}) })
}) })
} }

View file

@ -3,6 +3,10 @@ set shell := ["devbox", "run"]
# Load dotenv # Load dotenv
set dotenv-load set dotenv-load
# Load public and private keys
export JWT_PRIVATE_KEY := `cat jwt.private.pem`
export JWT_PUBLIC_KEY := `cat jwt.public.pem`
GIT_SHA := `git rev-parse --short HEAD` GIT_SHA := `git rev-parse --short HEAD`
DOCKER_IMAGE := "ghcr.io/mentos1386/zdravko:sha-"+GIT_SHA DOCKER_IMAGE := "ghcr.io/mentos1386/zdravko:sha-"+GIT_SHA
STATIC_DIR := "./web/static" STATIC_DIR := "./web/static"
@ -11,6 +15,7 @@ STATIC_DIR := "./web/static"
build: build:
docker build -f build/Dockerfile -t {{DOCKER_IMAGE}} . docker build -f build/Dockerfile -t {{DOCKER_IMAGE}} .
# Run Docker application.
run-docker: run-docker:
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-e SESSION_SECRET \ -e SESSION_SECRET \
@ -26,10 +31,19 @@ run-docker:
run: run:
devbox services up devbox services up
run-worker:
go build -o dist/zdravko cmd/zdravko/main.go
./dist/zdravko --worker=true --server=false --temporal=false
# Generates new jwt key pair
generate-jwt-key:
openssl genrsa -out jwt.private.pem 2048
openssl rsa -pubout -in jwt.private.pem -out jwt.public.pem
# Start zdravko # Start zdravko
run-zdravko: run-zdravko:
go build -o dist/zdravko cmd/zdravko/main.go go build -o dist/zdravko cmd/zdravko/main.go
./dist/zdravko ./dist/zdravko --worker=false
# Deploy the application to fly.io # Deploy the application to fly.io
deploy: deploy:

View file

@ -37,10 +37,11 @@ func (s *Server) Start() error {
} }
log.Println("Connected to database") log.Println("Connected to database")
temporalClient, err := temporal.ConnectToTemporal(s.cfg) temporalClient, err := temporal.ConnectServerToTemporal(s.cfg)
if err != nil { if err != nil {
return err return err
} }
log.Println("Connected to Temporal")
h := handlers.NewBaseHandler(db, query, temporalClient, s.cfg) h := handlers.NewBaseHandler(db, query, temporalClient, s.cfg)
@ -71,6 +72,11 @@ func (s *Server) Start() error {
r.HandleFunc("/settings/healthchecks/create", h.Authenticated(h.SettingsHealthchecksCreateGET)).Methods("GET") r.HandleFunc("/settings/healthchecks/create", h.Authenticated(h.SettingsHealthchecksCreateGET)).Methods("GET")
r.HandleFunc("/settings/healthchecks/create", h.Authenticated(h.SettingsHealthchecksCreatePOST)).Methods("POST") r.HandleFunc("/settings/healthchecks/create", h.Authenticated(h.SettingsHealthchecksCreatePOST)).Methods("POST")
r.HandleFunc("/settings/healthchecks/{slug}", h.Authenticated(h.SettingsHealthchecksDescribeGET)).Methods("GET") r.HandleFunc("/settings/healthchecks/{slug}", h.Authenticated(h.SettingsHealthchecksDescribeGET)).Methods("GET")
r.HandleFunc("/settings/workers", h.Authenticated(h.SettingsWorkersGET)).Methods("GET")
r.HandleFunc("/settings/workers/create", h.Authenticated(h.SettingsWorkersCreateGET)).Methods("GET")
r.HandleFunc("/settings/workers/create", h.Authenticated(h.SettingsWorkersCreatePOST)).Methods("POST")
r.HandleFunc("/settings/workers/{slug}", h.Authenticated(h.SettingsWorkersDescribeGET)).Methods("GET")
r.HandleFunc("/settings/workers/{slug}/token", h.Authenticated(h.SettingsWorkersTokenGET)).Methods("GET")
// OAuth2 // OAuth2
r.HandleFunc("/oauth2/login", h.OAuth2LoginGET).Methods("GET") r.HandleFunc("/oauth2/login", h.OAuth2LoginGET).Methods("GET")

View file

@ -1,10 +1,13 @@
package temporal package temporal
import ( import (
"crypto/ecdsa"
"crypto/rsa"
"fmt" "fmt"
"time" "time"
internal "code.tjo.space/mentos1386/zdravko/internal/config" internal "code.tjo.space/mentos1386/zdravko/internal/config"
"code.tjo.space/mentos1386/zdravko/internal/jwt"
"go.temporal.io/server/common/cluster" "go.temporal.io/server/common/cluster"
"go.temporal.io/server/common/config" "go.temporal.io/server/common/config"
"go.temporal.io/server/common/persistence/sql/sqlplugin/sqlite" "go.temporal.io/server/common/persistence/sql/sqlplugin/sqlite"
@ -20,6 +23,29 @@ const HistoryPort = 7234
const MatchingPort = 7235 const MatchingPort = 7235
const WorkerPort = 7236 const WorkerPort = 7236
type TokenKeyProvider struct {
config *internal.Config
}
func (p *TokenKeyProvider) SupportedMethods() []string {
return []string{"RS256", "RS384", "RS512"}
}
func (p *TokenKeyProvider) HmacKey(alg string, kid string) ([]byte, error) {
return nil, fmt.Errorf("HMAC key is not supported")
}
func (p *TokenKeyProvider) EcdsaKey(alg string, kid string) (*ecdsa.PublicKey, error) {
return nil, fmt.Errorf("ECDSA key is not supported")
}
func (p *TokenKeyProvider) RsaKey(alg string, kid string) (*rsa.PublicKey, error) {
return jwt.JwtPublicKey(p.config)
}
func (p *TokenKeyProvider) Close() {
}
func NewServerConfig(cfg *internal.Config) *config.Config { func NewServerConfig(cfg *internal.Config) *config.Config {
return &config.Config{ return &config.Config{
Persistence: config.Persistence{ Persistence: config.Persistence{
@ -42,6 +68,7 @@ func NewServerConfig(cfg *internal.Config) *config.Config {
MaxJoinDuration: 30 * time.Second, MaxJoinDuration: 30 * time.Second,
BroadcastAddress: BroadcastAddress, BroadcastAddress: BroadcastAddress,
}, },
Authorization: config.Authorization{},
}, },
Services: map[string]config.Service{ Services: map[string]config.Service{
"frontend": { "frontend": {
@ -69,14 +96,6 @@ func NewServerConfig(cfg *internal.Config) *config.Config {
BindOnIP: "", BindOnIP: "",
}, },
}, },
"worker": {
RPC: config.RPC{
GRPCPort: WorkerPort,
MembershipPort: WorkerPort + 100,
BindOnLocalHost: true,
BindOnIP: "",
},
},
}, },
ClusterMetadata: &cluster.Config{ ClusterMetadata: &cluster.Config{
EnableGlobalNamespace: false, EnableGlobalNamespace: false,

View file

@ -8,11 +8,12 @@ import (
"go.temporal.io/server/common/authorization" "go.temporal.io/server/common/authorization"
"go.temporal.io/server/common/config" "go.temporal.io/server/common/config"
"go.temporal.io/server/common/log" "go.temporal.io/server/common/log"
"go.temporal.io/server/common/primitives"
"go.temporal.io/server/schema/sqlite" "go.temporal.io/server/schema/sqlite"
t "go.temporal.io/server/temporal" t "go.temporal.io/server/temporal"
) )
func NewServer(cfg *config.Config) (t.Server, error) { func NewServer(cfg *config.Config, tokenKeyProvider authorization.TokenKeyProvider) (t.Server, error) {
logger := log.NewZapLogger(log.BuildZapLogger(log.Config{ logger := log.NewZapLogger(log.BuildZapLogger(log.Config{
Stdout: true, Stdout: true,
Level: "info", Level: "info",
@ -43,15 +44,8 @@ func NewServer(cfg *config.Config) (t.Server, error) {
return nil, err return nil, err
} }
authorizer, err := authorization.GetAuthorizerFromConfig(&cfg.Global.Authorization) authorizer := authorization.NewDefaultAuthorizer()
if err != nil { claimMapper := authorization.NewDefaultJWTClaimMapper(tokenKeyProvider, &cfg.Global.Authorization, logger)
return nil, err
}
claimMapper, err := authorization.GetClaimMapperFromConfig(&cfg.Global.Authorization, logger)
if err != nil {
return nil, err
}
ctx := context.Background() ctx := context.Background()
interruptChan := make(chan interface{}, 1) interruptChan := make(chan interface{}, 1)
@ -67,12 +61,16 @@ func NewServer(cfg *config.Config) (t.Server, error) {
return t.NewServer( return t.NewServer(
t.WithConfig(cfg), t.WithConfig(cfg),
t.ForServices(t.DefaultServices), t.ForServices([]string{
string(primitives.FrontendService),
string(primitives.HistoryService),
string(primitives.MatchingService),
}),
t.WithLogger(logger), t.WithLogger(logger),
t.InterruptOn(interruptChan),
t.WithAuthorizer(authorizer), t.WithAuthorizer(authorizer),
t.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { t.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper {
return claimMapper return claimMapper
}), }),
t.InterruptOn(interruptChan),
) )
} }

View file

@ -13,7 +13,8 @@ type Temporal struct {
func NewTemporal(cfg *config.Config) (*Temporal, error) { func NewTemporal(cfg *config.Config) (*Temporal, error) {
serverConfig := NewServerConfig(cfg) serverConfig := NewServerConfig(cfg)
server, err := NewServer(serverConfig) tokenKeyProvider := TokenKeyProvider{config: cfg}
server, err := NewServer(serverConfig, &tokenKeyProvider)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -45,11 +46,12 @@ func (t *Temporal) Start() error {
} }
func (t *Temporal) Stop() error { func (t *Temporal) Stop() error {
t.uiServer.Stop()
err := t.server.Stop() err := t.server.Stop()
if err != nil { if err != nil {
return err return err
} }
t.uiServer.Stop()
return nil return nil
} }

View file

@ -24,14 +24,14 @@ func (w *Worker) Name() string {
} }
func (w *Worker) Start() error { func (w *Worker) Start() error {
temporalClient, err := temporal.ConnectToTemporal(w.cfg) temporalClient, err := temporal.ConnectWorkerToTemporal(w.cfg)
if err != nil { if err != nil {
return err return err
} }
// Create a new Worker // Create a new Worker
// TODO: Maybe identify by region or something? // TODO: Maybe identify by region or something?
w.worker = worker.New(temporalClient, "default", worker.Options{}) w.worker = worker.New(temporalClient, "test", worker.Options{})
// Register Workflows // Register Workflows
w.worker.RegisterWorkflow(workflows.HealthcheckHttpWorkflowDefinition) w.worker.RegisterWorkflow(workflows.HealthcheckHttpWorkflowDefinition)

View file

@ -29,6 +29,7 @@ func main() {
// Generate default DAO interface for those specified structs // Generate default DAO interface for those specified structs
g.ApplyBasic( g.ApplyBasic(
models.Worker{},
models.HealthcheckHttp{}, models.HealthcheckHttp{},
models.HealthcheckHttpHistory{}, models.HealthcheckHttpHistory{},
models.HealthcheckTcp{}, models.HealthcheckTcp{},

View file

@ -26,8 +26,7 @@
List of Healthchecks List of Healthchecks
<div class="mt-1 flex"> <div class="mt-1 flex">
<p class="mt-1 text-sm font-normal text-gray-500"> <p class="mt-1 text-sm font-normal text-gray-500">
Healthchecks represent periodic checks of some HTTP or TCP service, to see if it's {{ $description }}
responding correctly to deterime if it's healthy or not.
</p> </p>
<a href="/settings/healthchecks/create" class="inline-flex justify-center items-center py-1 px-2 text-sm font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"> <a href="/settings/healthchecks/create" class="inline-flex justify-center items-center py-1 px-2 text-sm font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300">
Create New Create New

View file

@ -0,0 +1,68 @@
{{define "settings"}}
{{ $description := "Workers are executing healthchecks. You can deploy multiple of thems to multiple regions for wider coverage." }}
{{ if eq .WorkersLength 0 }}
<section>
<div class="py-8 px-4 mx-auto max-w-screen-xl text-center lg:py-16">
<h1 class="mb-4 text-2xl font-extrabold tracking-tight leading-none text-gray-900 md:text-3xl lg:text-4xl">
There are no workers yet.
</h1>
<p class="mb-8 text-l font-normal text-gray-500 lg:text-l sm:px-8 lg:px-40">
{{ $description }}
</p>
<div class="flex flex-col space-y-4 sm:flex-row sm:justify-center sm:space-y-0">
<a href="/settings/workers/create" class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300">
Create First Worker
<svg class="feather ml-1 h-5 w-5 overflow-visible"><use href="/static/icons/feather-sprite.svg#plus" /></svg>
</a>
</div>
</div>
</section>
{{ else }}
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500">
<caption class="p-5 text-lg font-semibold text-left rtl:text-right text-gray-900 bg-white">
List of Workers
<div class="mt-1 flex">
<p class="mt-1 text-sm font-normal text-gray-500">
{{ $description }}
</p>
<a href="/settings/workers/create" class="inline-flex justify-center items-center py-1 px-2 text-sm font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300">
Create New
<svg class="feather h-5 w-5 overflow-visible"><use href="/static/icons/feather-sprite.svg#plus" /></svg>
</a>
</div>
</caption>
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
Status
</th>
<th scope="col" class="px-6 py-3">
Action
</th>
</tr>
</thead>
{{range .Workers}}
<tbody>
<tr class="odd:bg-white even:bg-gray-50">
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap">
{{.Name}}
</th>
<td class="px-6 py-4">
OK
</td>
<td class="px-6 py-4">
<a href="/settings/workers/{{.Slug}}" class="font-medium text-blue-600 hover:underline">Details</a>
</td>
</tr>
</tbody>
{{end}}
</table>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,14 @@
{{define "settings"}}
<section class="relative overflow-x-auto shadow-md sm:rounded-lg p-5 text-gray-500 bg-white">
<h1 class="text-lg font-semibold text-gray-900">
Creating new worker.
</h1>
<form class="max-w-sm mt-4" action="/settings/workers/create" method="post">
<div class="mb-5">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900">Name</label>
<input type="name" name="name" id="name" placeholder="FooBar" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"/>
</div>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center">Create</button>
</form>
</section>
{{end}}

View file

@ -1,27 +0,0 @@
healthchecks:
- name: "Google"
http:
url: "https://www.google.com"
method: GET
timeout: 5s
schedule: "* * * * *"
retries: 3
- name: "GitHub"
http:
url: "https://www.github.com"
method: GET
timeout: 5s
schedule: "* * * * *"
retries: 3
- name: "Docker"
tcp:
hostname: "docker.com"
port: 443
schedule: "* * * * *"
timeout: 60s
retries: 3
cronjobs:
- name: "Backup"
schedule: "0 0 * * *"
buffer: 1h