From 015e686635fd6e05fc0a92348eabaeb79edf0612 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Wed, 26 Jun 2024 14:52:21 +0400 Subject: [PATCH] =?UTF-8?q?wip:=20=F0=9F=94=95=20temporary=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/serve/command.go | 70 +++++++++++--------- internal/cli/shared/flags.go | 10 ++- internal/cli/shared/flags_test.go | 16 +++-- internal/config/config.go | 9 ++- internal/config/rotation_mode.go | 14 ++-- internal/config/rotation_mode_test.go | 4 +- internal/config/templates.go | 22 ++++++ internal/config/templates_test.go | 28 ++++++++ internal/http/handlers/error_page/handler.go | 59 +++++++++++++++-- 9 files changed, 180 insertions(+), 52 deletions(-) diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 988938e..899f013 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -39,8 +39,9 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy var ( addrFlag = shared.ListenAddrFlag portFlag = shared.ListenPortFlag - addTplFlag = shared.AddTemplateFlag - addCodeFlag = shared.AddHTTPCodeFlag + addTplFlag = shared.AddTemplatesFlag + disableTplFlag = shared.DisableTemplateNamesFlag + addCodeFlag = shared.AddHTTPCodesFlag jsonFormatFlag = cli.StringFlag{ Name: "json-format", Usage: "override the default error page response in JSON format (Go templates are supported)", @@ -140,16 +141,6 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy return nil }, } - - // readBufferSizeFlag = cli.UintFlag{ - // Name: "read-buffer-size", - // Usage: "customize the HTTP read buffer size (set per connection for reading requests, also limits the " + - // "maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB " + - // "headers, such as large cookies)", - // DefaultText: "not set", - // Sources: cli.EnvVars("READ_BUFFER_SIZE"), - // OnlyOnce: true, - // } ) cmd.c = &cli.Command{ @@ -160,16 +151,28 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy Action: func(ctx context.Context, c *cli.Command) error { cmd.opt.http.addr = c.String(addrFlag.Name) cmd.opt.http.port = uint16(c.Uint(portFlag.Name)) - // cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name)) - - cfg.TemplateName = c.String(templateNameFlag.Name) cfg.L10n.Disable = c.Bool(disableL10nFlag.Name) cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name) cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name)) cfg.ShowDetails = c.Bool(showDetailsFlag.Name) - if add := c.StringSlice(addTplFlag.Name); len(add) > 0 { // add templates from files to the config + { // override default JSON, XML, and PlainText formats + if c.IsSet(jsonFormatFlag.Name) { + cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name)) + } + + if c.IsSet(xmlFormatFlag.Name) { + cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name)) + } + + if c.IsSet(plainTextFormatFlag.Name) { + cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name)) + } + } + + // add templates from files to the configuration + if add := c.StringSlice(addTplFlag.Name); len(add) > 0 { for _, templatePath := range add { if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil { return fmt.Errorf("cannot add template from file %s: %w", templatePath, err) @@ -182,10 +185,7 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy } } - if !cfg.Templates.Has(cfg.TemplateName) { - return fmt.Errorf("template %s not found and cannot be used", cfg.TemplateName) - } - + // set the list of HTTP headers we need to proxy from the incoming request to the error page response if c.IsSet(proxyHeadersListFlag.Name) { var m = make(map[string]struct{}) // map is used to avoid duplicates @@ -200,7 +200,8 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy } } - if add := c.StringMap(addCodeFlag.Name); len(add) > 0 { // add custom HTTP codes + // add custom HTTP codes to the configuration + if add := c.StringMap(addCodeFlag.Name); len(add) > 0 { for code, msgAndDesc := range add { var ( parts = strings.SplitN(msgAndDesc, "/", 2) //nolint:mnd @@ -225,17 +226,24 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy } } - { // override default JSON and XML formats - if c.IsSet(jsonFormatFlag.Name) { - cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name)) + // disable templates specified by the user + if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 { + for _, templateName := range disable { + if ok := cfg.Templates.Remove(templateName); ok { + log.Info("Template disabled", logger.String("name", templateName)) + } } + } - if c.IsSet(xmlFormatFlag.Name) { - cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name)) - } + // if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided + // template name) + if cfg.RotationMode == config.RotationModeRandomOnStartup { + cfg.TemplateName = cfg.Templates.RandomName() + } else { // otherwise, use the user-provided template name + cfg.TemplateName = c.String(templateNameFlag.Name) - if c.IsSet(plainTextFormatFlag.Name) { - cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name)) + if !cfg.Templates.Has(cfg.TemplateName) { + return fmt.Errorf("template %s not found and cannot be used", cfg.TemplateName) } } @@ -244,10 +252,12 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy logger.Strings("described HTTP codes", cfg.Codes.Codes()...), logger.String("JSON format", cfg.Formats.JSON), logger.String("XML format", cfg.Formats.XML), + logger.String("plain text format", cfg.Formats.PlainText), logger.String("template name", cfg.TemplateName), logger.Bool("disable localization", cfg.L10n.Disable), logger.Uint16("default code to render", cfg.DefaultCodeToRender), logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode), + logger.String("rotation mode", cfg.RotationMode.String()), logger.Bool("show details", cfg.ShowDetails), logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...), ) @@ -258,6 +268,7 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy &addrFlag, &portFlag, &addTplFlag, + &disableTplFlag, &addCodeFlag, &jsonFormatFlag, &xmlFormatFlag, @@ -269,7 +280,6 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy &showDetailsFlag, &proxyHeadersListFlag, &rotationModeFlag, - // &readBufferSizeFlag, }, } diff --git a/internal/cli/shared/flags.go b/internal/cli/shared/flags.go index 5c83819..a495867 100644 --- a/internal/cli/shared/flags.go +++ b/internal/cli/shared/flags.go @@ -49,7 +49,7 @@ var ListenPortFlag = cli.UintFlag{ }, } -var AddTemplateFlag = cli.StringSliceFlag{ +var AddTemplatesFlag = cli.StringSliceFlag{ Name: "add-template", Usage: "to add a new template, provide the path to the file using this flag (the filename without the extension " + "will be used as the template name)", @@ -69,7 +69,13 @@ var AddTemplateFlag = cli.StringSliceFlag{ }, } -var AddHTTPCodeFlag = cli.StringMapFlag{ +var DisableTemplateNamesFlag = cli.StringSliceFlag{ + Name: "disable-template", + Usage: "disable the specified template by its name", + Config: cli.StringConfig{TrimSpace: true}, +} + +var AddHTTPCodesFlag = cli.StringMapFlag{ Name: "add-http-code", Usage: "to add a new HTTP status code, provide the code and its message/description using this flag (the format " + "should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, " + diff --git a/internal/cli/shared/flags_test.go b/internal/cli/shared/flags_test.go index 9e48a63..014b4e2 100644 --- a/internal/cli/shared/flags_test.go +++ b/internal/cli/shared/flags_test.go @@ -84,10 +84,10 @@ func TestListenPortFlag(t *testing.T) { } } -func TestAddTemplateFlag(t *testing.T) { +func TestAddTemplatesFlag(t *testing.T) { t.Parallel() - var flag = shared.AddTemplateFlag + var flag = shared.AddTemplatesFlag assert.Equal(t, "add-template", flag.Name) @@ -108,10 +108,18 @@ func TestAddTemplateFlag(t *testing.T) { } } -func TestAddHTTPCodeFlag(t *testing.T) { +func TestDisableTemplateNamesFlag(t *testing.T) { t.Parallel() - var flag = shared.AddHTTPCodeFlag + var flag = shared.DisableTemplateNamesFlag + + assert.Equal(t, "disable-template", flag.Name) +} + +func TestAddHTTPCodesFlag(t *testing.T) { + t.Parallel() + + var flag = shared.AddHTTPCodesFlag assert.Equal(t, "add-http-code", flag.Name) diff --git a/internal/config/config.go b/internal/config/config.go index 003ba60..353b2cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,7 +74,8 @@ const defaultJSONFormat string = `{ "request_id": {{ request_id | json }}, "timestamp": {{ now.Unix }} }{{ end }} -}` +} +` // an empty line at the end is important for better UX const defaultXMLFormat string = ` @@ -92,7 +93,8 @@ const defaultXMLFormat string = ` {{ request_id }} {{ now.Unix }} {{ end }} -` + +` // an empty line at the end is important for better UX const defaultPlainTextFormat string = `Error {{ code }}: {{ message }}{{ if description }} {{ description }}{{ end }}{{ if show_details }} @@ -105,7 +107,8 @@ Ingress Name: {{ ingress_name }} Service Name: {{ service_name }} Service Port: {{ service_port }} Request ID: {{ request_id }} -Timestamp: {{ now.Unix }}{{ end }}` +Timestamp: {{ now.Unix }}{{ end }} +` // an empty line at the end is important for better UX //nolint:lll var defaultCodes = Codes{ //nolint:gochecknoglobals diff --git a/internal/config/rotation_mode.go b/internal/config/rotation_mode.go index f4bff1c..e9bbb38 100644 --- a/internal/config/rotation_mode.go +++ b/internal/config/rotation_mode.go @@ -12,8 +12,8 @@ const ( RotationModeDisabled RotationMode = iota // do not rotate templates, default RotationModeRandomOnStartup // pick a random template on startup RotationModeRandomOnEachRequest // pick a random template on each request - RotationModeRandomDaily // once a day switch to a random template RotationModeRandomHourly // once an hour switch to a random template + RotationModeRandomDaily // once a day switch to a random template ) // String returns a human-readable representation of the rotation mode. @@ -25,10 +25,10 @@ func (rm RotationMode) String() string { return "random-on-startup" case RotationModeRandomOnEachRequest: return "random-on-each-request" - case RotationModeRandomDaily: - return "random-daily" case RotationModeRandomHourly: return "random-hourly" + case RotationModeRandomDaily: + return "random-daily" } return fmt.Sprintf("RotationMode(%d)", rm) @@ -40,8 +40,8 @@ func RotationModes() []RotationMode { RotationModeDisabled, RotationModeRandomOnStartup, RotationModeRandomOnEachRequest, - RotationModeRandomDaily, RotationModeRandomHourly, + RotationModeRandomDaily, } } @@ -77,11 +77,11 @@ func ParseRotationMode[T string | []byte](text T) (RotationMode, error) { return RotationModeRandomOnStartup, nil case RotationModeRandomOnEachRequest.String(): return RotationModeRandomOnEachRequest, nil - case RotationModeRandomDaily.String(): - return RotationModeRandomDaily, nil case RotationModeRandomHourly.String(): return RotationModeRandomHourly, nil + case RotationModeRandomDaily.String(): + return RotationModeRandomDaily, nil } - return RotationMode(0), fmt.Errorf("unrecognized rotation mode: %q", mode) + return RotationModeDisabled, fmt.Errorf("unrecognized rotation mode: %q", mode) } diff --git a/internal/config/rotation_mode_test.go b/internal/config/rotation_mode_test.go index f359b7c..05d5b19 100644 --- a/internal/config/rotation_mode_test.go +++ b/internal/config/rotation_mode_test.go @@ -27,8 +27,8 @@ func TestRotationModes(t *testing.T) { config.RotationModeDisabled, config.RotationModeRandomOnStartup, config.RotationModeRandomOnEachRequest, - config.RotationModeRandomDaily, config.RotationModeRandomHourly, + config.RotationModeRandomDaily, }, config.RotationModes()) } @@ -39,8 +39,8 @@ func TestRotationModeStrings(t *testing.T) { "disabled", "random-on-startup", "random-on-each-request", - "random-daily", "random-hourly", + "random-daily", }, config.RotationModeStrings()) } diff --git a/internal/config/templates.go b/internal/config/templates.go index ff08029..9ee3a7e 100644 --- a/internal/config/templates.go +++ b/internal/config/templates.go @@ -81,3 +81,25 @@ func (tpl templates) Has(name string) (found bool) { _, found = tpl[name]; retur // Get returns the template content by the specified name, if it exists. func (tpl templates) Get(name string) (data string, ok bool) { data, ok = tpl[name]; return } //nolint:nlreturn + +// Remove deletes the template by the specified name. +func (tpl templates) Remove(name string) (ok bool) { + if _, ok = tpl[name]; ok { + delete(tpl, name) + } + + return +} + +// RandomName picks a random template name. It returns an empty string if there are no templates. +func (tpl templates) RandomName() string { + if len(tpl) == 0 { + return "" + } + + for name := range tpl { // map iteration order is unpredictable (random) by design + return name + } + + return "" +} diff --git a/internal/config/templates_test.go b/internal/config/templates_test.go index c6f834b..0065cf0 100644 --- a/internal/config/templates_test.go +++ b/internal/config/templates_test.go @@ -39,6 +39,10 @@ func TestTemplates_Common(t *testing.T) { assert.Equal(t, []string{"_test11", "_test99", "test"}, tpl.Names()) // sorted assert.True(t, tpl.Has("_test99")) + + assert.True(t, tpl.Remove("_test99")) + assert.False(t, tpl.Has("_test99")) + assert.False(t, tpl.Remove("_test99")) }) t.Run("adding template without a name should fail", func(t *testing.T) { @@ -134,3 +138,27 @@ func TestTemplates_AddFromFile(t *testing.T) { }) } } + +func TestTemplates_RandomName(t *testing.T) { + t.Parallel() + + var ( + tpl = templates{"test": "content", "test2": "content", "test3": "content"} + + lastName = tpl.RandomName() + changedCount int + ) + + for range 1_000 { + var name = tpl.RandomName() + + if name != lastName { + changedCount++ + } + + lastName = name + } + + // I expect at least 100 different names in 1000 iterations + assert.True(t, changedCount > 200) +} diff --git a/internal/http/handlers/error_page/handler.go b/internal/http/handlers/error_page/handler.go index 6588e5c..ba628b7 100644 --- a/internal/http/handlers/error_page/handler.go +++ b/internal/http/handlers/error_page/handler.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "sync/atomic" + "time" "gh.tarampamp.am/error-pages/internal/config" "gh.tarampamp.am/error-pages/internal/logger" @@ -86,7 +88,6 @@ func New(cfg *config.Config, log *logger.Logger) http.Handler { //nolint:funlen, tplProps.Host = r.Header.Get("Host") // the value of the `Host` header } - // TODO: ADD SUPPORT FOR THE RANDOM TEMPLATE AND SO ON // try to find the code message and description in the config and if not - use the standard status text or fallback if desc, found := cfg.Codes.Find(code); found { tplProps.Message = desc.Message @@ -116,11 +117,13 @@ func New(cfg *config.Config, log *logger.Logger) http.Handler { //nolint:funlen, } case format == htmlFormat: - if tpl, found := cfg.Templates.Get(cfg.TemplateName); found { + var templateName = templateToUse(cfg) + + if tpl, found := cfg.Templates.Get(templateName); found { if content, err := template.Render(tpl, tplProps); err != nil { write(w, log, fmt.Sprintf( "\nFailed to render the HTML template %s: %s", - cfg.TemplateName, + templateName, err.Error(), )) } else { @@ -128,7 +131,7 @@ func New(cfg *config.Config, log *logger.Logger) http.Handler { //nolint:funlen, } } else { write(w, log, fmt.Sprintf( - "\nTemplate %s not found and cannot be used", cfg.TemplateName, + "\nTemplate %s not found and cannot be used", templateName, )) } @@ -149,6 +152,54 @@ Supported formats: JSON, XML, HTML, Plain Text`) }) } +var ( + templateChangedAt atomic.Pointer[time.Time] //nolint:gochecknoglobals // the time when the theme was changed last time + pickedTemplate atomic.Pointer[string] //nolint:gochecknoglobals // the name of the randomly picked template +) + +// templateToUse decides which template to use based on the rotation mode and the last time the template was changed. +func templateToUse(cfg *config.Config) string { + switch rotationMode := cfg.RotationMode; rotationMode { + case config.RotationModeDisabled: + return cfg.TemplateName // not needed to do anything + case config.RotationModeRandomOnStartup: + return cfg.TemplateName // do nothing, the scope of this rotation mode is not here + case config.RotationModeRandomOnEachRequest: + return cfg.Templates.RandomName() // pick a random template on each request + case config.RotationModeRandomHourly, config.RotationModeRandomDaily: + var now, rndTemplate = time.Now(), cfg.Templates.RandomName() + + if changedAt := templateChangedAt.Load(); changedAt == nil { + // the template was not changed yet (first request) + templateChangedAt.Store(&now) + pickedTemplate.Store(&rndTemplate) + + return rndTemplate + } else { + // is it time to change the template? + if (rotationMode == config.RotationModeRandomHourly && changedAt.Hour() != now.Hour()) || + (rotationMode == config.RotationModeRandomDaily && changedAt.Day() != now.Day()) { + templateChangedAt.Store(&now) + pickedTemplate.Store(&rndTemplate) + + return rndTemplate + } else if lastUsed := pickedTemplate.Load(); lastUsed != nil { + // time to change the template has not come yet, so use the last picked template + return *lastUsed + } else { + // in case if the last picked template is not set, pick a random one and store it + templateChangedAt.Store(&now) + pickedTemplate.Store(&rndTemplate) + + return rndTemplate + } + } + } + + return cfg.TemplateName // the fallback of the fallback :D +} + +// write the content to the response writer and log the error if any. func write[T string | []byte](w http.ResponseWriter, log *logger.Logger, content T) { var data []byte