From b419a5b26045474e6b9bd4349e5134b247c47c22 Mon Sep 17 00:00:00 2001 From: Victor Lacasse-Beaudoin Date: Wed, 18 Sep 2024 19:06:33 -0400 Subject: [PATCH] =?UTF-8?q?major:=20s=C3=A9parer=20commande=20de=20librair?= =?UTF-8?q?ie=20importable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump major version à 9 package main déplacé vers cmd/bottin/ pour garder `go install` qui nomme l'exécutable `bottin`, sans empêcher d'importer le code à l'extérieur du projet avec pkg/bottin/. Déplacer fichiers SQL vers queries/ Déplacer fichiers html vers templates/ Ajouter scripts/ avec génération et injection de certificats x509 (https) et les ajouter au Makefile Ajouter début d'exemple de manifests dans deployments/kubernetes/ --- .gitignore | 2 + Dockerfile | 8 +- Makefile | 12 + README.md | 58 +-- cmd.go | 285 --------------- config.go => cmd/bottin/main.go | 344 +++++++++++++++--- client_test.go => cmd/bottin/main_test.go | 13 +- docker-compose.yaml => compose.yaml | 5 +- deployments/kubernetes/bottin-pod.yaml | 60 +++ .../kubernetes/example-bottin-secret.yaml | 26 ++ go.mod | 2 +- main.go | 18 - client.go => pkg/bottin/client.go | 2 +- pkg/bottin/config.go | 53 +++ db.go => pkg/bottin/db.go | 13 +- entity.go => pkg/bottin/entity.go | 2 +- request.go => pkg/bottin/request.go | 20 +- response.go => pkg/bottin/response.go | 2 +- routes.go => pkg/bottin/routes.go | 6 +- queries/queries.go | 14 + {sql => queries}/schema.sql | 0 {sql => queries}/views.sql | 0 scripts/compose-inject-x509.sh | 6 + scripts/generate-self-signed-x509.sh | 2 + template.go => templates/templates.go | 11 +- 25 files changed, 513 insertions(+), 451 deletions(-) delete mode 100644 cmd.go rename config.go => cmd/bottin/main.go (56%) rename client_test.go => cmd/bottin/main_test.go (91%) rename docker-compose.yaml => compose.yaml (95%) create mode 100644 deployments/kubernetes/bottin-pod.yaml create mode 100644 deployments/kubernetes/example-bottin-secret.yaml delete mode 100644 main.go rename client.go => pkg/bottin/client.go (99%) create mode 100644 pkg/bottin/config.go rename db.go => pkg/bottin/db.go (97%) rename entity.go => pkg/bottin/entity.go (98%) rename request.go => pkg/bottin/request.go (94%) rename response.go => pkg/bottin/response.go (99%) rename routes.go => pkg/bottin/routes.go (99%) create mode 100644 queries/queries.go rename {sql => queries}/schema.sql (100%) rename {sql => queries}/views.sql (100%) create mode 100755 scripts/compose-inject-x509.sh create mode 100755 scripts/generate-self-signed-x509.sh rename template.go => templates/templates.go (55%) diff --git a/.gitignore b/.gitignore index e2f7237..a8c4332 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# cert files +*.pem # env .env diff --git a/Dockerfile b/Dockerfile index 18bfd2d..d24bb50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,12 +4,14 @@ LABEL author="vlbeaudoin" WORKDIR /go/src/app -COPY go.mod go.sum client.go client_test.go cmd.go config.go db.go entity.go main.go request.go response.go routes.go template.go ./ +COPY go.mod go.sum LICENSE ./ -ADD sql/ sql/ +ADD cmd/ cmd/ +ADD pkg/ pkg/ +ADD queries/ queries/ ADD templates/ templates/ -RUN CGO_ENABLED=0 go build -a -o bottin . +RUN CGO_ENABLED=0 go build -a -o bottin ./cmd/bottin # Alpine diff --git a/Makefile b/Makefile index 31a6c23..827fc0c 100644 --- a/Makefile +++ b/Makefile @@ -14,3 +14,15 @@ help: ## Show this help .PHONY: test-integration test-integration: ## run integration tests through API client. Config is read from `~/.bottin.yaml`. WARNING: affects data in the database, do not run on production server docker-compose down && docker-compose up -d --build && sleep 2 && go test + +.PHONY: dev +dev: generate-self-signed-x509 compose-inject-x509 ## deploy development environment on docker-compose + docker-compose up -d + +.PHONY: generate-self-signed-x509 +generate-self-signed-x509: ## Générer une paire de clés x509 self-signed pour utilisation avec un serveur de développement + ./scripts/generate-self-signed-x509.sh + +.PHONY: compose-inject-x509 +compose-inject-x509: ## Copie la paire de clés x509 du current directory vers les containers orchestrés par docker-compose + ./scripts/compose-inject-x509.sh diff --git a/README.md b/README.md index 822c4f1..a024676 100644 --- a/README.md +++ b/README.md @@ -1,57 +1 @@ -# agecem/bottin - -Bottin de la masse étudiante, en Go - -https://git.agecem.com/agecem/bottin - -## fonctionalités - -### Serveur API - -- Insertion de membre et programme -- Lecture de membre -- Modification du nom d'usage de membre - -### Client web - -- Lecture de membre par requête au serveur API - -## usage - -Remplir .env avec les infos qui seront utilisées pour déployer le container - -Au minimum, il faut ces 3 entrées: - -*Remplacer `bottin` par quelque chose de plus sécuritaire* - -```sh -BOTTIN_SERVER_DB_DATABASE=bottin -BOTTIN_SERVER_DB_PASSWORD=bottin -BOTTIN_SERVER_DB_USER=bottin -``` - -*D'autres entrées peuvent être ajoutées, voir `config.go` pour les options* - -Déployer avec docker-compose - -`$ docker-compose up -d` - -### Optionnel: configuration par fichiers YAML - -*seulement nécessaire si les fichiers `.env` et `docker-compose.yaml` ne contiennent pas toute l'information nécessaire* - -Pour modifier la configuration du serveur API - -`$ docker-compose exec -it api vi /etc/bottin/api.yaml` - -*Y remplir au minimum le champs `server.api.key` (string)* - -Pour modifier la configuration du client web - -`$ docker-compose exec -it ui vi /etc/bottin/ui.yaml` - -*Y remplir au minimum les champs `server.ui.api.key` (string), `server.ui.user` (string) et `server.ui.password` (string)* - -Redémarrer les containers une fois la configuration modifiée - -`$ docker-compose down && docker-compose up -d` +Requiert un fichier .env ici pour un déploiement avec base de donnée diff --git a/cmd.go b/cmd.go deleted file mode 100644 index e801dd3..0000000 --- a/cmd.go +++ /dev/null @@ -1,285 +0,0 @@ -package main - -import ( - "context" - "crypto/subtle" - "crypto/tls" - "fmt" - "html/template" - "log" - "net/http" - "os" - - "codeberg.org/vlbeaudoin/voki/v3" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "bottin", - Short: "Bottin étudiant de l'AGECEM", -} - -// execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -var serverCmd = &cobra.Command{ - Use: "server", - Short: "Démarrer serveurs (API ou Web UI)", -} - -// apiCmd represents the api command -var apiCmd = &cobra.Command{ - Use: "api", - Short: "Démarrer le serveur API", - Args: cobra.ExactArgs(0), - Run: func(cmd *cobra.Command, args []string) { - var cfg Config - if err := viper.Unmarshal(&cfg); err != nil { - log.Fatal("parse config:", err) - } - - e := echo.New() - - // Middlewares - - e.Pre(middleware.AddTrailingSlash()) - - if cfg.Server.API.Key != "" { - e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { - return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.API.Key)) == 1, nil - })) - } else { - log.Println("Server started but no API key (server.api.key) was provided, using empty key (NOT RECOMMENDED FOR PRODUCTION)") - } - - // DataClient - ctx := context.Background() - - //prep - pool, err := pgxpool.New( - ctx, - fmt.Sprintf( - "user=%s password=%s database=%s host=%s port=%d sslmode=%s ", - cfg.Server.API.DB.User, - cfg.Server.API.DB.Password, - cfg.Server.API.DB.Database, - cfg.Server.API.DB.Host, - cfg.Server.API.DB.Port, - cfg.Server.API.DB.SSLMode, - )) - if err != nil { - log.Fatal("init pgx pool:", err) - } - defer pool.Close() - - db := &PostgresClient{ - Ctx: ctx, - Pool: pool, - } - if err := db.Pool.Ping(ctx); err != nil { - log.Fatal("ping db:", err) - } - - if err := db.CreateOrReplaceSchema(); err != nil { - log.Fatal("create or replace schema:", err) - } - - if err := db.CreateOrReplaceViews(); err != nil { - log.Fatal("create or replace views:", err) - } - - // Routes - if err := addRoutes(e, db, cfg); err != nil { - log.Fatal("add routes:", err) - } - /* - h := handlers.New(client) - - e.GET("/v8/health/", h.GetHealth) - - e.POST("/v8/membres/", h.PostMembres) - - e.GET("/v8/membres/", h.ListMembres) - - e.GET("/v8/membres/:membre_id/", h.ReadMembre) - - e.PUT("/v8/membres/:membre_id/prefered_name/", h.PutMembrePreferedName) - - e.POST("/v8/programmes/", h.PostProgrammes) - - e.POST("/v8/seed/", h.PostSeed) - */ - - // Execution - switch cfg.Server.API.TLS.Enabled { - case false: - e.Logger.Fatal( - e.Start( - fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port), - ), - ) - case true: - if cfg.Server.API.TLS.Certfile == "" { - log.Fatal("TLS enabled for API but no certificate file provided") - } - - if cfg.Server.API.TLS.Keyfile == "" { - log.Fatal("TLS enabled for UI but no private key file provided") - } - - e.Logger.Fatal( - e.StartTLS( - fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port), - cfg.Server.API.TLS.Certfile, - cfg.Server.API.TLS.Keyfile, - ), - ) - } - }, -} - -// uiCmd represents the ui command -var uiCmd = &cobra.Command{ - Use: "ui", - Aliases: []string{"web", "interface"}, - Short: "Démarrer l'interface Web UI", - Args: cobra.ExactArgs(0), - Run: func(cmd *cobra.Command, args []string) { - // Parse config - var cfg Config - if err := viper.Unmarshal(&cfg); err != nil { - log.Fatal("init config:", err) - } - - e := echo.New() - - // Middlewares - - // Trailing slash - e.Pre(middleware.AddTrailingSlash()) - - // Auth - e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) { - usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Server.UI.User)) == 1 - passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Server.UI.Password)) == 1 - return usersMatch && passwordsMatch, nil - })) - - // Templating - e.Renderer = &Template{ - templates: template.Must(template.ParseFS(templatesFS, "templates/*.html")), - } - - // API Client - var httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: cfg.Server.UI.API.TLS.SkipVerify, - }, - }, - } - apiClient := APIClient{voki.New( - httpClient, - cfg.Server.UI.API.Host, - cfg.Server.UI.API.Key, - cfg.Server.UI.API.Port, - cfg.Server.UI.API.Protocol, - )} - defer apiClient.Voki.CloseIdleConnections() - - // Routes - e.GET("/", func(c echo.Context) error { - pingResult, err := apiClient.GetHealth() - if err != nil { - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: fmt.Sprintf("impossible d'accéder au serveur API: %s", err)}, - ) - } - - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: pingResult}, - ) - }) - - e.GET("/membre/", func(c echo.Context) error { - membreID := c.QueryParam("membre_id") - switch { - case membreID == "": - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: "❗Veuillez entrer un numéro étudiant à rechercher"}, - ) - case !IsMembreID(membreID): - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: fmt.Sprintf("❗Numéro étudiant '%s' invalide", membreID)}, - ) - } - - membre, err := apiClient.GetMembreForDisplay(membreID) - if err != nil { - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: fmt.Sprintf("❗erreur: %s", err)}, - ) - } - - return c.Render( - http.StatusOK, - "index-html", - voki.MessageResponse{Message: fmt.Sprintf(` -Numéro étudiant: %s -Nom d'usage: %s -Programme: [%s] %s -`, - membre.ID, - membre.Name, - membre.ProgrammeID, - membre.ProgrammeName, - )}, - ) - }) - - // Execution - switch cfg.Server.UI.TLS.Enabled { - case false: - e.Logger.Fatal(e.Start( - fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port))) - case true: - if cfg.Server.UI.TLS.Certfile == "" { - log.Fatal("TLS enabled for UI but no certificate file provided") - } - - if cfg.Server.UI.TLS.Keyfile == "" { - log.Fatal("TLS enabled for UI but no private key file provided") - } - - e.Logger.Fatal( - e.StartTLS( - fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port), - cfg.Server.UI.TLS.Certfile, - cfg.Server.UI.TLS.Keyfile, - ), - ) - } - - }, -} diff --git a/config.go b/cmd/bottin/main.go similarity index 56% rename from config.go rename to cmd/bottin/main.go index cf521a7..526a344 100644 --- a/config.go +++ b/cmd/bottin/main.go @@ -1,67 +1,25 @@ package main import ( + "context" + "crypto/subtle" + "crypto/tls" "fmt" "log" + "net/http" "os" "strings" + "codeberg.org/vlbeaudoin/voki/v3" + "git.agecem.com/agecem/bottin/v9/pkg/bottin" + "git.agecem.com/agecem/bottin/v9/templates" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" "github.com/spf13/cobra" "github.com/spf13/viper" ) -type Config struct { - Client struct { - API struct { - Host string `yaml:"host"` - Key string `yaml:"key"` - Port int `yaml:"port"` - Protocol string `yaml:"protocol"` - } `yaml:"api"` - } `yaml:"client"` - - Server struct { - API struct { - DB struct { - Database string `yaml:"database"` - Host string `yaml:"host"` - Password string `yaml:"password"` - Port int `yaml:"port"` - SSLMode string `yaml:"sslmode"` - User string `yaml:"user"` - } `yaml:"db"` - Host string `yaml:"host"` - Key string `yaml:"key"` - Port int `yaml:"port"` - TLS struct { - Enabled bool `yaml:"enabled"` - Certfile string `yaml:"certfile"` - Keyfile string `yaml:"keyfile"` - } `yaml:"tls"` - } `yaml:"api"` - UI struct { - API struct { - Host string `yaml:"host"` - Key string `yaml:"key"` - Port int `yaml:"port"` - Protocol string `yaml:"protocol"` - TLS struct { - SkipVerify bool `yaml:"skipverify"` - } `yaml:"tls"` - } `yaml:"api"` - Host string `yaml:"host"` - Password string `yaml:"password"` - Port int `yaml:"port"` - TLS struct { - Enabled bool `yaml:"enabled"` - Certfile string `yaml:"certfile"` - Keyfile string `yaml:"keyfile"` - } `yaml:"tls"` - User string `yaml:"user"` - } `yaml:"ui"` - } `yaml:"server"` -} - var cfgFile string // initConfig reads in config file and ENV variables if set. @@ -90,6 +48,17 @@ func initConfig() { } } +func main() { + /* TODO + if err := Run(context.Background(), Config{}, nil, os.Stdout); err != nil { + log.Fatal(err) + } + */ + + // Handle the command-line via cobra and viper + execute() +} + func init() { // rootCmd @@ -475,3 +444,274 @@ func init() { log.Fatal(err) } } + +/* TODO +func Run(ctx context.Context, config Config, args []string, stdout io.Writer) error { + return fmt.Errorf("not implemented") +} +*/ + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "bottin", + Short: "Bottin étudiant de l'AGECEM", +} + +// execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Démarrer serveurs (API ou Web UI)", +} + +// apiCmd represents the api command +var apiCmd = &cobra.Command{ + Use: "api", + Short: "Démarrer le serveur API", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + var cfg bottin.Config + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal("parse config:", err) + } + + e := echo.New() + + // Middlewares + + e.Pre(middleware.AddTrailingSlash()) + + if cfg.Server.API.Key != "" { + e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { + return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.API.Key)) == 1, nil + })) + } else { + log.Println("Server started but no API key (server.api.key) was provided, using empty key (NOT RECOMMENDED FOR PRODUCTION)") + } + + // DataClient + ctx := context.Background() + + //prep + pool, err := pgxpool.New( + ctx, + fmt.Sprintf( + "user=%s password=%s database=%s host=%s port=%d sslmode=%s ", + cfg.Server.API.DB.User, + cfg.Server.API.DB.Password, + cfg.Server.API.DB.Database, + cfg.Server.API.DB.Host, + cfg.Server.API.DB.Port, + cfg.Server.API.DB.SSLMode, + )) + if err != nil { + log.Fatal("init pgx pool:", err) + } + defer pool.Close() + + db := &bottin.PostgresClient{ + Ctx: ctx, + Pool: pool, + } + if err := db.Pool.Ping(ctx); err != nil { + log.Fatal("ping db:", err) + } + + if err := db.CreateOrReplaceSchema(); err != nil { + log.Fatal("create or replace schema:", err) + } + + if err := db.CreateOrReplaceViews(); err != nil { + log.Fatal("create or replace views:", err) + } + + // Routes + if err := bottin.AddRoutes(e, db, cfg); err != nil { + log.Fatal("add routes:", err) + } + /* + h := handlers.New(client) + + e.GET("/v9/health/", h.GetHealth) + + e.POST("/v9/membres/", h.PostMembres) + + e.GET("/v9/membres/", h.ListMembres) + + e.GET("/v9/membres/:membre_id/", h.ReadMembre) + + e.PUT("/v9/membres/:membre_id/prefered_name/", h.PutMembrePreferedName) + + e.POST("/v9/programmes/", h.PostProgrammes) + + e.POST("/v9/seed/", h.PostSeed) + */ + + // Execution + switch cfg.Server.API.TLS.Enabled { + case false: + e.Logger.Fatal( + e.Start( + fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port), + ), + ) + case true: + if cfg.Server.API.TLS.Certfile == "" { + log.Fatal("TLS enabled for API but no certificate file provided") + } + + if cfg.Server.API.TLS.Keyfile == "" { + log.Fatal("TLS enabled for UI but no private key file provided") + } + + e.Logger.Fatal( + e.StartTLS( + fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port), + cfg.Server.API.TLS.Certfile, + cfg.Server.API.TLS.Keyfile, + ), + ) + } + }, +} + +// uiCmd represents the ui command +var uiCmd = &cobra.Command{ + Use: "ui", + Aliases: []string{"web", "interface"}, + Short: "Démarrer l'interface Web UI", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + // Parse config + var cfg bottin.Config + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal("init config:", err) + } + + e := echo.New() + + // Middlewares + + // Trailing slash + e.Pre(middleware.AddTrailingSlash()) + + // Auth + e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) { + usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Server.UI.User)) == 1 + passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Server.UI.Password)) == 1 + return usersMatch && passwordsMatch, nil + })) + + // Templating + e.Renderer = templates.NewTemplate() + + // API Client + var httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.Server.UI.API.TLS.SkipVerify, + }, + }, + } + apiClient := bottin.APIClient{ + Voki: voki.New( + httpClient, + cfg.Server.UI.API.Host, + cfg.Server.UI.API.Key, + cfg.Server.UI.API.Port, + cfg.Server.UI.API.Protocol, + )} + defer apiClient.Voki.CloseIdleConnections() + + // Routes + e.GET("/", func(c echo.Context) error { + pingResult, err := apiClient.GetHealth() + if err != nil { + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("impossible d'accéder au serveur API: %s", err)}, + ) + } + + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: pingResult}, + ) + }) + + e.GET("/membre/", func(c echo.Context) error { + membreID := c.QueryParam("membre_id") + switch { + case membreID == "": + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: "❗Veuillez entrer un numéro étudiant à rechercher"}, + ) + case !bottin.IsMembreID(membreID): + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("❗Numéro étudiant '%s' invalide", membreID)}, + ) + } + + membre, err := apiClient.GetMembreForDisplay(membreID) + if err != nil { + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf("❗erreur: %s", err)}, + ) + } + + return c.Render( + http.StatusOK, + "index-html", + voki.MessageResponse{Message: fmt.Sprintf(` +Numéro étudiant: %s +Nom d'usage: %s +Programme: [%s] %s +`, + membre.ID, + membre.Name, + membre.ProgrammeID, + membre.ProgrammeName, + )}, + ) + }) + + // Execution + switch cfg.Server.UI.TLS.Enabled { + case false: + e.Logger.Fatal(e.Start( + fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port))) + case true: + if cfg.Server.UI.TLS.Certfile == "" { + log.Fatal("TLS enabled for UI but no certificate file provided") + } + + if cfg.Server.UI.TLS.Keyfile == "" { + log.Fatal("TLS enabled for UI but no private key file provided") + } + + e.Logger.Fatal( + e.StartTLS( + fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port), + cfg.Server.UI.TLS.Certfile, + cfg.Server.UI.TLS.Keyfile, + ), + ) + } + + }, +} diff --git a/client_test.go b/cmd/bottin/main_test.go similarity index 91% rename from client_test.go rename to cmd/bottin/main_test.go index 261eb20..f8b6685 100644 --- a/client_test.go +++ b/cmd/bottin/main_test.go @@ -6,6 +6,7 @@ import ( "testing" "codeberg.org/vlbeaudoin/voki/v3" + "git.agecem.com/agecem/bottin/v9/pkg/bottin" "github.com/spf13/viper" ) @@ -14,7 +15,7 @@ func init() { } func TestAPI(t *testing.T) { - var cfg Config + var cfg bottin.Config if err := viper.Unmarshal(&cfg); err != nil { t.Error(err) return @@ -35,7 +36,7 @@ func TestAPI(t *testing.T) { defer httpClient.CloseIdleConnections() vokiClient := voki.New(&httpClient, "localhost", cfg.Client.API.Key, cfg.Client.API.Port, cfg.Client.API.Protocol) - apiClient := APIClient{vokiClient} + apiClient := bottin.APIClient{Voki: vokiClient} t.Run("get API health", func(t *testing.T) { health, err := apiClient.GetHealth() @@ -53,9 +54,9 @@ func TestAPI(t *testing.T) { t.Run("insert programmes", func(t *testing.T) { - programmes := []Programme{ - {"404.42", "Cool programme"}, - {"200.10", "Autre programme"}, + programmes := []bottin.Programme{ + {ID: "404.42", Name: "Cool programme"}, + {ID: "200.10", Name: "Autre programme"}, } t.Log("programmes:", programmes) _, err := apiClient.InsertProgrammes(programmes...) @@ -64,7 +65,7 @@ func TestAPI(t *testing.T) { } }) - testMembres := []Membre{ + testMembres := []bottin.Membre{ { ID: "0000000", FirstName: "Test", diff --git a/docker-compose.yaml b/compose.yaml similarity index 95% rename from docker-compose.yaml rename to compose.yaml index 5487eb1..686312c 100644 --- a/docker-compose.yaml +++ b/compose.yaml @@ -1,3 +1,4 @@ +name: 'bottin' services: db: @@ -13,7 +14,7 @@ services: api: depends_on: - db - build: . + build: ../.. image: 'git.agecem.com/agecem/bottin:latest' env_file: '.env' ports: @@ -26,7 +27,7 @@ services: ui: depends_on: - api - build: . + build: ../.. image: 'git.agecem.com/agecem/bottin:latest' env_file: '.env' ports: diff --git a/deployments/kubernetes/bottin-pod.yaml b/deployments/kubernetes/bottin-pod.yaml new file mode 100644 index 0000000..72569e4 --- /dev/null +++ b/deployments/kubernetes/bottin-pod.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: Pod +metadata: + name: bottin-pod +spec: + initContainers: + - name: clone + image: alpine:3.20 + command: ['sh', '-c'] + args: + - apk add git && + git clone -- https://git.agecem.com/agecem/bottin /opt/bottin-src + volumeMounts: + - name: bottin-src + mountPath: /opt/bottin-src + - name: build + image: golang:1.23 + env: + - name: CGO_ENABLED + value: '0' + command: ['sh', '-c'] + args: + - cd /opt/bottin-src && + go build -a -o /opt/bottin-executable/bottin + volumeMounts: + - name: bottin-src + mountPath: /opt/bottin-src + - name: bottin-executable + mountPath: /opt/bottin-executable + containers: + - name: api + image: alpine:3.20 + command: ['sh', '-c'] + args: + - ln -s /opt/bottin-executable/bottin /usr/bin/bottin + volumeMounts: + - name: bottin-executable + mountPath: /opt/bottin-executable + - name: bottin-secret + readOnly: true + mountPath: '/etc/bottin' + - name: ui + image: alpine:3.20 + command: ['sh', '-c'] + args: + - bottin --config /etc/bottin/ui.yaml server ui + volumeMounts: + - name: bottin-executable + mountPath: /opt/bottin-executable + - name: bottin-secret + readOnly: true + mountPath: '/etc/bottin' + volumes: + - name: bottin-src + emptyDir: {} + - name: bottin-executable + emptyDir: {} + - name: bottin-secret + secret: + secretName: bottin-secret diff --git a/deployments/kubernetes/example-bottin-secret.yaml b/deployments/kubernetes/example-bottin-secret.yaml new file mode 100644 index 0000000..36bed97 --- /dev/null +++ b/deployments/kubernetes/example-bottin-secret.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: bottin-secret +stringData: + api.yaml: | + bottin: + server: + api: + db: + database: 'bottin' + host: 'db.example.com' + password: 'bottin' + sslmode: 'require' + user: 'bottin' + key: 'bottin' + ui.yaml: | + bottin: + server: + ui: + api: + tls: + skipverify: 'true' + key: 'bottin' + password: 'bottin' + user: 'bottin' diff --git a/go.mod b/go.mod index 489b37d..e3da402 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.agecem.com/agecem/bottin/v8 +module git.agecem.com/agecem/bottin/v9 go 1.22.0 diff --git a/main.go b/main.go deleted file mode 100644 index 6f81738..0000000 --- a/main.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -func main() { - /* TODO - if err := Run(context.Background(), Config{}, nil, os.Stdout); err != nil { - log.Fatal(err) - } - */ - - // Handle the command-line via cobra and viper - execute() -} - -/* TODO -func Run(ctx context.Context, config Config, args []string, stdout io.Writer) error { - return fmt.Errorf("not implemented") -} -*/ diff --git a/client.go b/pkg/bottin/client.go similarity index 99% rename from client.go rename to pkg/bottin/client.go index e4958a6..529d9c8 100644 --- a/client.go +++ b/pkg/bottin/client.go @@ -1,4 +1,4 @@ -package main +package bottin import ( "fmt" diff --git a/pkg/bottin/config.go b/pkg/bottin/config.go new file mode 100644 index 0000000..b0c4650 --- /dev/null +++ b/pkg/bottin/config.go @@ -0,0 +1,53 @@ +package bottin + +type Config struct { + Client struct { + API struct { + Host string `yaml:"host"` + Key string `yaml:"key"` + Port int `yaml:"port"` + Protocol string `yaml:"protocol"` + } `yaml:"api"` + } `yaml:"client"` + + Server struct { + API struct { + DB struct { + Database string `yaml:"database"` + Host string `yaml:"host"` + Password string `yaml:"password"` + Port int `yaml:"port"` + SSLMode string `yaml:"sslmode"` + User string `yaml:"user"` + } `yaml:"db"` + Host string `yaml:"host"` + Key string `yaml:"key"` + Port int `yaml:"port"` + TLS struct { + Enabled bool `yaml:"enabled"` + Certfile string `yaml:"certfile"` + Keyfile string `yaml:"keyfile"` + } `yaml:"tls"` + } `yaml:"api"` + UI struct { + API struct { + Host string `yaml:"host"` + Key string `yaml:"key"` + Port int `yaml:"port"` + Protocol string `yaml:"protocol"` + TLS struct { + SkipVerify bool `yaml:"skipverify"` + } `yaml:"tls"` + } `yaml:"api"` + Host string `yaml:"host"` + Password string `yaml:"password"` + Port int `yaml:"port"` + TLS struct { + Enabled bool `yaml:"enabled"` + Certfile string `yaml:"certfile"` + Keyfile string `yaml:"keyfile"` + } `yaml:"tls"` + User string `yaml:"user"` + } `yaml:"ui"` + } `yaml:"server"` +} diff --git a/db.go b/pkg/bottin/db.go similarity index 97% rename from db.go rename to pkg/bottin/db.go index f0b2f4e..0769fbc 100644 --- a/db.go +++ b/pkg/bottin/db.go @@ -1,20 +1,15 @@ -package main +package bottin import ( "context" _ "embed" "fmt" + "git.agecem.com/agecem/bottin/v9/queries" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -//go:embed sql/schema.sql -var sqlSchema string - -//go:embed sql/views.sql -var sqlViews string - type PostgresClient struct { //TODO move context out of client Ctx context.Context @@ -22,12 +17,12 @@ type PostgresClient struct { } func (db *PostgresClient) CreateOrReplaceSchema() error { - _, err := db.Pool.Exec(db.Ctx, sqlSchema) + _, err := db.Pool.Exec(db.Ctx, queries.SQLSchema()) return err } func (db *PostgresClient) CreateOrReplaceViews() error { - _, err := db.Pool.Exec(db.Ctx, sqlViews) + _, err := db.Pool.Exec(db.Ctx, queries.SQLViews()) return err } diff --git a/entity.go b/pkg/bottin/entity.go similarity index 98% rename from entity.go rename to pkg/bottin/entity.go index 672f54c..8279b9e 100644 --- a/entity.go +++ b/pkg/bottin/entity.go @@ -1,4 +1,4 @@ -package main +package bottin import "unicode" diff --git a/request.go b/pkg/bottin/request.go similarity index 94% rename from request.go rename to pkg/bottin/request.go index f1a73b4..555ddab 100644 --- a/request.go +++ b/pkg/bottin/request.go @@ -1,4 +1,4 @@ -package main +package bottin import ( "bytes" @@ -23,7 +23,7 @@ func (request HealthGETRequest) Request(v *voki.Voki) (response HealthGETRespons statusCode, body, err := v.CallAndParse( http.MethodGet, - "/api/v8/health/", + "/api/v9/health/", nil, true, ) @@ -64,7 +64,7 @@ func (request ProgrammesPOSTRequest) Request(v *voki.Voki) (response ProgrammesP statusCode, body, err := v.CallAndParse( http.MethodPost, - "/api/v8/programme/", + "/api/v9/programme/", &buf, true, ) @@ -105,7 +105,7 @@ func (request MembresPOSTRequest) Request(v *voki.Voki) (response MembresPOSTRes statusCode, body, err := v.CallAndParse( http.MethodPost, - "/api/v8/membre/", + "/api/v9/membre/", &buf, true, ) @@ -146,7 +146,7 @@ func (request MembreGETRequest) Request(v *voki.Voki) (response MembreGETRespons statusCode, body, err := v.CallAndParse( http.MethodGet, - fmt.Sprintf("/api/v8/membre/%s/", request.Param.MembreID), + fmt.Sprintf("/api/v9/membre/%s/", request.Param.MembreID), nil, true, ) @@ -180,7 +180,7 @@ func (request MembresGETRequest) Request(v *voki.Voki) (response MembresGETRespo statusCode, body, err := v.CallAndParse( http.MethodGet, - fmt.Sprintf("/api/v8/membre/?limit=%d", request.Query.Limit), + fmt.Sprintf("/api/v9/membre/?limit=%d", request.Query.Limit), nil, true, ) @@ -224,7 +224,7 @@ func (request MembrePreferedNamePUTRequest) Request(v *voki.Voki) (response Memb statusCode, body, err := v.CallAndParse( http.MethodPut, - fmt.Sprintf("/api/v8/membre/%s/prefered_name/", request.Param.MembreID), + fmt.Sprintf("/api/v9/membre/%s/prefered_name/", request.Param.MembreID), &buf, true, ) @@ -258,7 +258,7 @@ func (request ProgrammesGETRequest) Request(v *voki.Voki) (response ProgrammesGE statusCode, body, err := v.CallAndParse( http.MethodGet, - fmt.Sprintf("/api/v8/programme/?limit=%d", request.Query.Limit), + fmt.Sprintf("/api/v9/programme/?limit=%d", request.Query.Limit), nil, true, ) @@ -292,7 +292,7 @@ func (request MembresDisplayGETRequest) Request(v *voki.Voki) (response MembresD statusCode, body, err := v.CallAndParse( http.MethodGet, - fmt.Sprintf("/api/v8/membre/display/?limit=%d", request.Query.Limit), + fmt.Sprintf("/api/v9/membre/display/?limit=%d", request.Query.Limit), nil, true, ) @@ -333,7 +333,7 @@ func (request MembreDisplayGETRequest) Request(v *voki.Voki) (response MembreDis statusCode, body, err := v.CallAndParse( http.MethodGet, - fmt.Sprintf("/api/v8/membre/%s/display/", request.Param.MembreID), + fmt.Sprintf("/api/v9/membre/%s/display/", request.Param.MembreID), nil, true, ) diff --git a/response.go b/pkg/bottin/response.go similarity index 99% rename from response.go rename to pkg/bottin/response.go index 19818df..cd034f1 100644 --- a/response.go +++ b/pkg/bottin/response.go @@ -1,4 +1,4 @@ -package main +package bottin import ( "fmt" diff --git a/routes.go b/pkg/bottin/routes.go similarity index 99% rename from routes.go rename to pkg/bottin/routes.go index a631aba..dc97041 100644 --- a/routes.go +++ b/pkg/bottin/routes.go @@ -1,4 +1,4 @@ -package main +package bottin import ( "encoding/csv" @@ -13,11 +13,11 @@ import ( "github.com/labstack/echo/v4" ) -func addRoutes(e *echo.Echo, db *PostgresClient, cfg Config) error { +func AddRoutes(e *echo.Echo, db *PostgresClient, cfg Config) error { _ = db _ = cfg - apiPath := "/api/v8" + apiPath := "/api/v9" apiGroup := e.Group(apiPath) p := pave.New() if err := pave.EchoRegister[HealthGETRequest]( diff --git a/queries/queries.go b/queries/queries.go new file mode 100644 index 0000000..0fa44d8 --- /dev/null +++ b/queries/queries.go @@ -0,0 +1,14 @@ +package queries + +import ( + _ "embed" +) + +//go:embed schema.sql +var sqlSchema string + +//go:embed views.sql +var sqlViews string + +func SQLSchema() string { return sqlSchema } +func SQLViews() string { return sqlViews } diff --git a/sql/schema.sql b/queries/schema.sql similarity index 100% rename from sql/schema.sql rename to queries/schema.sql diff --git a/sql/views.sql b/queries/views.sql similarity index 100% rename from sql/views.sql rename to queries/views.sql diff --git a/scripts/compose-inject-x509.sh b/scripts/compose-inject-x509.sh new file mode 100755 index 0000000..42ed90c --- /dev/null +++ b/scripts/compose-inject-x509.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +docker-compose cp cert.pem api:/etc/bottin/cert.pem +docker-compose cp key.pem api:/etc/bottin/key.pem +docker-compose cp cert.pem ui:/etc/bottin/cert.pem +docker-compose cp key.pem ui:/etc/bottin/key.pem diff --git a/scripts/generate-self-signed-x509.sh b/scripts/generate-self-signed-x509.sh new file mode 100755 index 0000000..2e1a5bc --- /dev/null +++ b/scripts/generate-self-signed-x509.sh @@ -0,0 +1,2 @@ +#!/bin/sh +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes diff --git a/template.go b/templates/templates.go similarity index 55% rename from template.go rename to templates/templates.go index a5b4980..2539cde 100644 --- a/template.go +++ b/templates/templates.go @@ -1,4 +1,4 @@ -package main +package templates import ( "embed" @@ -8,7 +8,7 @@ import ( "github.com/labstack/echo/v4" ) -//go:embed templates/* +//go:embed *.html var templatesFS embed.FS type Template struct { @@ -18,3 +18,10 @@ type Template struct { func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { return t.templates.ExecuteTemplate(w, name, data) } + +// NewTemplate returns a new Template instance with templates embedded from *.html +func NewTemplate() *Template { + return &Template{ + templates: template.Must(template.ParseFS(templatesFS, "*.html")), + } +} -- 2.45.2