From 478232bcda61c1a8f50064b5e2976ba23026ac7e Mon Sep 17 00:00:00 2001 From: Tine Date: Sun, 18 Feb 2024 22:37:17 +0100 Subject: [PATCH] feat(workers): initial worker authentication and provisioning --- .github/workflows/deploy.yaml | 8 + .gitignore | 3 + README.md | 3 + build/Dockerfile | 4 - cmd/zdravko/main.go | 5 +- go.mod | 9 +- go.sum | 8 +- internal/config/config.go | 39 +- internal/database.go | 1 + internal/handlers/settings.go | 1 + internal/handlers/settingshealthchecks.go | 8 +- internal/handlers/settingsworkers.go | 164 +++++++ internal/jwt/jwt.go | 64 +++ internal/models/models.go | 19 +- internal/models/query/gen.go | 8 + internal/models/query/workers.gen.go | 412 ++++++++++++++++++ internal/services/worker.go | 21 + internal/temporal/temporal.go | 38 +- justfile | 16 +- pkg/server/server.go | 8 +- pkg/temporal/config.go | 35 +- pkg/temporal/server.go | 22 +- pkg/temporal/temporal.go | 6 +- pkg/worker/worker.go | 4 +- tools/generate/main.go | 1 + .../pages/settings_healthchecks.tmpl | 3 +- web/templates/pages/settings_workers.tmpl | 68 +++ .../pages/settings_workers_create.tmpl | 14 + zdravko.yaml | 27 -- 29 files changed, 916 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/deploy.yaml create mode 100644 internal/handlers/settingsworkers.go create mode 100644 internal/jwt/jwt.go create mode 100644 internal/models/query/workers.gen.go create mode 100644 internal/services/worker.go create mode 100644 web/templates/pages/settings_workers.tmpl create mode 100644 web/templates/pages/settings_workers_create.tmpl delete mode 100644 zdravko.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..1ca62ab --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,8 @@ +name: Deploy +on: + push: + branches: + - main + +jobs: + diff --git a/.gitignore b/.gitignore index 6aeabed..2afc22d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ zdravko.db temporal.db temporal.db-journal +# Keys +*.pem + # Config .env diff --git a/README.md b/README.md index cd3a593..a03c715 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ Demo is available at https://zdravko.fly.dev. # Configure cp example.env .env +# Generate JWT key +just generate-jwt-key + # Start development environment just run ``` diff --git a/build/Dockerfile b/build/Dockerfile index 05890f1..4cd456e 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -14,9 +14,6 @@ COPY . ./ # Build 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 FROM gcr.io/distroless/base-debian12:latest as production @@ -34,7 +31,6 @@ EXPOSE 7233 # Volume to persist sqlite databases VOLUME /data -COPY --from=builder --chown=nonroot:nonroot /data /data ENV DATABASE_PATH=/data/zdravko.db ENV TEMPORAL_DATABASE_PATH=/data/temporal.db diff --git a/cmd/zdravko/main.go b/cmd/zdravko/main.go index 9740f09..9678e82 100644 --- a/cmd/zdravko/main.go +++ b/cmd/zdravko/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "sync" + "syscall" "code.tjo.space/mentos1386/zdravko/internal/config" "code.tjo.space/mentos1386/zdravko/pkg/server" @@ -85,7 +86,7 @@ func main() { } c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) go func() { for sig := range c { log.Printf("Received signal: %v", sig) @@ -97,7 +98,7 @@ func main() { println("Stopping", srv.Name()) err := srv.Stop() 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) } } } diff --git a/go.mod b/go.mod index 1837468..eaf5698 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,13 @@ module code.tjo.space/mentos1386/zdravko go 1.21.6 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/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 go.temporal.io/sdk v1.26.0-rc.2 go.temporal.io/server v1.22.4 @@ -45,11 +50,9 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.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/gocql/gocql v1.5.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/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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/gax-go/v2 v2.12.0 // 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/grpc-ecosystem/go-grpc-middleware v1.4.0 // 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/cast v1.6.0 // 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/testify v1.8.4 // indirect github.com/subosito/gotenv v1.6.0 // indirect diff --git a/go.sum b/go.sum index e72e794..3a6ce44 100644 --- a/go.sum +++ b/go.sum @@ -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/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.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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 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/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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/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/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/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/config/config.go b/internal/config/config.go index cfc36aa..ae84f34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "log" "os" "strings" - "time" "github.com/go-playground/validator/v10" "github.com/spf13/viper" @@ -16,12 +15,17 @@ type Config struct { DatabasePath string `validate:"required"` SessionSecret string `validate:"required"` + Jwt Jwt `validate:"required"` OAuth2 OAuth2 `validate:"required"` Temporal Temporal `validate:"required"` - HealthChecks []Healthcheck - CronJobs []CronJob + Worker Worker `validate:"required"` +} + +type Jwt struct { + PrivateKey string `validate:"required"` + PublicKey string `validate:"required"` } type OAuth2 struct { @@ -41,30 +45,8 @@ type Temporal struct { ServerHost string `validate:"required"` } -type HealthCheckHTTP struct { - URL string `validate:"required,url"` - 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"` +type Worker struct { + Token string `validate:"required"` } 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.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("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.clientsecret", os.Getenv("OAUTH2_CLIENT_SECRET")) 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.endpointuserinfourl", os.Getenv("OAUTH2_ENDPOINT_USER_INFO_URL")) viper.SetDefault("oauth2.endpointlogouturl", GetEnvOrDefault("OAUTH2_ENDPOINT_LOGOUT_URL", "")) + viper.SetDefault("worker.token", os.Getenv("WORKER_TOKEN")) err := viper.ReadInConfig() if err != nil { diff --git a/internal/database.go b/internal/database.go index c551ee4..f85976a 100644 --- a/internal/database.go +++ b/internal/database.go @@ -15,6 +15,7 @@ func ConnectToDatabase(path string) (*gorm.DB, *query.Query, error) { } err = db.AutoMigrate( + models.Worker{}, models.HealthcheckHttp{}, models.HealthcheckHttpHistory{}, models.HealthcheckTcp{}, diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index e48e3e9..b85ab5e 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -35,6 +35,7 @@ var SettingsPages = []*components.Page{ {Path: "/settings/healthchecks/create", Title: "Healthchecks Create", Breadcrumb: "Create"}, {Path: "/settings/cronjobs", Title: "Cronjobs", Breadcrumb: "Cronjobs"}, {Path: "/settings/workers", Title: "Workers", Breadcrumb: "Workers"}, + {Path: "/settings/workers/create", Title: "Workers Create", Breadcrumb: "Create"}, {Path: "/temporal", Title: "Temporal", Breadcrumb: "Temporal"}, {Path: "/oauth2/logout", Title: "Logout", Breadcrumb: "Logout"}, } diff --git a/internal/handlers/settingshealthchecks.go b/internal/handlers/settingshealthchecks.go index 4225a1c..19aa08f 100644 --- a/internal/handlers/settingshealthchecks.go +++ b/internal/handlers/settingshealthchecks.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -130,7 +131,12 @@ func (h *BaseHandler) SettingsHealthchecksCreatePOST(w http.ResponseWriter, r *h 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, h.db, healthcheckHttp, diff --git a/internal/handlers/settingsworkers.go b/internal/handlers/settingsworkers.go new file mode 100644 index 0000000..ee986c6 --- /dev/null +++ b/internal/handlers/settingsworkers.go @@ -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 + `"}`)) +} diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go new file mode 100644 index 0000000..8e30044 --- /dev/null +++ b/internal/jwt/jwt.go @@ -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 +} diff --git a/internal/models/models.go b/internal/models/models.go index 1458adb..af13212 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,27 +11,34 @@ type OAuth2State struct { Expiry time.Time } +type Worker struct { + gorm.Model + Name string `gorm:"unique" validate:"required"` + Slug string `gorm:"unique"` + Status string +} + type Healthcheck struct { gorm.Model Slug string `gorm:"unique"` - Name string `gorm:"unique"` + Name string `gorm:"unique" validate:"required"` Status string // UP, DOWN UptimePercentage float64 - Schedule string + Schedule string `validate:"required,cron"` } type HealthcheckHttp struct { gorm.Model Healthcheck - Url string - Method string + Url string `validate:"required,url"` + Method string `validate:"required,oneof=GET POST"` } type HealthcheckTcp struct { gorm.Model Healthcheck - Hostname string - Port int + Hostname string `validate:"required,hostname"` + Port int `validate:"required,gte=1,lte=65535"` } type Cronjob struct { diff --git a/internal/models/query/gen.go b/internal/models/query/gen.go index 669695e..dc34a8b 100644 --- a/internal/models/query/gen.go +++ b/internal/models/query/gen.go @@ -24,6 +24,7 @@ var ( HealthcheckTcp *healthcheckTcp HealthcheckTcpHistory *healthcheckTcpHistory OAuth2State *oAuth2State + Worker *worker ) func SetDefault(db *gorm.DB, opts ...gen.DOOption) { @@ -35,6 +36,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { HealthcheckTcp = &Q.HealthcheckTcp HealthcheckTcpHistory = &Q.HealthcheckTcpHistory OAuth2State = &Q.OAuth2State + Worker = &Q.Worker } 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...), HealthcheckTcpHistory: newHealthcheckTcpHistory(db, opts...), OAuth2State: newOAuth2State(db, opts...), + Worker: newWorker(db, opts...), } } @@ -60,6 +63,7 @@ type Query struct { HealthcheckTcp healthcheckTcp HealthcheckTcpHistory healthcheckTcpHistory OAuth2State oAuth2State + Worker worker } 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), HealthcheckTcpHistory: q.HealthcheckTcpHistory.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), HealthcheckTcpHistory: q.HealthcheckTcpHistory.replaceDB(db), OAuth2State: q.OAuth2State.replaceDB(db), + Worker: q.Worker.replaceDB(db), } } @@ -106,6 +112,7 @@ type queryCtx struct { HealthcheckTcp IHealthcheckTcpDo HealthcheckTcpHistory IHealthcheckTcpHistoryDo OAuth2State IOAuth2StateDo + Worker IWorkerDo } 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), HealthcheckTcpHistory: q.HealthcheckTcpHistory.WithContext(ctx), OAuth2State: q.OAuth2State.WithContext(ctx), + Worker: q.Worker.WithContext(ctx), } } diff --git a/internal/models/query/workers.gen.go b/internal/models/query/workers.gen.go new file mode 100644 index 0000000..ecdb88f --- /dev/null +++ b/internal/models/query/workers.gen.go @@ -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 +} diff --git a/internal/services/worker.go b/internal/services/worker.go new file mode 100644 index 0000000..dda1188 --- /dev/null +++ b/internal/services/worker.go @@ -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() +} diff --git a/internal/temporal/temporal.go b/internal/temporal/temporal.go index 7138f70..e134423 100644 --- a/internal/temporal/temporal.go +++ b/internal/temporal/temporal.go @@ -1,18 +1,52 @@ package temporal import ( + "context" "time" "code.tjo.space/mentos1386/zdravko/internal/config" + "code.tjo.space/mentos1386/zdravko/internal/jwt" "code.tjo.space/mentos1386/zdravko/pkg/retry" "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 return retry.Retry(5, 6*time.Second, func() (client.Client, error) { 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", }) }) } diff --git a/justfile b/justfile index 7e39e80..9bc6220 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,10 @@ set shell := ["devbox", "run"] # Load dotenv 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` DOCKER_IMAGE := "ghcr.io/mentos1386/zdravko:sha-"+GIT_SHA STATIC_DIR := "./web/static" @@ -11,6 +15,7 @@ STATIC_DIR := "./web/static" build: docker build -f build/Dockerfile -t {{DOCKER_IMAGE}} . +# Run Docker application. run-docker: docker run -p 8080:8080 \ -e SESSION_SECRET \ @@ -26,10 +31,19 @@ run-docker: run: 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 run-zdravko: go build -o dist/zdravko cmd/zdravko/main.go - ./dist/zdravko + ./dist/zdravko --worker=false # Deploy the application to fly.io deploy: diff --git a/pkg/server/server.go b/pkg/server/server.go index 4764415..a30e9ae 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -37,10 +37,11 @@ func (s *Server) Start() error { } log.Println("Connected to database") - temporalClient, err := temporal.ConnectToTemporal(s.cfg) + temporalClient, err := temporal.ConnectServerToTemporal(s.cfg) if err != nil { return err } + log.Println("Connected to Temporal") 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.SettingsHealthchecksCreatePOST)).Methods("POST") 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 r.HandleFunc("/oauth2/login", h.OAuth2LoginGET).Methods("GET") diff --git a/pkg/temporal/config.go b/pkg/temporal/config.go index f6a72c3..86bbbdc 100644 --- a/pkg/temporal/config.go +++ b/pkg/temporal/config.go @@ -1,10 +1,13 @@ package temporal import ( + "crypto/ecdsa" + "crypto/rsa" "fmt" "time" 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/config" "go.temporal.io/server/common/persistence/sql/sqlplugin/sqlite" @@ -20,6 +23,29 @@ const HistoryPort = 7234 const MatchingPort = 7235 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 { return &config.Config{ Persistence: config.Persistence{ @@ -42,6 +68,7 @@ func NewServerConfig(cfg *internal.Config) *config.Config { MaxJoinDuration: 30 * time.Second, BroadcastAddress: BroadcastAddress, }, + Authorization: config.Authorization{}, }, Services: map[string]config.Service{ "frontend": { @@ -69,14 +96,6 @@ func NewServerConfig(cfg *internal.Config) *config.Config { BindOnIP: "", }, }, - "worker": { - RPC: config.RPC{ - GRPCPort: WorkerPort, - MembershipPort: WorkerPort + 100, - BindOnLocalHost: true, - BindOnIP: "", - }, - }, }, ClusterMetadata: &cluster.Config{ EnableGlobalNamespace: false, diff --git a/pkg/temporal/server.go b/pkg/temporal/server.go index aec59de..68d64dc 100644 --- a/pkg/temporal/server.go +++ b/pkg/temporal/server.go @@ -8,11 +8,12 @@ import ( "go.temporal.io/server/common/authorization" "go.temporal.io/server/common/config" "go.temporal.io/server/common/log" + "go.temporal.io/server/common/primitives" "go.temporal.io/server/schema/sqlite" 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{ Stdout: true, Level: "info", @@ -43,15 +44,8 @@ func NewServer(cfg *config.Config) (t.Server, error) { return nil, err } - authorizer, err := authorization.GetAuthorizerFromConfig(&cfg.Global.Authorization) - if err != nil { - return nil, err - } - - claimMapper, err := authorization.GetClaimMapperFromConfig(&cfg.Global.Authorization, logger) - if err != nil { - return nil, err - } + authorizer := authorization.NewDefaultAuthorizer() + claimMapper := authorization.NewDefaultJWTClaimMapper(tokenKeyProvider, &cfg.Global.Authorization, logger) ctx := context.Background() interruptChan := make(chan interface{}, 1) @@ -67,12 +61,16 @@ func NewServer(cfg *config.Config) (t.Server, error) { return t.NewServer( t.WithConfig(cfg), - t.ForServices(t.DefaultServices), + t.ForServices([]string{ + string(primitives.FrontendService), + string(primitives.HistoryService), + string(primitives.MatchingService), + }), t.WithLogger(logger), + t.InterruptOn(interruptChan), t.WithAuthorizer(authorizer), t.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { return claimMapper }), - t.InterruptOn(interruptChan), ) } diff --git a/pkg/temporal/temporal.go b/pkg/temporal/temporal.go index f1645c7..a44c1bb 100644 --- a/pkg/temporal/temporal.go +++ b/pkg/temporal/temporal.go @@ -13,7 +13,8 @@ type Temporal struct { func NewTemporal(cfg *config.Config) (*Temporal, error) { serverConfig := NewServerConfig(cfg) - server, err := NewServer(serverConfig) + tokenKeyProvider := TokenKeyProvider{config: cfg} + server, err := NewServer(serverConfig, &tokenKeyProvider) if err != nil { return nil, err } @@ -45,11 +46,12 @@ func (t *Temporal) Start() error { } func (t *Temporal) Stop() error { + t.uiServer.Stop() + 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 index 820e8ac..e83e446 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -24,14 +24,14 @@ func (w *Worker) Name() string { } func (w *Worker) Start() error { - temporalClient, err := temporal.ConnectToTemporal(w.cfg) + temporalClient, err := temporal.ConnectWorkerToTemporal(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{}) + w.worker = worker.New(temporalClient, "test", worker.Options{}) // Register Workflows w.worker.RegisterWorkflow(workflows.HealthcheckHttpWorkflowDefinition) diff --git a/tools/generate/main.go b/tools/generate/main.go index 7b1c1a1..7bd6b5f 100644 --- a/tools/generate/main.go +++ b/tools/generate/main.go @@ -29,6 +29,7 @@ func main() { // Generate default DAO interface for those specified structs g.ApplyBasic( + models.Worker{}, models.HealthcheckHttp{}, models.HealthcheckHttpHistory{}, models.HealthcheckTcp{}, diff --git a/web/templates/pages/settings_healthchecks.tmpl b/web/templates/pages/settings_healthchecks.tmpl index f08ef94..60668be 100644 --- a/web/templates/pages/settings_healthchecks.tmpl +++ b/web/templates/pages/settings_healthchecks.tmpl @@ -26,8 +26,7 @@ List of Healthchecks

