commit 4eb5f4da673d9bae58abd430e35efa7b4ea50b59 Author: nhyatt Date: Thu Jul 11 14:12:22 2024 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e689df1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Application created directories +output/ + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launce.json +!.vscode/extensions.json +!.vscode/*.code-snippets +.history/ +*.vsix + +# GoLang +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# General +.DS_Store +.AppleDouble +.LSOverride +# Icon must end with two \r +Icon + + +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +# Dump file +*.stackdump +# Folder config file +[Dd]esktop.ini +# Recycle Bin used on file shares +$RECYCLE.BIN/ +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp +# Windows shortcuts +*.lnk diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..7a02377 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,58 @@ +linters: + disable-all: true + enable: + # default linters + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + # project linters + - asasalint + - asciicheck + - bodyclose + - contextcheck + - dupl + - durationcheck + - errchkjson + - gocheckcompilerdirectives + - gocognit + - goconst + - gocritic + - godox + - goimports + - gosec + - grouper + - importas + - misspell + - musttag + - nestif + - nilerr + - nilnil + - prealloc + - reassign + - tagalign + - tenv + - unconvert + - unparam + - usestdlibvars + - wastedassign + - whitespace + fast: true +linter-settings: + tagalign: + order: + - json + - yaml + - yml + - toml + - mapstructure + - binding + - validate + - env + - default + - ignored + - required + - secret + - info diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bb0dba0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "golang.go", + "oderwat.indent-rainbow" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fce29d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Build Container +#### +FROM golang:alpine AS builder + +COPY . /go/src/app + +WORKDIR /go/src/app + +RUN apk add --no-cache git && \ + git config --global --add safe.directory /go/src/app && \ + addgroup -S -g 1000 app && \ + adduser --disabled-password -G app --gecos "application account" --home "/home/app" --shell "/sbin/nologin" --no-create-home --uid 1000 app && \ + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -tags timetzdata -o go-webserver ./ + +# Step 3 - Running Container +#### +FROM scratch + +COPY --from=builder /etc/passwd /etc/group /etc/ +COPY --from=builder --chown=app:app /go/src/app/go-webserver /app/go-webserver + +USER app:app +WORKDIR /app/ + +ENTRYPOINT ["/app/go-webserver"] \ No newline at end of file diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..3ed61af --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed html/* +var EmbedData embed.FS diff --git a/assets/html/file-not-found.tplt b/assets/html/file-not-found.tplt new file mode 100644 index 0000000..ccf7693 --- /dev/null +++ b/assets/html/file-not-found.tplt @@ -0,0 +1,40 @@ + + + + + {{ .Title }} + + + + +

{{ printf "%d" .ErrorCode }}

+ + \ No newline at end of file diff --git a/assets/html/images/game.png b/assets/html/images/game.png new file mode 100644 index 0000000..47b714b Binary files /dev/null and b/assets/html/images/game.png differ diff --git a/assets/html/index.tplt b/assets/html/index.tplt new file mode 100644 index 0000000..4ab7bde --- /dev/null +++ b/assets/html/index.tplt @@ -0,0 +1,15 @@ + + + + + {{ .Title }} + + + +
+ Time: {{ .Time }} +
+ +
+ + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0eeb53 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module example.com/go-performance + +go 1.22.5 diff --git a/internal/config/envconfig.go b/internal/config/envconfig.go new file mode 100644 index 0000000..73c1786 --- /dev/null +++ b/internal/config/envconfig.go @@ -0,0 +1,241 @@ +package config + +import ( + "flag" + "fmt" + "os" + "reflect" + "strconv" + "strings" +) + +type structInfo struct { + Name string + Alt string + Info string + Key string + Field reflect.Value + Tags reflect.StructTag + Type reflect.Type + DefaultValue interface{} + Secret interface{} +} + +func getEnv[t string | bool | int | int64 | float64](env string, def t) (t, error) { + val := os.Getenv(env) + if len(val) == 0 { + return def, nil + } + + output := *new(t) + switch (interface{})(def).(type) { + case string: + v, err := typeConversion("string", val) + if err != nil { + return (interface{})(false).(t), err + } + output = v.(t) + case bool: + v, err := typeConversion("bool", val) + if err != nil { + return (interface{})(false).(t), err + } + output = v.(t) + case int: + v, err := typeConversion("int", val) + if err != nil { + return (interface{})(int(0)).(t), err + } + output = (interface{})(int(v.(int64))).(t) + case int64: + v, err := typeConversion("int64", val) + if err != nil { + return (interface{})(int64(0)).(t), err + } + output = v.(t) + case float64: + v, err := typeConversion("float64", val) + if err != nil { + return (interface{})(float64(0)).(t), err + } + output = v.(t) + } + + return output, nil +} + +func getStructInfo(spec interface{}) ([]structInfo, error) { + s := reflect.ValueOf(spec) + + if s.Kind() != reflect.Pointer { + return []structInfo{}, fmt.Errorf("getStructInfo() was sent a %s instead of a pointer to a struct.\n", s.Kind()) + } + + s = s.Elem() + if s.Kind() != reflect.Struct { + return []structInfo{}, fmt.Errorf("getStructInfo() was sent a %s instead of a struct.\n", s.Kind()) + } + typeOfSpec := s.Type() + + infos := make([]structInfo, 0, s.NumField()) + for i := 0; i < s.NumField(); i++ { + f := s.Field(i) + ftype := typeOfSpec.Field(i) + + ignored, _ := strconv.ParseBool(ftype.Tag.Get("ignored")) + if !f.CanSet() || ignored { + continue + } + + for f.Kind() == reflect.Pointer { + if f.IsNil() { + if f.Type().Elem().Kind() != reflect.Struct { + break + } + f.Set(reflect.New(f.Type().Elem())) + } + f = f.Elem() + } + + secret, err := typeConversion(ftype.Type.String(), ftype.Tag.Get("secret")) + if err != nil { + secret = false + } + + var desc string + if len(ftype.Tag.Get("info")) != 0 { + desc = fmt.Sprintf("(%s) %s", strings.ToUpper(ftype.Tag.Get("env")), ftype.Tag.Get("info")) + } else { + desc = fmt.Sprintf("(%s)", strings.ToUpper(ftype.Tag.Get("env"))) + } + + info := structInfo{ + Name: ftype.Name, + Alt: strings.ToUpper(ftype.Tag.Get("env")), + Info: desc, + Key: ftype.Name, + Field: f, + Tags: ftype.Tag, + Type: ftype.Type, + Secret: secret, + } + if info.Alt != "" { + info.Key = info.Alt + } + info.Key = strings.ToUpper(info.Key) + if ftype.Tag.Get("default") != "" { + v, err := typeConversion(ftype.Type.String(), ftype.Tag.Get("default")) + if err != nil { + return []structInfo{}, err + } + info.DefaultValue = v + } + infos = append(infos, info) + } + return infos, nil +} + +func typeConversion(t, v string) (interface{}, error) { + switch t { + case "string": //nolint:goconst + return v, nil + case "int": //nolint:goconst + return strconv.ParseInt(v, 10, 0) + case "int8": + return strconv.ParseInt(v, 10, 8) + case "int16": + return strconv.ParseInt(v, 10, 16) + case "int32": + return strconv.ParseInt(v, 10, 32) + case "int64": + return strconv.ParseInt(v, 10, 64) + case "uint": + return strconv.ParseUint(v, 10, 0) + case "uint16": + return strconv.ParseUint(v, 10, 16) + case "uint32": + return strconv.ParseUint(v, 10, 32) + case "uint64": + return strconv.ParseUint(v, 10, 64) + case "float32": + return strconv.ParseFloat(v, 32) + case "float64": + return strconv.ParseFloat(v, 64) + case "complex64": + return strconv.ParseComplex(v, 64) + case "complex128": + return strconv.ParseComplex(v, 128) + case "bool": //nolint:goconst + return strconv.ParseBool(v) + } + return nil, fmt.Errorf("Unable to identify type.") +} + +func (cfg *Config) parseFlags(cfgInfo []structInfo) error { //nolint:gocognit + for _, info := range cfgInfo { + switch info.Type.String() { + case "string": + var dv string + + if info.DefaultValue != nil { + dv = info.DefaultValue.(string) + } + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*string) + retVal, err := getEnv(info.Alt, dv) + if err != nil { + return err + } + flag.StringVar(p, info.Name, retVal, info.Info) + case "bool": + var dv bool + + if info.DefaultValue != nil { + dv = info.DefaultValue.(bool) + } + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*bool) + retVal, err := getEnv(info.Alt, dv) + if err != nil { + return err + } + flag.BoolVar(p, info.Name, retVal, info.Info) + case "int": + var dv int + + if info.DefaultValue != nil { + dv = int(info.DefaultValue.(int64)) + } + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*int) + retVal, err := getEnv(info.Alt, dv) + if err != nil { + return err + } + flag.IntVar(p, info.Name, retVal, info.Info) + case "int64": + var dv int64 + + if info.DefaultValue != nil { + dv = info.DefaultValue.(int64) + } + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*int64) + retVal, err := getEnv(info.Alt, dv) + if err != nil { + return err + } + flag.Int64Var(p, info.Name, retVal, info.Info) + case "float64": + var dv float64 + + if info.DefaultValue != nil { + dv = info.DefaultValue.(float64) + } + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*float64) + retVal, err := getEnv(info.Alt, dv) + if err != nil { + return err + } + flag.Float64Var(p, info.Name, retVal, info.Info) + } + } + flag.Parse() + return nil +} diff --git a/internal/config/initialize.go b/internal/config/initialize.go new file mode 100644 index 0000000..16ee1a3 --- /dev/null +++ b/internal/config/initialize.go @@ -0,0 +1,38 @@ +package config + +import ( + "log" + "os" + "time" +) + +func Init() Config { + cfg := New() + + cfgInfo, err := getStructInfo(&cfg) + if err != nil { + log.Fatalf("Unable to initialize program: %v", err) + } + + // get command line flags + if err := cfg.parseFlags(cfgInfo); err != nil { + log.Fatalf("Unable to initialize program: %v", err) + } + + // set logging Level + setLogLevel(&cfg) + + // set timezone & time format + cfg.TZUTC, _ = time.LoadLocation("UTC") + cfg.TZLocal, err = time.LoadLocation(cfg.TimeZoneLocal) + if err != nil { + cfg.Log.Error("Unable to parse timezone string", "error", err) + os.Exit(1) + } + + // print running config + printRunningConfig(&cfg, cfgInfo) + + // return configuration + return cfg +} diff --git a/internal/config/struct-config.go b/internal/config/struct-config.go new file mode 100644 index 0000000..f499a77 --- /dev/null +++ b/internal/config/struct-config.go @@ -0,0 +1,81 @@ +package config + +import ( + "log/slog" + "os" + "reflect" + "strconv" + "time" +) + +type Config struct { + // time configuration + TimeFormat string `default:"2006-01-02 15:04:05" env:"time_format"` + TimeZoneLocal string `default:"America/Chicago" env:"time_zone"` + TZLocal *time.Location `ignored:"true"` + TZUTC *time.Location `ignored:"true"` + + // logging + LogLevel int `default:"50" env:"log_level"` + Log *slog.Logger `ignored:"true"` + SLogLevel *slog.LevelVar `ignored:"true"` + + // webserver + WebServerPort int `default:"8080" env:"webserver_port"` + WebServerIP string `default:"0.0.0.0" env:"webserver_ip"` + WebServerReadTimeout int `default:"5" env:"webserver_read_timeout"` + WebServerWriteTimeout int `default:"1" env:"webserver_write_timeout"` + WebServerIdleTimeout int `default:"2" env:"webserver_idle_timeout"` +} + +// New initializes the config variable for use with a prepared set of defaults. +func New() Config { + cfg := Config{ + SLogLevel: new(slog.LevelVar), + } + + cfg.Log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: cfg.SLogLevel, + })) + + return cfg +} + +func setLogLevel(cfg *Config) { + switch { + // error + case cfg.LogLevel <= 20: + cfg.SLogLevel.Set(slog.LevelError) + cfg.Log.Info("Log level updated", "level", slog.LevelError) + // warning + case cfg.LogLevel > 20 && cfg.LogLevel <= 40: + cfg.SLogLevel.Set(slog.LevelWarn) + cfg.Log.Info("Log level updated", "level", slog.LevelWarn) + // info + case cfg.LogLevel > 40 && cfg.LogLevel <= 60: + cfg.SLogLevel.Set(slog.LevelInfo) + cfg.Log.Info("Log level updated", "level", slog.LevelInfo) + // debug + case cfg.LogLevel > 60: + cfg.SLogLevel.Set(slog.LevelDebug) + cfg.Log.Info("Log level updated", "level", slog.LevelDebug) + } + // set default logger + slog.SetDefault(cfg.Log) +} + +func printRunningConfig(cfg *Config, cfgInfo []structInfo) { + for _, info := range cfgInfo { + switch info.Type.String() { + case "string": + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*string) + cfg.Log.Debug("Running Configuration", info.Alt, *p) + case "bool": + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*bool) + cfg.Log.Debug("Running Configuration", info.Alt, strconv.FormatBool(*p)) + case "int": + p := reflect.ValueOf(cfg).Elem().FieldByName(info.Name).Addr().Interface().(*int) + cfg.Log.Debug("Running Configuration", info.Alt, strconv.FormatInt(int64(*p), 10)) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..98d1c9b --- /dev/null +++ b/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "html/template" + "log/slog" + "net/http" + "os" + "os/signal" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "example.com/go-performance/assets" + "example.com/go-performance/internal/config" +) + +const ( + cTcss string = "text/css" + cTxpem string = "application/x-pem-file" + cTpdf string = "application/pdf" + cTmpeg string = "audio/mpeg" + cTwoff string = "font/woff" + cTwoff2 string = "font/woff2" + cTpng string = "image/png" + cTjpeg string = "image/jpg" + cTjs string = "text/javascript" + cTjson string = "application/json" + cTplain string = "text/plain" + cTraw string = "text/raw" + cThtml string = "text/html" +) + +type webErrStruct struct { + Error bool `json:"error" yaml:"error"` + ErrorMsg string `json:"error_message" yaml:"errorMessage"` +} + +var ( + validFiles map[string]string = map[string]string{ + "/images/game.png": cTpng, + } + cfg config.Config +) + +func forever(log *slog.Logger) { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + sig := <-c + log.Warn("Shutting down, triggered by signal", "signal", sig) +} + +func main() { + // initialize all parameters + cfg = config.Init() + + // configure shutdown sequence + defer func() { + cfg.Log.Info("Shutdown sequence complete") + }() + + // start webserver + go httpServer(cfg.Log) + + forever(cfg.Log) +} + +func httpServer(log *slog.Logger) { + path := http.NewServeMux() + + connection := &http.Server{ + Addr: cfg.WebServerIP + ":" + strconv.FormatInt(int64(cfg.WebServerPort), 10), + Handler: path, + ReadTimeout: time.Duration(cfg.WebServerReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WebServerWriteTimeout) * time.Second, + IdleTimeout: time.Duration(cfg.WebServerIdleTimeout) * time.Second, + } + + path.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { webRoot(log, w, r) }) + + if err := connection.ListenAndServe(); err != nil { + log.Error("Unable to create webserver", "function", "httpServer", "error", err) + panic(err) + } +} + +func isValidReq(file string) (string, error) { + for f, t := range validFiles { + if file == f { + return t, nil + } + } + + return "", fmt.Errorf("Invalid file requested: %s", file) +} + +func webRoot(log *slog.Logger, w http.ResponseWriter, r *http.Request) { + httpAccessLog(log, r) + + if strings.ToLower(r.Method) != "get" { + log.Debug("Request made using the wrong method", "function", "webRoot", "url", r.URL.Path, "expected-method", "GET", "requested-method", r.Method) + tmpltError(log, w, http.StatusBadRequest, "Invalid http method.") + return + } + + if r.URL.Path == "/" { + tmpltWebRoot(log, w) + return + } + + cType, err := isValidReq(r.URL.Path) + if err != nil { + log.Debug("Request not found", "function", "webRoot", "url", r.URL.Path) + tmpltStatusNotFound(log, w, r.URL.Path) + return + } + + w.Header().Add("Content-Type", cType) + o, err := assets.EmbedData.ReadFile("html" + r.URL.Path) + if err != nil { + log.Error("Unable to read local embedded file", "function", "webRoot", "error", err) + tmpltError(log, w, http.StatusInternalServerError, "Server unable to retrieve file data.") + return + } + + if regexp.MustCompile(`gzip`).Match([]byte(r.Header.Get("Accept-Encoding"))) { + w.Header().Add("Content-Encoding", "gzip") + gw := gzip.NewWriter(w) + defer gw.Close() + gw.Write(o) //nolint: errcheck + } else { + w.Write(o) //nolint: errcheck + } +} + +func httpAccessLog(log *slog.Logger, req *http.Request) { + log.Debug("AccessLog", "method", req.Method, "remote-addr", req.RemoteAddr, "uri", req.RequestURI) +} + +func tmpltWebRoot(log *slog.Logger, w http.ResponseWriter) { + tmplt, err := template.ParseFS( + assets.EmbedData, + "html/index.tplt", + ) + if err != nil { + log.Debug("Unable to parse HTML template", "function", "tmpltWebRoot", "error", err) + tmpltError(log, w, http.StatusInternalServerError, "Template Parse Error.") + return + } + + var msgBuffer bytes.Buffer + if err := tmplt.Execute(&msgBuffer, struct { + Time string + Title string + Version string + }{ + Time: time.Now().In(cfg.TZLocal).Format(cfg.TimeFormat), + Title: "Performance Test", + Version: "v1.0.0", + }); err != nil { + log.Debug("Unable to execute HTML template", "function", "tmpltWebRoot", "error", err) + tmpltError(log, w, http.StatusInternalServerError, "Template Parse Error.") + return + } + + w.Write(msgBuffer.Bytes()) //nolint: errcheck +} + +func tmpltError(log *slog.Logger, w http.ResponseWriter, serverStatus int, message string) { + var ( + output []byte + o = webErrStruct{ + Error: true, + ErrorMsg: message, + } + err error + ) + + w.Header().Add("Content-Type", "application/json") + output, err = json.MarshalIndent(o, "", " ") + if err != nil { + log.Warn("Unable to marshal error", "function", "tmpltError", "error", err) + w.WriteHeader(serverStatus) + w.Write(output) //nolint:errcheck + } +} + +func tmpltStatusNotFound(log *slog.Logger, w http.ResponseWriter, path string) { + tmplt, err := template.ParseFS(assets.EmbedData, "html/file-not-found.tplt") + if err != nil { + log.Debug("Unable to parse HTML template", "function", "tmpltStatusNotFound", "error", err) + tmpltError(log, w, http.StatusInternalServerError, "Template Parse Error.") + return + } + + var msgBuffer bytes.Buffer + if err := tmplt.Execute(&msgBuffer, struct { + Title string + ErrorCode int + }{ + Title: path, + ErrorCode: http.StatusNotFound, + }); err != nil { + log.Debug("Unable to execute HTML template", "function", "tmpltStatusNotFound", "error", err) + tmpltError(log, w, http.StatusInternalServerError, "Template Parse Error.") + return + } + w.Write(msgBuffer.Bytes()) //nolint: errcheck +}