Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 123 additions & 55 deletions cmd/config/configuration.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package config

import (
"fmt"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"text/template"
"time"

"github.com/YuukanOO/seelf/cmd/serve"
"github.com/YuukanOO/seelf/internal/deployment/domain"
"github.com/YuukanOO/seelf/pkg/bus"
"github.com/YuukanOO/seelf/pkg/bus/embedded"
"github.com/YuukanOO/seelf/pkg/bytesize"
"github.com/YuukanOO/seelf/pkg/config"
"github.com/YuukanOO/seelf/pkg/crypto"
Expand All @@ -18,11 +23,13 @@ import (
"github.com/YuukanOO/seelf/pkg/monad"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/ostools"
"github.com/YuukanOO/seelf/pkg/storage"
"github.com/YuukanOO/seelf/pkg/validate"
"github.com/YuukanOO/seelf/pkg/validate/numbers"
"github.com/YuukanOO/seelf/pkg/validate/arrays"
)

var (
dotEnvFilenames = []string{".env", ".env.local"}
userConfigDir = must.Panic(os.UserConfigDir())
generatedSecretKey = must.Panic(crypto.RandomKey[string](64))
defaultDataDirectory = filepath.Join(userConfigDir, "seelf")
Expand All @@ -31,16 +38,16 @@ var (
)

const (
databaseConnectionString = "seelf.db?_journal=WAL&_timeout=5000&_foreign_keys=yes&_txlock=immediate&_synchronous=NORMAL"
defaultConfigFilename = "conf.yml"
defaultPort = 8080
defaultHost = ""
defaultRunnersPollInterval = "4s"
defaultRunnersDeploymentCount = 4
defaultCleanupDeploymentCount = 2
defaultBalancerDomain = "http://docker.localhost"
defaultDeploymentDirTemplate = "{{ .Environment }}"
defaultSourceArchiveMaxSize = "32mb"
databaseConnectionString = "seelf.db?_journal=WAL&_timeout=5000&_foreign_keys=yes&_txlock=immediate&_synchronous=NORMAL"
defaultConfigFilename = "conf.yml"
defaultPort = 8080
defaultHost = ""
defaultRunnersPollInterval = "4s"
defaultDeploymentWorkersCount uint8 = 4
defaultGeneralWorkersCount uint8 = 2
defaultBalancerDomain = "http://docker.localhost"
defaultDeploymentDirTemplate = "{{ .Environment }}"
defaultSourceArchiveMaxSize = "32mb"
)

type (
Expand All @@ -60,20 +67,16 @@ type (
Data dataConfiguration
Source sourceConfiguration
Http httpConfiguration
Runners runnersConfiguration
Runners runnersConfiguration `env:"RUNNERS_CONFIGURATION"`
Private internalConfiguration `yaml:"-"`

appExposedUrl monad.Maybe[domain.Url]
pollInterval time.Duration
deploymentDirTemplate *template.Template
logLevel log.Level
logFormat log.OutputFormat
sourceArchiveMaxSize int64
}

logConfiguration struct {
Level string `env:"LOG_LEVEL"`
Format string `env:"LOG_FORMAT"`

level log.Level
format log.OutputFormat
}

httpConfiguration struct {
Expand All @@ -87,29 +90,37 @@ type (
dataConfiguration struct {
Path string `env:"DATA_PATH"`
DeploymentDirTemplate string `env:"DEPLOYMENT_DIR_TEMPLATE" yaml:"deployment_dir_template"`

deploymentDirTemplate *template.Template
}

sourceArchiveConfiguration struct {
MaxSize string `env:"SOURCE_ARCHIVE_MAX_SIZE" yaml:"max_size"`

maxSize int64
}

// Contains configuration related to deployment sources
sourceConfiguration struct {
Archive sourceArchiveConfiguration
}

// Configuration related to the async jobs runners.
runnersConfiguration struct {
PollInterval string `env:"RUNNERS_POLL_INTERVAL" yaml:"poll_interval"`
Deployment int `env:"RUNNERS_DEPLOYMENT_COUNT" yaml:"deployment"`
Cleanup int `env:"RUNNERS_CLEANUP_COUNT" yaml:"cleanup"`
// Configuration related to the background runners.
runnerConfiguration struct {
PollInterval string `yaml:"poll_interval"`
Count uint8 `yaml:"count"`
Jobs []string `yaml:"jobs,omitempty"`

pollInterval time.Duration
}

// internalConfiguration fields not read from the configuration file and use only during specific steps
internalConfiguration struct {
Email string `env:"SEELF_ADMIN_EMAIL,ADMIN_EMAIL"`
Password string `env:"SEELF_ADMIN_PASSWORD,ADMIN_PASSWORD"`
ExposedOn string `env:"EXPOSED_ON"` // Container name and default target url (ie. http://seelf@docker.localhost)

exposedUrl monad.Maybe[domain.Url]
}
)

Expand All @@ -134,11 +145,11 @@ func Default(builders ...ConfigurationBuilder) Configuration {
Port: defaultPort,
Secret: generatedSecretKey,
},
Runners: runnersConfiguration{
PollInterval: defaultRunnersPollInterval,
Deployment: defaultRunnersDeploymentCount,
Cleanup: defaultCleanupDeploymentCount,
},
Runners: defaultRunnersConfiguration(
defaultRunnersPollInterval,
defaultDeploymentWorkersCount,
defaultGeneralWorkersCount,
),
}

for _, builder := range builders {
Expand All @@ -153,27 +164,30 @@ func Default(builders ...ConfigurationBuilder) Configuration {
}

func (c *configuration) Initialize(logger log.ConfigurableLogger, path string) error {
exists, err := config.Load(path, c)
var configFileFound bool

if err != nil {
if err := config.Load(c,
config.FromYAML(path, &configFileFound),
config.FromEnvironment(dotEnvFilenames...),
); err != nil {
return err
}

if err = c.validate(); err != nil {
if err := c.validate(); err != nil {
return err
}

// Make sure the data path exists
if err = ostools.MkdirAll(c.Data.Path); err != nil {
if err := ostools.MkdirAll(c.Data.Path); err != nil {
return err
}

// Update logger based on loaded configuration
if err = logger.Configure(c.logFormat, c.logLevel); err != nil {
if err := logger.Configure(c.Log.format, c.Log.level); err != nil {
return err
}

if exists {
if configFileFound {
logger.Infow("configuration loaded",
"path", path)
} else {
Expand All @@ -193,25 +207,72 @@ func (c *configuration) Initialize(logger log.ConfigurableLogger, path string) e
return nil
}

func (c *configuration) DataDir() string { return c.Data.Path }
func (c *configuration) DeploymentDirTemplate() *template.Template { return c.deploymentDirTemplate }
func (c *configuration) AppExposedUrl() monad.Maybe[domain.Url] { return c.appExposedUrl }
func (c *configuration) DefaultEmail() string { return c.Private.Email }
func (c *configuration) DefaultPassword() string { return c.Private.Password }
func (c *configuration) Secret() []byte { return []byte(c.Http.Secret) }
func (c *configuration) RunnersPollInterval() time.Duration { return c.pollInterval }
func (c *configuration) RunnersDeploymentCount() int { return c.Runners.Deployment }
func (c *configuration) RunnersCleanupCount() int { return c.Runners.Cleanup }
func (c *configuration) IsDebug() bool { return c.logLevel == log.DebugLevel }
func (c *configuration) MaxDeploymentArchiveFileSize() int64 { return c.sourceArchiveMaxSize }
func (c *configuration) DataDir() string { return c.Data.Path }
func (c *configuration) DeploymentDirTemplate() *template.Template {
return c.Data.deploymentDirTemplate
}
func (c *configuration) AppExposedUrl() monad.Maybe[domain.Url] { return c.Private.exposedUrl }
func (c *configuration) DefaultEmail() string { return c.Private.Email }
func (c *configuration) DefaultPassword() string { return c.Private.Password }
func (c *configuration) Secret() []byte { return []byte(c.Http.Secret) }
func (c *configuration) IsDebug() bool { return c.Log.level == log.DebugLevel }
func (c *configuration) MaxDeploymentArchiveFileSize() int64 {
return c.Source.Archive.maxSize
}

func (c *configuration) RunnersDefinitions(mapper *storage.DiscriminatedMapper[bus.AsyncRequest]) ([]embedded.RunnerDefinition, error) {
definitions := make([]embedded.RunnerDefinition, len(c.Runners))
unhandledMessages := mapper.Keys()

for i, r := range c.Runners {
// No specific messages set, handle all messages not seen already.
// Since the validate function ensure only the last worker can have an empty list,
// we should be good.
if len(r.Jobs) == 0 {
r.Jobs = unhandledMessages
unhandledMessages = nil
}

messages := make([]bus.AsyncRequest, len(r.Jobs))

for j, msg := range r.Jobs {
// Remove the msg from the unhandledMessages
msgIdx := slices.Index(unhandledMessages, msg)

if msgIdx != -1 {
unhandledMessages = slices.Delete(unhandledMessages, msgIdx, msgIdx+1)
}

req, err := mapper.From(msg, "{}")

if err != nil {
return nil, fmt.Errorf("unknown job name: %s, must be one of %s", msg, strings.Join(mapper.Keys(), ", "))
}

messages[j] = req
}

definitions[i] = embedded.RunnerDefinition{
PollInterval: r.pollInterval,
WorkersCount: r.Count,
Messages: messages,
}
}

if len(unhandledMessages) > 0 {
return nil, fmt.Errorf("some background jobs are not handled: %s, please fix your configuration by adding a worker to handle them", strings.Join(unhandledMessages, ", "))
}

return definitions, nil
}

func (c *configuration) IsSecure() bool {
// If secure has been explicitly isSet, returns it
if secure, isSet := c.Http.Secure.TryGet(); isSet {
return secure
}

if defaultTargetUrl, isSet := c.appExposedUrl.TryGet(); isSet {
if defaultTargetUrl, isSet := c.Private.exposedUrl.TryGet(); isSet {
return defaultTargetUrl.UseSSL()
}

Expand All @@ -229,22 +290,29 @@ func (c *configuration) ListenAddress() string {
}

func (c *configuration) validate() error {
lastRunnerIdx := len(c.Runners) - 1

return validate.Struct(validate.Of{
"log.level": validate.Value(c.Log.Level, &c.logLevel, log.ParseLevel),
"log.format": validate.Value(c.Log.Format, &c.logFormat, log.ParseFormat),
"source.archive.max_size": validate.Value(c.Source.Archive.MaxSize, &c.sourceArchiveMaxSize, bytesize.Parse),
"data.deployment_dir_template": validate.Value(c.Data.DeploymentDirTemplate, &c.deploymentDirTemplate, template.New("").Parse),
"runners.poll_interval": validate.Value(c.Runners.PollInterval, &c.pollInterval, time.ParseDuration),
"runners.deployment": validate.Field(c.Runners.Deployment, numbers.Min(1)),
"runners.cleanup": validate.Field(c.Runners.Cleanup, numbers.Min(1)),
"log.level": validate.Value(c.Log.Level, &c.Log.level, log.ParseLevel),
"log.format": validate.Value(c.Log.Format, &c.Log.format, log.ParseFormat),
"source.archive.max_size": validate.Value(c.Source.Archive.MaxSize, &c.Source.Archive.maxSize, bytesize.Parse),
"data.deployment_dir_template": validate.Value(c.Data.DeploymentDirTemplate, &c.Data.deploymentDirTemplate, template.New("").Parse),
"runners": validate.Array(c.Runners, func(runner runnerConfiguration, idx int) error {
return validate.Struct(validate.Of{
"poll_interval": validate.Value(runner.PollInterval, &c.Runners[idx].pollInterval, time.ParseDuration),
"jobs": validate.If(idx != lastRunnerIdx, func() error {
return validate.Field(runner.Jobs, arrays.Required)
}),
})
}),
"exposed_as": validate.If(c.Private.ExposedOn != "", func() error {
url, err := domain.UrlFrom(c.Private.ExposedOn)

if err != nil {
return err
}

c.appExposedUrl.Set(url)
c.Private.exposedUrl.Set(url)

return nil
}),
Expand Down
Loading