diff --git a/Dockerfile b/Dockerfile index 82411ffa..66a08500 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM golang:1.22.5-bookworm AS build-stage +FROM golang:1.23.0-bookworm AS build-stage WORKDIR /build diff --git a/Dockerfile.admin b/Dockerfile.admin new file mode 100644 index 00000000..d3b0bc0b --- /dev/null +++ b/Dockerfile.admin @@ -0,0 +1,45 @@ +# Copyright 2024 The Vitess Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM golang:1.23.0-bookworm AS build-stage + +WORKDIR /build + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +# Build arewefastyet +RUN CGO_ENABLED=0 GOOS=linux go build -o /arewefastyetcli ./go/main.go + +FROM debian:bookworm AS run-stage + +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --reinstall ca-certificates \ + && update-ca-certificates + +# Copy the source code to the working directory +COPY --from=build-stage /arewefastyetcli /arewefastyetcli +COPY --from=build-stage /build/go/admin/templates/ /go/admin/templates/ +COPY --from=build-stage /build/go/admin/assets/ /go/admin/assets/ + +EXPOSE 8081 + +# Configuration files MUST be attached to the container using a volume. +# The configuration files are not mounted on the Docker image for obvious +# security reasons. +CMD ["/arewefastyetcli", "admin", "--config", "/config/config.yaml", "--secrets", "/config/secrets.yaml"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9b3be6e2..26791c86 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,6 +13,9 @@ # limitations under the License. version: "3.8" +networks: + default: + driver: bridge services: traefik: @@ -31,7 +34,6 @@ services: volumes: - "/var/letsencrypt:/letsencrypt" - "/var/run/docker.sock:/var/run/docker.sock:ro" - network_mode: bridge frontend: build: @@ -46,7 +48,6 @@ services: - "traefik.http.routers.frontend.entrypoints=https" - "traefik.http.routers.frontend.tls.certresolver=tlsresolver" - "traefik.http.services.frontend.loadbalancer.server.port=3000" - network_mode: bridge api: restart: always @@ -68,7 +69,23 @@ services: - "traefik.http.routers.api.entrypoints=https" - "traefik.http.routers.api.tls.certresolver=tlsresolver" - "traefik.http.services.api.loadbalancer.server.port=8080" - network_mode: bridge + + admin: + restart: always + build: + context: . + dockerfile: Dockerfile.admin + image: "arewefastyet-admin" + container_name: "admin" + volumes: + - "./config/prod/config.yaml:/config/config.yaml" + - "./config/prod/secrets.yaml:/config/secrets.yaml" + labels: + - "traefik.enable=true" + - "traefik.http.routers.admin.rule=Host(`benchmark.vitess.io`) && PathPrefix(`/admin`)" + - "traefik.http.routers.admin.entrypoints=https" + - "traefik.http.routers.admin.tls.certresolver=tlsresolver" + - "traefik.http.services.admin.loadbalancer.server.port=8081" cleanup_executions: image: alpine diff --git a/docker-compose.yml b/docker-compose.yml index f74037fd..932f39f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ # limitations under the License. version: "3.8" +networks: + default: + driver: bridge services: traefik: @@ -29,7 +32,6 @@ services: - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock" - network_mode: bridge api: restart: always @@ -51,7 +53,23 @@ services: - "traefik.http.routers.api.rule=PathPrefix(`/api`)" - "traefik.http.routers.api.entrypoints=http" - "traefik.http.services.api.loadbalancer.server.port=8080" - network_mode: bridge + + admin: + restart: always + build: + context: . + dockerfile: Dockerfile.admin + image: "arewefastyet-admin" + container_name: "admin" + volumes: + - "./config/dev/config.yaml:/config/config.yaml" + - "./config/dev/secrets.yaml:/config/secrets.yaml" + labels: + - "traefik.enable=true" + - "traefik.http.routers.admin.rule=Host(`localhost`)" + - "traefik.http.routers.admin.rule=PathPrefix(`/admin`)" + - "traefik.http.routers.admin.entrypoints=http" + - "traefik.http.services.admin.loadbalancer.server.port=8081" frontend: build: @@ -67,7 +85,6 @@ services: - "traefik.http.routers.frontend.rule=Host(`localhost`)" - "traefik.http.routers.frontend.entrypoints=http" - "traefik.http.services.frontend.loadbalancer.server.port=5173" - network_mode: bridge cleanup_executions: image: alpine diff --git a/docs/arewefastyet_admin.md b/docs/arewefastyet_admin.md index 2f34f493..f8684036 100644 --- a/docs/arewefastyet_admin.md +++ b/docs/arewefastyet_admin.md @@ -9,6 +9,7 @@ arewefastyet admin [flags] ### Options ``` + --admin-auth string The salt string to salt the GitHub Token --admin-gh-app-id string The ID of the GitHub App --admin-gh-app-secret string The secret of the GitHub App --admin-mode string Specify the mode on which the server will run diff --git a/docs/arewefastyet_api.md b/docs/arewefastyet_api.md index 5666b5e8..c12dacea 100644 --- a/docs/arewefastyet_api.md +++ b/docs/arewefastyet_api.md @@ -9,6 +9,7 @@ arewefastyet api [flags] ### Options ``` + --admin-auth string The salt string to salt the GitHub Token --gh-app-id int ID of the GitHub App --gh-installation-id int GitHub installation ID of this app --gh-port string Port on which to run the github app (default "8181") diff --git a/go.mod b/go.mod index 2ce13af1..19a86d97 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/frankban/quicktest v1.14.6 github.com/gin-contrib/cors v1.7.2 - github.com/gin-contrib/sessions v1.0.1 github.com/gin-gonic/gin v1.10.0 github.com/go-sql-driver/mysql v1.8.1 github.com/google/go-github v17.0.0+incompatible @@ -62,9 +61,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/context v1.1.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.2.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect diff --git a/go.sum b/go.sum index c76f386a..4f8633b3 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= -github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI= -github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= @@ -83,16 +81,8 @@ github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vh github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= -github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/go/admin/admin.go b/go/admin/admin.go index f6642f6a..be397db5 100644 --- a/go/admin/admin.go +++ b/go/admin/admin.go @@ -21,13 +21,9 @@ package admin import ( "errors" "net/http" - "path/filepath" - "runtime" "time" "github.com/gin-contrib/cors" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -40,6 +36,7 @@ const ( flagMode = "admin-mode" flagAdminAppId = "admin-gh-app-id" flagAdminAppSecret = "admin-gh-app-secret" + flagGhAuth = "admin-auth" ) type Admin struct { @@ -48,6 +45,7 @@ type Admin struct { ghAppId string ghAppSecret string + auth string dbCfg *psdb.Config dbClient *psdb.Client @@ -60,11 +58,13 @@ func (a *Admin) AddToCommand(cmd *cobra.Command) { cmd.Flags().Var(&a.Mode, flagMode, "Specify the mode on which the server will run") cmd.Flags().StringVar(&a.ghAppId, flagAdminAppId, "", "The ID of the GitHub App") cmd.Flags().StringVar(&a.ghAppSecret, flagAdminAppSecret, "", "The secret of the GitHub App") + cmd.Flags().StringVar(&a.auth, flagGhAuth, "", "The salt string to salt the GitHub Token") _ = viper.BindPFlag(flagPort, cmd.Flags().Lookup(flagPort)) _ = viper.BindPFlag(flagMode, cmd.Flags().Lookup(flagMode)) _ = viper.BindPFlag(flagAdminAppId, cmd.Flags().Lookup(flagAdminAppId)) _ = viper.BindPFlag(flagAdminAppSecret, cmd.Flags().Lookup(flagAdminAppSecret)) + _ = viper.BindPFlag(flagGhAuth, cmd.Flags().Lookup(flagGhAuth)) if a.dbCfg == nil { a.dbCfg = &psdb.Config{} @@ -107,24 +107,18 @@ func (a *Admin) Run() error { return errors.New(server.ErrorIncorrectConfiguration) } - _, b, _, _ := runtime.Caller(0) - basepath := filepath.Dir(b) - a.Mode.SetGin() a.router = gin.Default() - store := cookie.NewStore([]byte("secret")) - a.router.Use(sessions.Sessions("mysession", store)) - - a.router.Static("/assets", filepath.Join(basepath, "assets")) + a.router.Static("/admin/assets", "/go/admin/assets") - a.router.LoadHTMLGlob(filepath.Join(basepath, "templates/*")) + a.router.LoadHTMLGlob("/go/admin/templates/*") a.router.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, - AllowMethods: []string{"GET"}, + AllowMethods: []string{"GET", "POST"}, AllowHeaders: []string{"Origin"}, - ExposeHeaders: []string{"Content-Length"}, + ExposeHeaders: []string{"Content-Length", "Content-Type"}, AllowCredentials: true, MaxAge: 12 * time.Hour, })) @@ -133,6 +127,7 @@ func (a *Admin) Run() error { a.router.GET("/admin", a.login) a.router.GET("/admin/login", a.handleGitHubLogin) a.router.GET("/admin/auth/callback", a.handleGitHubCallback) + a.router.POST("/admin/executions/add", a.authMiddleware(), a.handleExecutionsAdd) a.router.GET("/admin/dashboard", a.authMiddleware(), a.dashboard) return a.router.Run(":" + a.port) diff --git a/go/admin/api.go b/go/admin/api.go index 3b2ed66a..6a4a94a0 100644 --- a/go/admin/api.go +++ b/go/admin/api.go @@ -19,11 +19,14 @@ package admin import ( + "bytes" "context" + "encoding/json" "net/http" "strings" + "sync" + "time" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" goGithub "github.com/google/go-github/github" "github.com/labstack/gommon/random" @@ -36,9 +39,13 @@ var ( oauthConf = &oauth2.Config{ Scopes: []string{"read:org"}, // Request access to read organization membership Endpoint: github.Endpoint, - RedirectURL: "http://localhost:8081/admin/auth/callback", + RedirectURL: "http://localhost/admin/auth/callback", } oauthStateString = random.String(10) // A random string to protect against CSRF attacks + orgName = "vitessio" + tokens = make(map[string]oauth2.Token) + + mu sync.Mutex ) const ( @@ -46,6 +53,14 @@ const ( arewefastyetTeamGitHub = "arewefastyet" ) +type ExecutionRequest struct { + Auth string `json:"auth"` + Source string `json:"source"` + SHA string `json:"sha"` + Workloads []string `json:"workloads"` + NumberOfExecutions string `json:"number_of_executions"` +} + func (a *Admin) login(c *gin.Context) { a.render(c, gin.H{}, "login.html") } @@ -54,22 +69,62 @@ func (a *Admin) dashboard(c *gin.Context) { a.render(c, gin.H{}, "base.html") } +func CreateGhClient(token *oauth2.Token) *goGithub.Client { + return goGithub.NewClient(oauthConf.Client(context.Background(), token)) +} + func (a *Admin) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - session := sessions.Default(c) - user := session.Get("user") - if user == nil { - // User not authenticated, redirect to login + cookie, err := c.Cookie("tk") + if err != nil { c.Redirect(http.StatusSeeOther, "/admin/login") c.Abort() return } - // User is authenticated, proceed to the next handler + mu.Lock() + token, ok := tokens[cookie] + mu.Unlock() + if !ok { + c.Redirect(http.StatusSeeOther, "/admin/login") + c.Abort() + return + } + + client := CreateGhClient(&token) + + isMaintainer, err := a.GetUser(client) + + if err != nil { + slog.Error("Error getting user: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + if !isMaintainer { + c.String(http.StatusForbidden, "You must be a maintainer in the %s organization to access this page.", orgName) + c.Abort() + return + } + c.Next() } } +func (a *Admin) GetUser(client *goGithub.Client) (bool, error) { + user, _, err := client.Users.Get(context.Background(), "") + if err != nil { + return false, err + } + + isMaintainer, err := a.checkUserOrgMembership(client, user.GetLogin(), orgName) + if err != nil { + return false, err + } + + return isMaintainer, nil +} + func (a *Admin) handleGitHubLogin(c *gin.Context) { if a.Mode == server.ProductionMode { oauthConf.RedirectURL = "https://benchmark.vitess.io/admin/auth/callback" @@ -95,29 +150,29 @@ func (a *Admin) handleGitHubCallback(c *gin.Context) { return } - client := goGithub.NewClient(oauthConf.Client(context.Background(), token)) + client := CreateGhClient(token) - user, _, err := client.Users.Get(context.Background(), "") + isMaintainer, err := a.GetUser(client) if err != nil { - slog.Error("Failed to get user: ", err) + slog.Error("Failed to get user information: ", err) c.AbortWithStatus(http.StatusInternalServerError) return } - slog.Infof("Authenticated user: %s", user.GetLogin()) + if isMaintainer { + mu.Lock() + defer mu.Unlock() - orgName := "vitessio" - isMaintainer, err := a.checkUserOrgMembership(client, user.GetLogin(), orgName) - if err != nil { - slog.Error("Error checking org membership: ", err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } + randomKey := random.String(32) + tokens[randomKey] = *token - if isMaintainer { - session := sessions.Default(c) - session.Set("user", user.GetLogin()) - _ = session.Save() + domain := "localhost" + + if a.Mode == server.ProductionMode { + domain = "benchmark.vitess.io" + } + + c.SetCookie("tk", randomKey, int(time.Hour.Seconds()), "/", domain, true, true) c.Redirect(http.StatusSeeOther, "/admin/dashboard") } else { @@ -149,3 +204,88 @@ func (a *Admin) checkUserOrgMembership(client *goGithub.Client, username, orgNam } return isMember, nil } + +func (a *Admin) handleExecutionsAdd(c *gin.Context) { + source := c.PostForm("source") + sha := c.PostForm("sha") + workloads := c.PostFormArray("workloads") + numberOfExecutions := c.PostForm("numberOfExecutions") + + if source == "" || sha == "" || len(workloads) == 0 || numberOfExecutions == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required fields: Source and/or SHA"}) + return + } + + tokenKey, err := c.Cookie("tk") + if err != nil { + slog.Error("Failed to get token from cookie: ", err) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + mu.Lock() + defer mu.Unlock() + + token, exists := tokens[tokenKey] + + if !exists { + slog.Error("Failed to get token from map") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + encryptedToken, err := server.Encrypt(token.AccessToken, a.auth) + + if err != nil { + slog.Error("Failed to encrypt token: ", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt token"}) + return + } + + requestPayload := ExecutionRequest{ + Auth: encryptedToken, + Source: source, + SHA: sha, + Workloads: workloads, + NumberOfExecutions: numberOfExecutions, + } + + jsonData, err := json.Marshal(requestPayload) + + if err != nil { + slog.Error("Failed to marshal request payload: ", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal request payload"}) + return + } + + serverAPIURL := "http://traefik/api/executions/add" + if a.Mode == server.ProductionMode { + serverAPIURL = "https://benchmark.vitess.io/api/executions/add" + } + + req, err := http.NewRequest("POST", serverAPIURL, bytes.NewBuffer(jsonData)) + if err != nil { + slog.Error("Failed to create new HTTP request: ", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request to server API"}) + return + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + slog.Error("Failed to send request to server API: ", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send request to server API"}) + return + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + slog.Error("Server API returned an error: ", resp.Status) + c.JSON(resp.StatusCode, gin.H{"error": "Failed to process request on server API"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Execution(s) added successfully"}) +} diff --git a/go/admin/templates/base.html b/go/admin/templates/base.html index 29fc33c9..146031e9 100644 --- a/go/admin/templates/base.html +++ b/go/admin/templates/base.html @@ -1,38 +1,22 @@ - -
- - -Use the sidebar to navigate through different sections of the dashboard.
+ +