commit 221afdb9b281dd6ddb06f54751397c102cd7db01 Author: Melora Hugues Date: Wed Oct 11 09:55:28 2023 +0200 Initial commit diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..be4bc2f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,90 @@ +--- +# Test building the code and docker image +kind: pipeline +type: docker +name: test-build + +steps: +- name: go-test + image: golang + commands: + - make -C template test + depends_on: + +- name: go-build + image: golang + commands: + - make -C template build + depends_on: + +- name: docker-build-only + image: thegeeklab/drone-docker-buildx + privileged: true + settings: + repo: git.faercol.me/go-web-template + tags: latest + dry_run: true + platforms: + - linux/amd64 + - linux/arm64 + depends_on: + when: + branch: + exclude: + - main + +# - name: docker-build-push +# image: thegeeklab/drone-docker-buildx +# privileged: true +# settings: +# repo: git.faercol.me/ +# registry: git.faercol.me +# tags: latest +# username: +# from_secret: GIT_USERNAME +# password: +# from_secret: GIT_PASSWORD +# platforms: +# - linux/amd64 +# - linux/arm64 +# depends_on: +# - go-test +# - go-build +# when: +# branch: +# - main + +# trigger: +# event: +# - push +# - tag + +# --- +# # On a tag, only build the related docker image +# kind: pipeline +# type: docker +# name: tag-release +# depends_on: +# - test-build + +# steps: +# - name: docker-push-tag +# image: thegeeklab/drone-docker-buildx +# privileged: true +# settings: +# registry: git.faercol.me +# repo: git.faercol.me/ +# auto_tag: true +# platforms: +# - linux/amd64 +# - linux/arm64 +# username: +# from_secret: GIT_USERNAME +# password: +# from_secret: GIT_PASSWORD + +# trigger: +# event: +# - tag + +... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83a83a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Go build file +**/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..06c6df2 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# go-web-template + +[![Build Status](https://drone.faercol.me/api/badges/faercol/go-web-template/status.svg)](https://drone.faercol.me/faercol/go-web-template) + +Simple template for Go modules with a Web server \ No newline at end of file diff --git a/template/Makefile b/template/Makefile new file mode 100644 index 0000000..16349ff --- /dev/null +++ b/template/Makefile @@ -0,0 +1,10 @@ +.PHONY: build test + +build: + go build -o build/ + +test: + go test -v ./... + +run: build + ./build/template diff --git a/template/config/config.go b/template/config/config.go new file mode 100644 index 0000000..01ab09e --- /dev/null +++ b/template/config/config.go @@ -0,0 +1,109 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/sirupsen/logrus" +) + +type ListeningMode int64 + +func (lm ListeningMode) String() string { + mapping := map[ListeningMode]string{ + ModeNet: "net", + ModeUnix: "unix", + } + val := mapping[lm] + return val +} + +func listeningModeFromString(rawVal string) (ListeningMode, error) { + mapping := map[string]ListeningMode{ + "unix": ModeUnix, + "net": ModeNet, + } + if typedVal, ok := mapping[rawVal]; !ok { + return ModeNet, fmt.Errorf("invalid listening mode %s", rawVal) + } else { + return typedVal, nil + } +} + +const ( + ModeUnix ListeningMode = iota + ModeNet +) + +type jsonConf struct { + Log struct { + Level string `json:"level"` + } `json:"log"` + Server struct { + Host string `json:"host"` + Port int `json:"port"` + Mode string `json:"mode"` + SockPath string `json:"sock"` + } `json:"server"` +} + +type AppConfig struct { + LogLevel logrus.Level + ServerMode ListeningMode + Host string + Port int + SockPath string +} + +func parseLevel(lvlStr string) logrus.Level { + for _, lvl := range logrus.AllLevels { + if lvl.String() == lvlStr { + return lvl + } + } + return logrus.InfoLevel +} + +func (ac *AppConfig) UnmarshalJSON(data []byte) error { + var jsonConf jsonConf + if err := json.Unmarshal(data, &jsonConf); err != nil { + return fmt.Errorf("failed to read JSON: %w", err) + } + ac.LogLevel = parseLevel(jsonConf.Log.Level) + + lm, err := listeningModeFromString(jsonConf.Server.Mode) + if err != nil { + return fmt.Errorf("failed to parse server listening mode: %w", err) + } + ac.ServerMode = lm + ac.SockPath = jsonConf.Server.SockPath + ac.Host = jsonConf.Server.Host + ac.Port = jsonConf.Server.Port + return nil +} + +var defaultConfig AppConfig = AppConfig{ + LogLevel: logrus.InfoLevel, + ServerMode: ModeNet, + Host: "0.0.0.0", + Port: 5000, +} + +func New(filepath string) (*AppConfig, error) { + content, err := os.ReadFile(filepath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + conf := defaultConfig + return &conf, nil + } + return nil, fmt.Errorf("failed to read config file %q: %w", filepath, err) + } + var conf AppConfig + if err := json.Unmarshal(content, &conf); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + return &conf, nil +} diff --git a/template/config/config_test.go b/template/config/config_test.go new file mode 100644 index 0000000..7088e2d --- /dev/null +++ b/template/config/config_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "os" + "path" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListeningModeString(t *testing.T) { + assert.Equal(t, "net", ModeNet.String(), "Unexpected string value") + assert.Equal(t, "unix", ModeUnix.String(), "Unexpected string value") +} + +// Test returning a default config when providing a path that does not exist +func TestDefault(t *testing.T) { + conf, err := New("/this/path/does/not/exist") + if assert.Nil(t, err, "Unexpected error") { + assert.Equal(t, defaultConfig, *conf, "Unexpected config") + } +} + +// Test creating a valid config (net mode) +func TestOKNet(t *testing.T) { + tmpPath := t.TempDir() + content := `{ + "log": { + "level": "error" + }, + "server": { + "mode": "net", + "host": "127.0.0.1", + "port": 8888 + } +}` + confPath := path.Join(tmpPath, "config.json") + require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + + expectedConf := AppConfig{ + LogLevel: logrus.ErrorLevel, + ServerMode: ModeNet, + Host: "127.0.0.1", + Port: 8888, + } + conf, err := New(confPath) + if assert.Nil(t, err, "Unexpected error") { + assert.Equal(t, expectedConf, *conf, "Unexpected config") + } +} + +// Test creating a valid config (unix mode) +func TestOKUnix(t *testing.T) { + tmpPath := t.TempDir() + content := `{ + "log": { + "level": "error" + }, + "server": { + "mode": "unix", + "sock": "/run/toto.sock" + } +}` + confPath := path.Join(tmpPath, "config.json") + require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + + expectedConf := AppConfig{ + LogLevel: logrus.ErrorLevel, + ServerMode: ModeUnix, + SockPath: "/run/toto.sock", + } + conf, err := New(confPath) + if assert.Nil(t, err, "Unexpected error") { + assert.Equal(t, expectedConf, *conf, "Unexpected config") + } +} + +// Test creating a valid config, no log level provided, should be info +func TestOKNoLogLevel(t *testing.T) { + tmpPath := t.TempDir() + content := `{ + "server": { + "mode": "net", + "host": "127.0.0.1", + "port": 8888 + } +}` + confPath := path.Join(tmpPath, "config.json") + require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + + expectedConf := AppConfig{ + LogLevel: logrus.InfoLevel, + ServerMode: ModeNet, + Host: "127.0.0.1", + Port: 8888, + } + conf, err := New(confPath) + if assert.Nil(t, err, "Unexpected error") { + assert.Equal(t, expectedConf, *conf, "Unexpected config") + } +} + +// Test giving an invalid server mode +func TestErrMode(t *testing.T) { + tmpPath := t.TempDir() + content := `{ + "log": { + "level": "error" + }, + "server": { + "mode": "toto", + "sock": "/run/toto.sock" + } +}` + confPath := path.Join(tmpPath, "config.json") + require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + + _, err := New(confPath) + if assert.Error(t, err, "Unexpected nil error") { + errMsg := "failed to parse config file: failed to parse server listening mode: invalid listening mode toto" + assert.Equal(t, errMsg, err.Error(), "Unexpected error message") + } +} + +func TestInvalidJSON(t *testing.T) { + tmpPath := t.TempDir() + content := "toto" + confPath := path.Join(tmpPath, "config.json") + require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + _, err := New(confPath) + if assert.Error(t, err, "Unexpected nil error") { + errMsg := "failed to parse config file: invalid character 'o' in literal true (expecting 'r')" + assert.Equal(t, errMsg, err.Error(), "Unexpected error message") + } +} diff --git a/template/go.mod b/template/go.mod new file mode 100644 index 0000000..c2a73ab --- /dev/null +++ b/template/go.mod @@ -0,0 +1,15 @@ +module git.faercol.me/faercol/go-mod-template/template + +go 1.20 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/template/go.sum b/template/go.sum new file mode 100644 index 0000000..9243c28 --- /dev/null +++ b/template/go.sum @@ -0,0 +1,16 @@ +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/template/logger/logger.go b/template/logger/logger.go new file mode 100644 index 0000000..31b8a0e --- /dev/null +++ b/template/logger/logger.go @@ -0,0 +1,10 @@ +package logger + +import "github.com/sirupsen/logrus" + +var L *logrus.Logger + +func Init(level logrus.Level) { + L = logrus.New() + L.SetLevel(level) +} diff --git a/template/main.go b/template/main.go new file mode 100644 index 0000000..5ef4d49 --- /dev/null +++ b/template/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "time" + + "git.faercol.me/faercol/go-mod-template/template/config" + "git.faercol.me/faercol/go-mod-template/template/logger" + "git.faercol.me/faercol/go-mod-template/template/server" +) + +const stopTimeout = 10 * time.Second + +type cliArgs struct { + configPath string +} + +func parseArgs() *cliArgs { + configPath := flag.String("config", "", "Path to the JSON configuration file") + + flag.Parse() + + return &cliArgs{ + configPath: *configPath, + } +} + +func main() { + args := parseArgs() + + mainCtx, cancel := context.WithCancel(context.Background()) + + conf, err := config.New(args.configPath) + if err != nil { + panic(err) + } + + logger.Init(conf.LogLevel) + logger.L.Infof("Initialized logger with level %v", conf.LogLevel) + + logger.L.Info("Initializing server") + s, err := server.New(conf, logger.L) + if err != nil { + logger.L.Fatalf("Failed to initialize server: %s", err.Error()) + } + + go s.Run(mainCtx) + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + + logger.L.Info("Application successfully started") + + logger.L.Debug("Waiting for stop signal") + select { + case <-s.Done(): + logger.L.Fatal("Unexpected exit from server") + case <-c: + logger.L.Info("Stopping main application") + cancel() + } + + logger.L.Debugf("Waiting %v for all daemons to stop", stopTimeout) + select { + case <-time.After(stopTimeout): + logger.L.Fatalf("Failed to stop all daemons in the expected time") + case <-s.Done(): + logger.L.Info("web server successfully stopped") + } + + logger.L.Info("Application successfully stopped") + os.Exit(0) +} diff --git a/template/server/server.go b/template/server/server.go new file mode 100644 index 0000000..9b9ac5b --- /dev/null +++ b/template/server/server.go @@ -0,0 +1,107 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + + "git.faercol.me/faercol/go-mod-template/template/config" + "github.com/sirupsen/logrus" +) + +type Server struct { + ctx context.Context + cancel context.CancelFunc + httpSrv *http.Server + listener net.Listener + serverMode config.ListeningMode + address string + handler *http.ServeMux + l *logrus.Logger +} + +func newUnixListener(sockPath string) (net.Listener, error) { + if err := os.Remove(sockPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to cleanup previously existing socket: %w", err) + } + + sock, err := net.Listen("unix", sockPath) + if err != nil { + return nil, fmt.Errorf("failed to create unix socket: %w", err) + } + if err := os.Chmod(sockPath, 0o777); err != nil { + return nil, fmt.Errorf("failed to set permissions to unix socket: %w", err) + } + return sock, nil +} + +func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) { + var listener net.Listener + var addr string + var err error + switch appConf.ServerMode { + case config.ModeNet: + addr = fmt.Sprintf("%s:%d", appConf.Host, appConf.Port) + listener, err = net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to init server in net mode: %w", err) + } + case config.ModeUnix: + addr = appConf.SockPath + listener, err = newUnixListener(appConf.SockPath) + if err != nil { + return nil, fmt.Errorf("failed to init server in unix mode: %w", err) + } + default: + panic(fmt.Errorf("unexpected listening mode %v", appConf.ServerMode)) + } + + m := http.NewServeMux() + + return &Server{ + handler: m, + httpSrv: &http.Server{ + Handler: m, + }, + listener: listener, + l: logger, + serverMode: appConf.ServerMode, + address: addr, + }, nil +} + +func (s *Server) initMux() { + s.handler.HandleFunc("/", s.statusHandler) +} + +func (s *Server) Run(ctx context.Context) { + s.ctx, s.cancel = context.WithCancel(ctx) + s.initMux() + switch s.serverMode { + case config.ModeNet: + s.l.Infof("Server listening on host %q", s.address) + case config.ModeUnix: + s.l.Infof("Server listening on unix socket %q", s.address) + default: + } + if err := s.httpSrv.Serve(s.listener); err != nil { + s.l.Errorf("failed to serve HTTP server: %s", err.Error()) + } + s.cancel() +} + +func (s *Server) Done() <-chan struct{} { + return s.ctx.Done() +} + +func (s *Server) statusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte("Hello world!")) +}