- Healthchecks represent periodic checks of some HTTP or TCP service, to see if it's - responding correctly to deterime if it's healthy or not. + {{ $description }}

Create New diff --git a/web/templates/pages/settings_workers.tmpl b/web/templates/pages/settings_workers.tmpl new file mode 100644 index 0000000..74b97a6 --- /dev/null +++ b/web/templates/pages/settings_workers.tmpl @@ -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 }} +
+ +
+{{ else }} +
+ + + + + + + + + + {{range .Workers}} + + + + + + + + {{end}} +
+ List of Workers +
+

+ {{ $description }} +

+ + Create New + + +
+
+ Name + + Status + + Action +
+ {{.Name}} + + OK + + Details +
+
+{{end}} +{{end}} diff --git a/web/templates/pages/settings_workers_create.tmpl b/web/templates/pages/settings_workers_create.tmpl new file mode 100644 index 0000000..b652a1d --- /dev/null +++ b/web/templates/pages/settings_workers_create.tmpl @@ -0,0 +1,14 @@ +{{define "settings"}} +
+

+ Creating new worker. +

+
+
+ + +
+ +
+
+{{end}} diff --git a/zdravko.yaml b/zdravko.yaml deleted file mode 100644 index c133d7f..0000000 --- a/zdravko.yaml +++ /dev/null @@ -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