From 945329a0807f5e9a03f4d266926d4bacf121f7ca Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Sun, 12 Feb 2023 18:13:52 +0100 Subject: [PATCH] Move to yaml-based configuration This commit updates the configuration to be yaml-based and updates the configuration to read in a yaml file. --- cmd/config.go | 3 +- cmd/config_migrate.go | 64 ++++++++ cmd/system_update.go | 8 +- cmd/wdcli/main.go | 1 + go.mod | 1 + go.sum | 2 + internal/bootstrap/bootstrap.go | 2 +- internal/config/config.go | 72 ++------- internal/config/config_template | 73 --------- internal/config/config_template.yml | 83 ++++++++++ internal/config/database.go | 23 +++ internal/config/docker.go | 6 + internal/config/executable.go | 33 ---- internal/config/home.go | 10 ++ internal/config/{domains.go => http.go} | 61 +++++--- .../config/legacy}/envreader/envreader.go | 24 +-- .../legacy}/envreader/envreader_test.go | 0 internal/config/legacy/legacy.go | 147 ++++++++++++++++++ .../config/legacy}/stringparser/parse.go | 0 .../legacy}/stringparser/stringparser.go | 3 +- internal/config/paths.go | 52 +++++++ internal/config/read.go | 67 +++----- internal/config/runtime.go | 10 -- internal/config/template.go | 12 +- internal/config/validators/collection.go | 32 ++++ internal/config/validators/domain.go | 21 +++ internal/config/validators/duration.go | 14 ++ internal/config/validators/email.go | 24 +++ internal/config/validators/files.go | 27 ++++ internal/config/validators/int.go | 32 ++++ internal/config/validators/slug.go | 24 +++ internal/config/validators/string.go | 16 ++ internal/config/validators/url.go | 46 ++++++ internal/dis/component/auth/next/next.go | 2 +- internal/dis/component/auth/panel/ssh.go | 4 +- internal/dis/component/exporter/exporter.go | 2 +- .../dis/component/exporter/extras_config.go | 6 +- internal/dis/component/exporter/prune.go | 2 +- internal/dis/component/instances/create.go | 14 +- internal/dis/component/instances/instances.go | 2 +- internal/dis/component/instances/runtime.go | 2 +- internal/dis/component/resolver/resolver.go | 4 +- .../component/server/admin/html/index.html | 4 +- internal/dis/component/server/home/home.go | 2 +- internal/dis/component/server/home/public.go | 2 +- .../dis/component/server/home/redirect.go | 2 +- internal/dis/component/server/server.go | 4 +- internal/dis/component/server/stack.go | 16 +- .../dis/component/server/templating/assets.go | 2 +- internal/dis/component/solr/solr.go | 4 +- internal/dis/component/sql/connect.go | 6 +- internal/dis/component/sql/sql.go | 6 +- internal/dis/component/sql/update.go | 8 +- internal/dis/component/ssh2/api.go | 2 +- internal/dis/component/ssh2/server_forward.go | 5 +- internal/dis/component/ssh2/server_handler.go | 4 +- internal/dis/component/ssh2/stack.go | 16 +- .../dis/component/triplestore/database.go | 2 +- .../dis/component/triplestore/triplestore.go | 4 +- internal/dis/component/triplestore/update.go | 4 +- internal/dis/component/web/web.go | 12 +- internal/dis/init.go | 2 +- internal/wisski/ingredient/barrel/stack.go | 6 +- .../wisski/ingredient/php/extras/prefixes.go | 2 +- internal/wisski/ingredient/reserve/reserve.go | 4 +- internal/wisski/liquid/domain.go | 4 +- pkg/validator/validator.go | 88 +++++++++++ pkg/validator/validator_test.go | 123 +++++++++++++++ pkg/validator/vmap.go | 131 ++++++++++++++++ program.go | 4 +- 70 files changed, 1150 insertions(+), 350 deletions(-) create mode 100644 cmd/config_migrate.go delete mode 100644 internal/config/config_template create mode 100644 internal/config/config_template.yml create mode 100644 internal/config/database.go create mode 100644 internal/config/docker.go delete mode 100644 internal/config/executable.go create mode 100644 internal/config/home.go rename internal/config/{domains.go => http.go} (52%) rename {pkg => internal/config/legacy}/envreader/envreader.go (89%) rename {pkg => internal/config/legacy}/envreader/envreader_test.go (100%) create mode 100644 internal/config/legacy/legacy.go rename {pkg => internal/config/legacy}/stringparser/parse.go (100%) rename {pkg => internal/config/legacy}/stringparser/stringparser.go (97%) create mode 100644 internal/config/paths.go delete mode 100644 internal/config/runtime.go create mode 100644 internal/config/validators/collection.go create mode 100644 internal/config/validators/domain.go create mode 100644 internal/config/validators/duration.go create mode 100644 internal/config/validators/email.go create mode 100644 internal/config/validators/files.go create mode 100644 internal/config/validators/int.go create mode 100644 internal/config/validators/slug.go create mode 100644 internal/config/validators/string.go create mode 100644 internal/config/validators/url.go create mode 100644 pkg/validator/validator.go create mode 100644 pkg/validator/validator_test.go create mode 100644 pkg/validator/vmap.go diff --git a/cmd/config.go b/cmd/config.go index c4e9831..726a359 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -21,6 +21,5 @@ func (c cfg) Description() wisski_distillery.Description { } func (cfg) Run(context wisski_distillery.Context) error { - context.Printf("%#v", context.Environment.Config) - return nil + return context.Environment.Config.Marshal(context.Stdout) } diff --git a/cmd/config_migrate.go b/cmd/config_migrate.go new file mode 100644 index 0000000..481a1a4 --- /dev/null +++ b/cmd/config_migrate.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "bytes" + + wisski_distillery "github.com/FAU-CDI/wisski-distillery" + "github.com/FAU-CDI/wisski-distillery/internal/cli" + "github.com/FAU-CDI/wisski-distillery/internal/config" + "github.com/FAU-CDI/wisski-distillery/internal/config/legacy" + "github.com/FAU-CDI/wisski-distillery/pkg/environment" +) + +// ConfigMigrate is the config-migrate command +var ConfigMigrate wisski_distillery.Command = cfgMigrate{} + +type cfgMigrate struct { + Positionals struct { + Input string `positional-arg-name:"input" required:"1-1" description:"old config to migrate"` + } `positional-args:"true"` +} + +func (cfgMigrate) Description() wisski_distillery.Description { + return wisski_distillery.Description{ + Requirements: cli.Requirements{ + NeedsDistillery: false, + }, + Command: "config_migrate", + Description: "migrate legacy configuration", + } +} + +func (c cfgMigrate) Run(context wisski_distillery.Context) error { + // migration environment is the native environment! + env := new(environment.Native) + + // open the legacy file + file, err := env.Open(c.Positionals.Input) + if err != nil { + return err + } + defer file.Close() + + // migrate from a legacy configuration + // then marshal, and re-read + + var cfg config.Config + { + var mconfig config.Config + var output bytes.Buffer + + if err := legacy.Migrate(&mconfig, env, file); err != nil { + return err + } + if err := mconfig.Marshal(&output); err != nil { + return err + } + if err := cfg.Unmarshal(env, &output); err != nil { + return err + } + } + + // do a final marshal + return cfg.Marshal(context.Stdout) +} diff --git a/cmd/system_update.go b/cmd/system_update.go index 5e0af71..8f86528 100644 --- a/cmd/system_update.go +++ b/cmd/system_update.go @@ -68,7 +68,7 @@ func (si systemupdate) Run(context wisski_distillery.Context) error { // create all the other directories logging.LogMessage(context.Stderr, context.Context, "Ensuring distillery installation directories exist") for _, d := range []string{ - dis.Config.DeployRoot, + dis.Config.Paths.Root, dis.Instances().Path(), dis.Exporter().StagingPath(), dis.Exporter().ArchivePath(), @@ -102,7 +102,7 @@ func (si systemupdate) Run(context wisski_distillery.Context) error { } logging.LogMessage(context.Stderr, context.Context, "Checking that 'docker' is installed") - if err := si.mustExec(context, "", "docker", "--version", dis.Config.DockerNetworkName); err != nil { + if err := si.mustExec(context, "", "docker", "--version"); err != nil { return err } @@ -114,7 +114,7 @@ func (si systemupdate) Run(context wisski_distillery.Context) error { // create the docker network // TODO: Use docker API for this logging.LogMessage(context.Stderr, context.Context, "Updating Docker Configuration") - si.mustExec(context, "", "docker", "network", "create", dis.Config.DockerNetworkName) + si.mustExec(context, "", "docker", "network", "create", dis.Config.Docker.Network) // install and update the various stacks! ctx := component.InstallationContext{ @@ -193,7 +193,7 @@ var errMustExecFailed = exit.Error{ func (si systemupdate) mustExec(context wisski_distillery.Context, workdir string, exe string, argv ...string) error { dis := context.Environment if workdir == "" { - workdir = dis.Config.DeployRoot + workdir = dis.Config.Paths.Root } code := dis.Still.Environment.Exec(context.Context, context.IOStream, workdir, exe, argv...)() if code != 0 { diff --git a/cmd/wdcli/main.go b/cmd/wdcli/main.go index 6305517..f9d8024 100644 --- a/cmd/wdcli/main.go +++ b/cmd/wdcli/main.go @@ -18,6 +18,7 @@ var wdcli = wisski_distillery.NewProgram() func init() { // self commands wdcli.Register(cmd.Config) + wdcli.Register(cmd.ConfigMigrate) wdcli.Register(cmd.License) // setup commands diff --git a/go.mod b/go.mod index 18fc7d5..a616f0c 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( golang.org/x/exp v0.0.0-20230105202349-8879d0199aa3 golang.org/x/sync v0.1.0 golang.org/x/term v0.3.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.4.4 gorm.io/gorm v1.24.2 ) diff --git a/go.sum b/go.sum index cf131af..6d733be 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ= gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 016be21..bb87fe5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -15,7 +15,7 @@ const Executable = "wdcli" // ConfigFile is the name of the config file. // It should be located inside the deployment directory. -const ConfigFile = ".env" +const ConfigFile = "distillery.yaml" // OverridesJSON is the name of the json overrides file. // It should be located inside the deployment directory. diff --git a/internal/config/config.go b/internal/config/config.go index 237b2d5..03d4e4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,7 +5,6 @@ import ( "fmt" "hash/fnv" "math/rand" - "net/url" "reflect" "time" @@ -19,80 +18,33 @@ import ( // Config contains many methods that do not require any interaction with any running components. // Methods that require running components are instead store inside the [Distillery] or an appropriate [Component]. type Config struct { - // Several docker-compose files are created to manage global services and the system itself. - // On top of this all real-system space will be created under this directory. - DeployRoot string `env:"DEPLOY_ROOT" default:"/var/www/deploy" parser:"abspath"` + Paths PathsConfig `yaml:"paths" recurse:"true"` + HTTP HTTPConfig `yaml:"http" recurse:"true"` + Theme ThemeConfig `yaml:"theme" recurse:"true"` + Docker DockerConfig `yaml:"docker" recurse:"true"` - // Each created Drupal Instance corresponds to a single domain name. - // These domain names should either be a complete domain name or a sub-domain of a default domain. - // This setting configures the default domain-name to create subdomains of. - DefaultDomain string `env:"DEFAULT_DOMAIN" default:"localhost.kwarc.info" parser:"domain"` - - // By default, the default domain redirects to the distillery repository. - // If you want to change this, set an alternate domain name here. - SelfRedirect *url.URL `env:"SELF_REDIRECT" default:"https://github.com/FAU-CDI/wisski-distillery" parser:"https_url"` - - // By default, only the 'self' domain above is caught. - // To catch additional domains, add them here (comma seperated) - SelfExtraDomains []string `env:"SELF_EXTRA_DOMAINS" default:"" parser:"domains"` - - // You can override individual URLS in the homepage - // Do this by adding URLs (without trailing '/'s) into a JSON file - SelfOverridesFile string `env:"SELF_OVERRIDES_FILE" default:"" parser:"file"` - - // You can block specific prefixes from being picked up by the resolver. - // Do this by adding one prefix per file. - SelfResolverBlockFile string `env:"SELF_RESOLVER_BLOCK_FILE" default:"" parser:"file"` - - // The system can support setting up certificate(s) automatically. - // It can be enabled by setting an email for certbot certificates. - // This email address can be configured here. - CertbotEmail string `env:"CERTBOT_EMAIL" default:"" parser:"email"` + SQL SQLConfig `yaml:"sql" recurse:"true"` + TS TSConfig `yaml:"triplestore" recurse:"true"` // Maximum age for backup in days - MaxBackupAge int `env:"MAX_BACKUP_AGE" default:"" parser:"number"` - - // Each Drupal instance requires a corresponding system user, database users and databases. - // These are also set by the appropriate domain name. - // To differentiate them from other users of the system, these names can be prefixed. - // The prefix to use can be configured here. - // When changing these please consider that no system user may exist that has the same name as a mysql user. - // This is a MariaDB restriction. - MysqlUserPrefix string `env:"MYSQL_USER_PREFIX" default:"mysql-factory-" parser:"slug"` - MysqlDatabasePrefix string `env:"MYSQL_DATABASE_PREFIX" default:"mysql-factory-" parser:"slug"` - GraphDBUserPrefix string `env:"GRAPHDB_USER_PREFIX" default:"mysql-factory-" parser:"slug"` - GraphDBRepoPrefix string `env:"GRAPHDB_REPO_PREFIX" default:"mysql-factory-" parser:"slug"` - - // In addition to the filesystem the WissKI distillery requires a single SQL table. - // It uses this database to store a list of installed things. - DistilleryDatabase string `env:"DISTILLERY_BOOKKEEPING_DATABASE" default:"distillery" parser:"slug"` + MaxBackupAge time.Duration `yaml:"age" validate:"duration"` // Various components use password-based-authentication. // These passwords are generated automatically. // This variable can be used to determine their length. - PasswordLength int `env:"PASSWORD_LENGTH" default:"64" parser:"number"` + PasswordLength int `yaml:"password_length" default:"64" validate:"positive"` // Public port to use for the ssh server - PublicSSHPort uint16 `env:"SSH_PORT" default:"2222" parser:"port"` + PublicSSHPort uint16 `yaml:"ssh_port" default:"2222" validate:"port"` - // admin credentials for graphdb - TriplestoreAdminUser string `env:"GRAPHDB_ADMIN_USER" default:"admin" parser:"nonempty"` - TriplestoreAdminPassword string `env:"GRAPHDB_ADMIN_PASSWORD" default:"" parser:"nonempty"` - - // admin credentials for the Mysql database - MysqlAdminUser string `env:"MYSQL_ADMIN_USER" default:"admin" parser:"nonempty"` - MysqlAdminPassword string `env:"MYSQL_ADMIN_PASSWORD" default:"" parser:"nonempty"` // session secret holds the secret for login - SessionSecret string `env:"SESSION_SECRET" default:"" parser:"nonempty"` - - // name of docker network to use - DockerNetworkName string `env:"DOCKER_NETWORK_NAME" default:"distillery" parser:"nonempty"` + SessionSecret string `yaml:"session_secret" default:"" validate:"nonempty"` // interval to trigger distillery cron tasks in - CronInterval time.Duration `env:"CRON_INTERVAL" default:"10m" parser:"duration"` + CronInterval time.Duration `env:"cron_interval" default:"10m" validate:"duration"` // ConfigPath is the path this configuration was loaded from (if any) - ConfigPath string + ConfigPath string `yaml:"-"` } // CSRFSecret return the csrfSecret derived from the session secret diff --git a/internal/config/config_template b/internal/config/config_template deleted file mode 100644 index c48c2c5..0000000 --- a/internal/config/config_template +++ /dev/null @@ -1,73 +0,0 @@ -# Several docker-compose files are created to manage global services and the system itself. -# On top of this all real-system space will be created under this directory. -DEPLOY_ROOT=${DEPLOY_ROOT} - -# The name of the (global) docker network to run the distillery services in. -DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME} - -# Each created Drupal Instance corresponds to a single domain name. -# These domain names should either be a complete domain name or a sub-domain of a default domain. -# This setting configures the default domain-name to create subdomains of. -DEFAULT_DOMAIN=${DEFAULT_DOMAIN} - -# By default, the default domain redirects to the distillery repository. -# If you want to change this, set an alternate domain name here. -SELF_REDIRECT= - -# By default, only the 'self' domain above is caught. -# To catch additional domains, add them here (comma seperated) -SELF_EXTRA_DOMAINS= - -# You can override individual URLS in the homepage. -# Do this by adding URLs (without trailing '/'s) into a JSON file -SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE} - -# You can block specific prefixes within Triplestore from showing up in the resolver configuration file. -# Do this by adding one prefix per line in this file. -SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE} - -# The system can support setting up certificate(s) automatically. -# It can be enabled by setting an email for certbot certificates. -# This email address can be configured here. -CERTBOT_EMAIL= - -# The maximum age (in days) for backups to be kept. -# Backups older than this will be removed when a new backup is made. -MAX_BACKUP_AGE=30 - -# Each Drupal instance requires a corresponding system user, database users and databases. -# These are also set by the appropriate domain name. -# To differentiate them from other users of the system, these names can be prefixed. -# The prefix to use can be configured here. -# When changing these please consider that no system user may exist that has the same name as a mysql user. -# This is a MariaDB restriction. -MYSQL_USER_PREFIX=mysql-factory- -MYSQL_DATABASE_PREFIX=mysql-factory- -GRAPHDB_USER_PREFIX=graphdb-factory- -GRAPHDB_REPO_PREFIX=graphdb-factory- - -# In addition to the filesystem the WissKI distillery requires a 'bookkeeping' database. -# This is used to store several settings. -DISTILLERY_BOOKKEEPING_DATABASE=distillery - -# Various components use password-based-authentication. -# These passwords are generated automatically. -# This variable can be used to determine their length. -PASSWORD_LENGTH=64 - -# the port to use for the ssh server -SSH_PORT=2222 - -# The admin user and password of the GraphDB interface, to be used for queries -GRAPHDB_ADMIN_USER=${GRAPHDB_ADMIN_USER} -GRAPHDB_ADMIN_PASSWORD=${GRAPHDB_ADMIN_PASSWORD} - -# The admin user and password of the MySQL interface, to be used for provisioning -MYSQL_ADMIN_USER=${MYSQL_ADMIN_USER} -MYSQL_ADMIN_PASSWORD=${MYSQL_ADMIN_PASSWORD} - -# the interval to run cron in -CRON_INTERVAL=10m - -# The secret for sessions (for login etc) -SESSION_SECRET=${SESSION_SECRET} diff --git a/internal/config/config_template.yml b/internal/config/config_template.yml new file mode 100644 index 0000000..42f38f9 --- /dev/null +++ b/internal/config/config_template.yml @@ -0,0 +1,83 @@ +paths: + # Several files are required to manage the system + # On top of this all real-system space will be created under this directory. + root: ${DEPLOY_ROOT} + + # You can override individual URLS in the homepage. + # Do this by adding URLs (without trailing '/'s) into a JSON file. + # This is the path to that file. + overrides: ${SELF_OVERRIDES_FILE} + + # You can block specific prefixes within Triplestore from showing up in the global resolver. + # Do this by adding one prefix per line in this file. + # Lines starting with '#' and blank lines are ignored. + blocks: ${SELF_RESOLVER_BLOCK_FILE} + +http: + # Each created Drupal Instance corresponds to a single domain name. + # These domain names should either be a complete domain name or a sub-domain of a default domain. + # This setting configures the default domain-name to create subdomains of. + domain: ${DEFAULT_DOMAIN} + + # By default, only the 'domain' domain above is caught. + # To catch additional domains, add them here + domains: [] + + # The system can support setting up certificate(s) automatically. + # It can be enabled by setting an email for certbot certificates. + # This email address can be configured here. + certbot_email: "" + +# By default, the default domain redirects to the distillery repository. +# If you want to change this, set an alternate domain name here. +home: "" + +docker: + # The name of the (global) docker network to run the distillery services in. + network: ${DOCKER_NETWORK_NAME} + +# Configuration of the sql backend +sql: + # username and password for the sql administrative user. + # this user is automatically created. + username: ${MYSQL_ADMIN_USER} + password: ${MYSQL_ADMIN_PASSWORD} + + # prefixes for the data and users to be created and managed + # one of these is created per WissKI instance. + user_prefix: "mysql-factory-" + data_prefix: "mysql-factory-" + + # database used for internal configuration + database: "distillery" + +# configuration of the triplestore backend +triplestore: + # admin user and password of the graphdb interface + # this will be created automatically. + username: ${GRAPHDB_ADMIN_USER} + password: ${GRAPHDB_ADMIN_PASSWORD} + + # prefixes for the users and repositories to be created + user_prefix: "graphdb-factory-" + data_prefix: "graphdb-factory-" + +# The maximum agefor backups to be kept. +# Backups older than this will be removed when a new backup is made. +# The default here is 720hours (== 30 days) +age: '720h' + + +# Various components use password-based-authentication. +# These passwords are generated automatically. +# This variable can be used to determine their length. +password_length: 64 + +# the port to use for the ssh server +ssh_port: 2222 + +# The secret for sessions (for login etc) +session_secret: ${SESSION_SECRET} + +# the interval to run cron in +cron_interval: "10m" \ No newline at end of file diff --git a/internal/config/database.go b/internal/config/database.go new file mode 100644 index 0000000..df4a12b --- /dev/null +++ b/internal/config/database.go @@ -0,0 +1,23 @@ +package config + +type DatabaseConfig struct { + // Credentials for the admin user. + // Is automatically created if it does not exist. + AdminUsername string `yaml:"username" default:"admin" validate:"nonempty"` + AdminPassword string `yaml:"password" validate:"nonempty"` + + // Prefix for new users and data setss + UserPrefix string `yaml:"user_prefix" default:"wisski-distillery-" validate:"slug"` + DataPrefix string `yaml:"fragment_prefix" default:"wisski-distillery-" validate:"slug"` +} + +type SQLConfig struct { + DatabaseConfig `yaml:",inline" recurse:"true"` + + // Database to use to store distillery datastructures + Database string `yaml:"database" default:"distillery" validate:"slug"` +} + +type TSConfig struct { + DatabaseConfig `yaml:",inline" recurse:"true"` +} diff --git a/internal/config/docker.go b/internal/config/docker.go new file mode 100644 index 0000000..bb8a699 --- /dev/null +++ b/internal/config/docker.go @@ -0,0 +1,6 @@ +package config + +type DockerConfig struct { + // name of docker network to use + Network string `yaml:"network" default:"distillery" validate:"nonempty"` +} diff --git a/internal/config/executable.go b/internal/config/executable.go deleted file mode 100644 index 0042a80..0000000 --- a/internal/config/executable.go +++ /dev/null @@ -1,33 +0,0 @@ -package config - -import ( - "path/filepath" - - "github.com/FAU-CDI/wisski-distillery/internal/bootstrap" - "github.com/FAU-CDI/wisski-distillery/pkg/environment" - "github.com/FAU-CDI/wisski-distillery/pkg/fsx" -) - -// ExecutablePath returns the path to the executable of this distillery. -func (cfg Config) ExecutablePath() string { - return filepath.Join(cfg.DeployRoot, bootstrap.Executable) -} - -// UsingDistilleryExecutable checks if the current process is using the distillery executable -func (cfg Config) UsingDistilleryExecutable(env environment.Environment) bool { - exe, err := env.Executable() - if err != nil { - return false - } - return fsx.SameFile(env, exe, cfg.ExecutablePath()) -} - -// CurrentExecutable returns the path to the current executable being used. -// When it does not exist, falls back to the default executable. -func (cfg Config) CurrentExecutable(env environment.Environment) string { - exe, err := env.Executable() - if err != nil || !fsx.IsFile(env, exe) { - return cfg.ExecutablePath() - } - return exe -} diff --git a/internal/config/home.go b/internal/config/home.go new file mode 100644 index 0000000..58e1f79 --- /dev/null +++ b/internal/config/home.go @@ -0,0 +1,10 @@ +package config + +import "github.com/FAU-CDI/wisski-distillery/internal/config/validators" + +// ThemeConfig determines theming options +type ThemeConfig struct { + // By default, the default domain redirects to the distillery repository. + // If you want to change this, set an alternate domain name here. + SelfRedirect *validators.URL `yaml:"home" default:"https://github.com/FAU-CDI/wisski-distillery" validate:"https"` +} diff --git a/internal/config/domains.go b/internal/config/http.go similarity index 52% rename from internal/config/domains.go rename to internal/config/http.go index 42601a5..dca71ee 100644 --- a/internal/config/domains.go +++ b/internal/config/http.go @@ -7,24 +7,30 @@ import ( "github.com/tkw1536/goprogram/lib/collection" ) -// This file contains domain related derived configuration values. +type HTTPConfig struct { + // Each created Drupal Instance corresponds to a single domain name. + // These domain names should either be a complete domain name or a sub-domain of a default domain. + // This setting configures the default domain-name to create subdomains of. + PrimaryDomain string `yaml:"domain" default:"localhost.kwarc.info" validate:"domain"` -// HTTPSEnabled returns if the distillery has HTTPS enabled, and false otherwise. -func (cfg Config) HTTPSEnabled() bool { - return cfg.CertbotEmail != "" + // By default, only the 'self' domain above is caught. + // To catch additional domains, add them here (comma seperated) + ExtraDomains []string `yaml:"domains" validate:"domains"` + + // The system can support setting up certificate(s) automatically. + // It can be enabled by setting an email for certbot certificates. + // This email address can be configured here. + CertbotEmail string `yaml:"certbot_email" validate:"email"` } -// HostRequirement returns a traefik rule for the given names -func (Config) HostRule(names ...string) string { - quoted := collection.MapSlice(names, func(name string) string { - return "`" + name + "`" - }) - return fmt.Sprintf("Host(%s)", strings.Join(quoted, ",")) +// HTTPSEnabled returns if the distillery has HTTPS enabled, and false otherwise. +func (hcfg HTTPConfig) HTTPSEnabled() bool { + return hcfg.CertbotEmail != "" } // HTTPSEnabledEnv returns "true" if https is enabled, and "false" otherwise. -func (cfg Config) HTTPSEnabledEnv() string { - if cfg.HTTPSEnabled() { +func (hcfg HTTPConfig) HTTPSEnabledEnv() string { + if hcfg.HTTPSEnabled() { return "true" } return "false" @@ -32,24 +38,18 @@ func (cfg Config) HTTPSEnabledEnv() string { // HostFromSlug returns the hostname belonging to a given slug. // When the slug is empty, returns the default (top-level) domain. -func (cfg Config) HostFromSlug(slug string) string { +func (cfg HTTPConfig) HostFromSlug(slug string) string { if slug == "" { - return cfg.DefaultDomain + return cfg.PrimaryDomain } - return fmt.Sprintf("%s.%s", slug, cfg.DefaultDomain) -} - -// DefaultHostRule returns the default traefik hostname rule for this distillery. -// This consists of the [DefaultDomain] as well as [ExtraDomains]. -func (cfg Config) DefaultHostRule() string { - return cfg.HostRule(append([]string{cfg.DefaultDomain}, cfg.SelfExtraDomains...)...) + return fmt.Sprintf("%s.%s", slug, cfg.PrimaryDomain) } // SlugFromHost returns the slug belonging to the appropriate host.' // // When host is a top-level domain, returns "", true. // When no slug is found, returns "", false. -func (cfg Config) SlugFromHost(host string) (slug string, ok bool) { +func (cfg HTTPConfig) SlugFromHost(host string) (slug string, ok bool) { // extract an ':port' that happens to be in the host. domain, _, _ := strings.Cut(host, ":") domain = TrimSuffixFold(domain, ".wisski") // remove optional ".wisski" ending that is used inside docker @@ -57,7 +57,7 @@ func (cfg Config) SlugFromHost(host string) (slug string, ok bool) { domainL := strings.ToLower(domain) // check all the possible domain endings - for _, suffix := range append([]string{cfg.DefaultDomain}, cfg.SelfExtraDomains...) { + for _, suffix := range append([]string{cfg.PrimaryDomain}, cfg.ExtraDomains...) { suffixL := strings.ToLower(suffix) if domainL == suffixL { return "", true @@ -77,3 +77,18 @@ func TrimSuffixFold(s string, suffix string) string { } return s } + +// HostRule returns a traefik rule for the given names +// TODO: Move this over! +func HostRule(names ...string) string { + quoted := collection.MapSlice(names, func(name string) string { + return "`" + name + "`" + }) + return fmt.Sprintf("Host(%s)", strings.Join(quoted, ",")) +} + +// DefaultHostRule returns the default traefik hostname rule for this distillery. +// This consists of the [DefaultDomain] as well as [ExtraDomains]. +func (cfg HTTPConfig) DefaultHostRule() string { + return HostRule(append([]string{cfg.PrimaryDomain}, cfg.ExtraDomains...)...) +} diff --git a/pkg/envreader/envreader.go b/internal/config/legacy/envreader/envreader.go similarity index 89% rename from pkg/envreader/envreader.go rename to internal/config/legacy/envreader/envreader.go index f75af34..ca7e69b 100644 --- a/pkg/envreader/envreader.go +++ b/internal/config/legacy/envreader/envreader.go @@ -1,4 +1,5 @@ -// Package envreader +// Package envreader provides Scanner. +// It is deprecated and will be removed in a future release. package envreader import ( @@ -14,7 +15,9 @@ import ( // Reads may be internally buffered. // // An environment variable is of the form: -// KEY=VALUE +// +// KEY=VALUE +// // on a separate line. // Keys and values are case-sensitive and may contain anything except for newline characters. // Spaces around key and value are trimmed using [strings.TrimSpace]. @@ -26,14 +29,14 @@ import ( // // A typical use-case of a scanner is as follows: // -// scanner := NewScanner(r) -// for scanner.Scan() { -// // process any data .... -// fmt.Println(scanner.Data()) -// } -// if err := scanner.Err(); err != nil { -// // handle errors -// } +// scanner := NewScanner(r) +// for scanner.Scan() { +// // process any data .... +// fmt.Println(scanner.Data()) +// } +// if err := scanner.Err(); err != nil { +// // handle errors +// } // // For the common use case of reading a set of distinct keys from a file see [ReadAll]. type Scanner struct { @@ -100,6 +103,7 @@ func (scanner Scanner) Err() error { // ReadAll creates a new [Scanner], and then reads all key/value pairs from r. // If a key occurs more than once, only the last value is set in the returned map. func ReadAll(r io.Reader) (values map[string]string, err error) { + // TODO: This is no longer used scanner := NewScanner(r) // read and store all values diff --git a/pkg/envreader/envreader_test.go b/internal/config/legacy/envreader/envreader_test.go similarity index 100% rename from pkg/envreader/envreader_test.go rename to internal/config/legacy/envreader/envreader_test.go diff --git a/internal/config/legacy/legacy.go b/internal/config/legacy/legacy.go new file mode 100644 index 0000000..07b3242 --- /dev/null +++ b/internal/config/legacy/legacy.go @@ -0,0 +1,147 @@ +// Package legacy provides support for reading legacy configuration. +// It is deprecated and will be removed in a future release. +package legacy + +import ( + "io" + "net/url" + "reflect" + "time" + + "github.com/FAU-CDI/wisski-distillery/internal/config" + "github.com/FAU-CDI/wisski-distillery/internal/config/legacy/envreader" + "github.com/FAU-CDI/wisski-distillery/internal/config/legacy/stringparser" + "github.com/FAU-CDI/wisski-distillery/internal/config/validators" + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/pkg/errors" +) + +// Migrate parses a configuration from an old configuration. +func Migrate(config *config.Config, env environment.Environment, src io.Reader) error { + var legacy Legacy + if err := legacy.Unmarshal(env, src); err != nil { + return nil + } + return legacy.Migrate(config) +} + +// Legacy represents a legacy configuration file. +// +// NOTE(twiesing): This will be deprecated soon. +type Legacy struct { + DeployRoot string `env:"DEPLOY_ROOT" default:"/var/www/deploy" parser:"abspath"` + + DefaultDomain string `env:"DEFAULT_DOMAIN" default:"localhost.kwarc.info" parser:"domain"` + + SelfRedirect *url.URL `env:"SELF_REDIRECT" default:"https://github.com/FAU-CDI/wisski-distillery" parser:"https_url"` + + SelfExtraDomains []string `env:"SELF_EXTRA_DOMAINS" default:"" parser:"domains"` + + SelfOverridesFile string `env:"SELF_OVERRIDES_FILE" default:"" parser:"file"` + + SelfResolverBlockFile string `env:"SELF_RESOLVER_BLOCK_FILE" default:"" parser:"file"` + + CertbotEmail string `env:"CERTBOT_EMAIL" default:"" parser:"email"` + + MaxBackupAge int `env:"MAX_BACKUP_AGE" default:"" parser:"number"` + + MysqlUserPrefix string `env:"MYSQL_USER_PREFIX" default:"mysql-factory-" parser:"slug"` + MysqlDatabasePrefix string `env:"MYSQL_DATABASE_PREFIX" default:"mysql-factory-" parser:"slug"` + GraphDBUserPrefix string `env:"GRAPHDB_USER_PREFIX" default:"mysql-factory-" parser:"slug"` + GraphDBRepoPrefix string `env:"GRAPHDB_REPO_PREFIX" default:"mysql-factory-" parser:"slug"` + + DistilleryDatabase string `env:"DISTILLERY_BOOKKEEPING_DATABASE" default:"distillery" parser:"slug"` + + PasswordLength int `env:"PASSWORD_LENGTH" default:"64" parser:"number"` + + PublicSSHPort uint16 `env:"SSH_PORT" default:"2222" parser:"port"` + + TriplestoreAdminUser string `env:"GRAPHDB_ADMIN_USER" default:"admin" parser:"nonempty"` + TriplestoreAdminPassword string `env:"GRAPHDB_ADMIN_PASSWORD" default:"" parser:"nonempty"` + + MysqlAdminUser string `env:"MYSQL_ADMIN_USER" default:"admin" parser:"nonempty"` + MysqlAdminPassword string `env:"MYSQL_ADMIN_PASSWORD" default:"" parser:"nonempty"` + + SessionSecret string `env:"SESSION_SECRET" default:"" parser:"nonempty"` + + // name of docker network to use + DockerNetworkName string `env:"DOCKER_NETWORK_NAME" default:"distillery" parser:"nonempty"` + CronInterval time.Duration `env:"CRON_INTERVAL" default:"10m" parser:"duration"` +} + +// Migrate migrates this LegacyConfig into a new configuration. +func (legacy *Legacy) Migrate(cfg *config.Config) error { + cfg.Paths.Root = legacy.DeployRoot + cfg.HTTP.PrimaryDomain = legacy.DefaultDomain + cfg.Theme.SelfRedirect = (*validators.URL)(legacy.SelfRedirect) + cfg.HTTP.ExtraDomains = legacy.SelfExtraDomains + cfg.Paths.OverridesJSON = legacy.SelfOverridesFile + cfg.Paths.ResolverBlocks = legacy.SelfResolverBlockFile + cfg.HTTP.CertbotEmail = legacy.CertbotEmail + cfg.MaxBackupAge = time.Duration(legacy.MaxBackupAge) * 24 * time.Hour + cfg.SQL.UserPrefix = legacy.MysqlUserPrefix + cfg.SQL.DataPrefix = legacy.MysqlDatabasePrefix + cfg.TS.UserPrefix = legacy.GraphDBUserPrefix + cfg.TS.DataPrefix = legacy.GraphDBRepoPrefix + cfg.SQL.Database = legacy.DistilleryDatabase + cfg.PasswordLength = legacy.PasswordLength + cfg.PublicSSHPort = legacy.PublicSSHPort + cfg.TS.AdminUsername = legacy.TriplestoreAdminUser + cfg.TS.AdminPassword = legacy.TriplestoreAdminPassword + cfg.SQL.AdminUsername = legacy.MysqlAdminUser + cfg.SQL.AdminPassword = legacy.MysqlAdminPassword + cfg.SessionSecret = legacy.SessionSecret + cfg.Docker.Network = legacy.DockerNetworkName + cfg.CronInterval = legacy.CronInterval + return nil +} + +// Unmarshal opens a legacy configuration file. +// +// Data is read using the [envreader.ReadAll] method, see the appropriate documentation for the file format. +// +// The `env` and `parser` reflect tags of the [Config] struct determine the keys to read from, and the types to expect. +// When a key is missing, it is set to the default value. +// +// See also [stringparser.Parse]. +func (config *Legacy) Unmarshal(env environment.Environment, src io.Reader) error { + // read all the values! + values, err := envreader.ReadAll(src) + if err != nil { + return err + } + + vConfig := reflect.ValueOf(config).Elem() + tConfig := vConfig.Type() + + // iterate over the types + numValues := tConfig.NumField() + for i := 0; i < numValues; i++ { + tField := tConfig.Field(i) + vField := vConfig.FieldByName(tField.Name) + + tEnv := tField.Tag.Get("env") + tDefault := tField.Tag.Get("default") + tParser := tField.Tag.Get("parser") + + // skip it if it isn't loaded! + if tEnv == "" { + continue + } + + // read the value with a default + value, ok := values[tEnv] + if !ok || value == "" { + if tDefault != "" { + value = tDefault + } + } + + // parse the value! + if err := stringparser.Parse(env, tParser, value, vField); err != nil { + return errors.Errorf("Legacy.Unmarshal: Setting %q, Parser %q: %s", tEnv, tParser, err) + } + } + + return nil +} diff --git a/pkg/stringparser/parse.go b/internal/config/legacy/stringparser/parse.go similarity index 100% rename from pkg/stringparser/parse.go rename to internal/config/legacy/stringparser/parse.go diff --git a/pkg/stringparser/stringparser.go b/internal/config/legacy/stringparser/stringparser.go similarity index 97% rename from pkg/stringparser/stringparser.go rename to internal/config/legacy/stringparser/stringparser.go index 07b4f11..5d73a3d 100644 --- a/pkg/stringparser/stringparser.go +++ b/internal/config/legacy/stringparser/stringparser.go @@ -1,4 +1,5 @@ -// Package stringparser provides Parser +// Package stringparser provides Parser. +// It is deprecated and will be removed in a future release. package stringparser import ( diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..5fd94a6 --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,52 @@ +package config + +import ( + "path/filepath" + + "github.com/FAU-CDI/wisski-distillery/internal/bootstrap" + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/FAU-CDI/wisski-distillery/pkg/fsx" +) + +type PathsConfig struct { + // Several docker-compose files are created to manage global services and the system itself. + // On top of this all real-system space will be created under this directory. + Root string `yaml:"root" default:"/var/www/deploy" validate:"directory"` + + // You can override individual URLS in the homepage + // Do this by adding URLs (without trailing '/'s) into a JSON file + OverridesJSON string `yaml:"overrides" validate:"file"` + + // You can block specific prefixes from being picked up by the resolver. + // Do this by adding one prefix per file. + ResolverBlocks string `yaml:"blocks" validate:"file"` +} + +// RuntimeDir returns the path to the runtime directory +func (pcfg PathsConfig) RuntimeDir() string { + return filepath.Join(pcfg.Root, "runtime") +} + +// ExecutablePath returns the path to the executable of this distillery. +func (pcfg PathsConfig) ExecutablePath() string { + return filepath.Join(pcfg.Root, bootstrap.Executable) +} + +// UsingDistilleryExecutable checks if the current process is using the distillery executable +func (pcfg PathsConfig) UsingDistilleryExecutable(env environment.Environment) bool { + exe, err := env.Executable() + if err != nil { + return false + } + return fsx.SameFile(env, exe, pcfg.ExecutablePath()) +} + +// CurrentExecutable returns the path to the current executable being used. +// When it does not exist, falls back to the default executable. +func (pcfg PathsConfig) CurrentExecutable(env environment.Environment) string { + exe, err := env.Executable() + if err != nil || !fsx.IsFile(env, exe) { + return pcfg.ExecutablePath() + } + return exe +} diff --git a/internal/config/read.go b/internal/config/read.go index 649f31c..f82352f 100644 --- a/internal/config/read.go +++ b/internal/config/read.go @@ -2,60 +2,31 @@ package config import ( "io" - "reflect" + "github.com/FAU-CDI/wisski-distillery/internal/config/validators" "github.com/FAU-CDI/wisski-distillery/pkg/environment" - "github.com/FAU-CDI/wisski-distillery/pkg/envreader" - "github.com/FAU-CDI/wisski-distillery/pkg/stringparser" - "github.com/pkg/errors" + "github.com/FAU-CDI/wisski-distillery/pkg/validator" + "gopkg.in/yaml.v3" ) -// Unmarshal updates this configuration from the provided [io.Reader]. -// -// Data is read using the [envreader.ReadAll] method, see the appropriate documentation for the file format. -// -// The `env` and `parser` reflect tags of the [Config] struct determine the keys to read from, and the types to expect. -// When a key is missing, it is set to the default value. -// -// See also [stringparser.Parse]. +// Unmarshal reads configuration from the provided io.Reader, and then validates it. +// Configuration is read in yaml format. func (config *Config) Unmarshal(env environment.Environment, src io.Reader) error { - // read all the values! - values, err := envreader.ReadAll(src) - if err != nil { - return err - } - - vConfig := reflect.ValueOf(config).Elem() - tConfig := vConfig.Type() - - // iterate over the types - numValues := tConfig.NumField() - for i := 0; i < numValues; i++ { - tField := tConfig.Field(i) - vField := vConfig.FieldByName(tField.Name) - - tEnv := tField.Tag.Get("env") - tDefault := tField.Tag.Get("default") - tParser := tField.Tag.Get("parser") - - // skip it if it isn't loaded! - if tEnv == "" { - continue - } - - // read the value with a default - value, ok := values[tEnv] - if !ok || value == "" { - if tDefault != "" { - value = tDefault - } - } - - // parse the value! - if err := stringparser.Parse(env, tParser, value, vField); err != nil { - return errors.Errorf("Config.Unmarshal: Setting %q, Parser %q: %s", tEnv, tParser, err) + // read yaml! + { + decoder := yaml.NewDecoder(src) + decoder.KnownFields(true) + if err := decoder.Decode(config); err != nil { + return err } } - return nil + // do the validator + return validator.Validate(config, validators.New(env)) +} + +func (config *Config) Marshal(dest io.Writer) error { + encoder := yaml.NewEncoder(dest) + encoder.SetIndent(4) + return encoder.Encode(config) } diff --git a/internal/config/runtime.go b/internal/config/runtime.go deleted file mode 100644 index 48db811..0000000 --- a/internal/config/runtime.go +++ /dev/null @@ -1,10 +0,0 @@ -package config - -import ( - "path/filepath" -) - -// RuntimeDir returns the path to the runtime directory -func (cfg Config) RuntimeDir() string { - return filepath.Join(cfg.DeployRoot, "runtime") -} diff --git a/internal/config/template.go b/internal/config/template.go index ba71554..088acc5 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -11,6 +11,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/pkg/hostname" "github.com/FAU-CDI/wisski-distillery/pkg/password" "github.com/FAU-CDI/wisski-distillery/pkg/unpack" + "gopkg.in/yaml.v3" _ "embed" ) @@ -87,7 +88,7 @@ func (tpl *Template) SetDefaults(env environment.Environment) (err error) { return nil } -//go:embed config_template +//go:embed config_template.yml var templateBytes []byte // MarshalTo marshals this template into dst @@ -100,10 +101,15 @@ func (tpl Template) MarshalTo(dst io.Writer) error { field := tplType.Field(i) key := field.Tag.Get("env") - value := tplVal.FieldByName(field.Name).String() + value := tplVal.FieldByName(field.Name).Interface() - context[key] = value + bytes, err := yaml.Marshal(value) + if err != nil { + return err + } + context[key] = string(bytes) } + // TODO: CONFIG: Update template writing return unpack.WriteTemplate(dst, context, bytes.NewReader(templateBytes)) } diff --git a/internal/config/validators/collection.go b/internal/config/validators/collection.go new file mode 100644 index 0000000..a544e5e --- /dev/null +++ b/internal/config/validators/collection.go @@ -0,0 +1,32 @@ +package validators + +import ( + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/FAU-CDI/wisski-distillery/pkg/validator" +) + +// New creates a new set of standard validators for the configuration +func New(env environment.Environment) validator.Collection { + coll := make(validator.Collection) + + validator.Add(coll, "nonempty", ValidateNonempty) + + validator.Add(coll, "directory", func(value *string, dflt string) error { + return ValidateDirectory(env, value, dflt) + }) + validator.Add(coll, "file", func(value *string, dflt string) error { + return ValidateFile(env, value, dflt) + }) + + validator.Add(coll, "domain", ValidateDomain) + validator.AddSlice(coll, "domains", ",", ValidateDomain) + validator.Add(coll, "https", ValidateHTTPSURL) + validator.Add(coll, "slug", ValidateSlug) + validator.Add(coll, "email", ValidateEmail) + + validator.Add(coll, "positive", ValidatePositive) + validator.Add(coll, "port", ValidatePort) + + validator.Add(coll, "duration", ValidateDuration) + return coll +} diff --git a/internal/config/validators/domain.go b/internal/config/validators/domain.go new file mode 100644 index 0000000..8fe6aff --- /dev/null +++ b/internal/config/validators/domain.go @@ -0,0 +1,21 @@ +package validators + +import ( + "regexp" + "strings" + + "github.com/pkg/errors" +) + +var regexpDomain = regexp.MustCompile(`^([a-zA-Z0-9][-a-zA-Z0-9]*\.)*[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer! + +func ValidateDomain(domain *string, dflt string) error { + if *domain == "" { + *domain = dflt + } + if !regexpDomain.MatchString(*domain) { + return errors.Errorf("%q is not a valid domain", *domain) + } + *domain = strings.ToLower(*domain) + return nil +} diff --git a/internal/config/validators/duration.go b/internal/config/validators/duration.go new file mode 100644 index 0000000..4922fcc --- /dev/null +++ b/internal/config/validators/duration.go @@ -0,0 +1,14 @@ +package validators + +import "time" + +func ValidateDuration(d *time.Duration, dflt string) error { + if *d == 0 { + var err error + *d, err = time.ParseDuration(dflt) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/config/validators/email.go b/internal/config/validators/email.go new file mode 100644 index 0000000..1c93efa --- /dev/null +++ b/internal/config/validators/email.go @@ -0,0 +1,24 @@ +package validators + +import ( + "regexp" + + "github.com/pkg/errors" +) + +var regexpEmail = regexp.MustCompile(`^([-a-zA-Z0-9]+)\@([a-zA-Z0-9][-a-zA-Z0-9]*\.)*[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer! + +// ValidateEmail checks that s represents an email, and then returns it as is. +func ValidateEmail(email *string, dflt string) error { + if *email == "" { + *email = dflt + } + if *email == "" { // no email provided => ok + return nil + } + + if !regexpEmail.MatchString(*email) { + return errors.Errorf("%q is not a valid email", *email) + } + return nil +} diff --git a/internal/config/validators/files.go b/internal/config/validators/files.go new file mode 100644 index 0000000..e189659 --- /dev/null +++ b/internal/config/validators/files.go @@ -0,0 +1,27 @@ +package validators + +import ( + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/FAU-CDI/wisski-distillery/pkg/fsx" + "github.com/pkg/errors" +) + +func ValidateFile(env environment.Environment, path *string, dflt string) error { + if *path == "" { + *path = dflt + } + if !fsx.IsFile(env, *path) { + return errors.Errorf("%q does not exist or is not a file", *path) + } + return nil +} + +func ValidateDirectory(env environment.Environment, path *string, dflt string) error { + if *path == "" { + *path = dflt + } + if !fsx.IsDirectory(env, *path) { + return errors.Errorf("%q does not exist or is not a directory", *path) + } + return nil +} diff --git a/internal/config/validators/int.go b/internal/config/validators/int.go new file mode 100644 index 0000000..36998f7 --- /dev/null +++ b/internal/config/validators/int.go @@ -0,0 +1,32 @@ +package validators + +import ( + "strconv" + + "github.com/pkg/errors" +) + +func ValidatePositive(value *int, dflt string) (err error) { + if *value == 0 && dflt != "" { + v, err := strconv.ParseInt(dflt, 10, 64) + if err != nil { + return err + } + *value = int(v) + } + if *value <= 0 { + return errors.Errorf("%d is not a positive value", *value) + } + return nil +} + +func ValidatePort(value *uint16, dflt string) (err error) { + if *value == 0 && dflt != "" { + v, err := strconv.ParseUint(dflt, 10, 16) + if err != nil { + return err + } + *value = uint16(v) + } + return nil +} diff --git a/internal/config/validators/slug.go b/internal/config/validators/slug.go new file mode 100644 index 0000000..57da756 --- /dev/null +++ b/internal/config/validators/slug.go @@ -0,0 +1,24 @@ +package validators + +import ( + "regexp" + "strings" + + "github.com/pkg/errors" +) + +var regexpSlug = regexp.MustCompile(`^[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer! + +var ErrInvalidSlug = errors.New("invalid slug") + +// ValidateSlug validates a slug and normalizes it. +func ValidateSlug(s *string, dflt string) error { + if *s == "" { + *s = dflt + } + *s = strings.ToLower(*s) + if !regexpSlug.MatchString(*s) { + return ErrInvalidSlug + } + return nil +} diff --git a/internal/config/validators/string.go b/internal/config/validators/string.go new file mode 100644 index 0000000..b26b813 --- /dev/null +++ b/internal/config/validators/string.go @@ -0,0 +1,16 @@ +package validators + +import "github.com/pkg/errors" + +var errEmpty = errors.New("value is empty") + +func ValidateNonempty(value *string, dflt string) error { + if *value == "" { + *value = dflt + } + + if *value == "" { + return errEmpty + } + return nil +} diff --git a/internal/config/validators/url.go b/internal/config/validators/url.go new file mode 100644 index 0000000..c8478c3 --- /dev/null +++ b/internal/config/validators/url.go @@ -0,0 +1,46 @@ +package validators + +import ( + "net/url" + + "github.com/pkg/errors" +) + +// URL represents a url.URL that is marshaled as a string representing the url. +type URL url.URL + +func (u *URL) MarshalText() (text []byte, err error) { + return []byte(u.String()), nil +} + +func (u *URL) String() string { + if u == nil { + return "" + } + return (*url.URL)(u).String() +} + +func (u *URL) UnmarshalText(text []byte) error { + if len(text) == 0 { + return nil + } + pu, err := url.Parse(string(text)) + if err != nil { + return err + } + *u = URL(*pu) + return nil +} + +func ValidateHTTPSURL(url **URL, dflt string) error { + if (*url).String() == "" { + *url = new(URL) + if err := (*url).UnmarshalText([]byte(dflt)); err != nil { + return err + } + } + if (*url).Scheme != "https" { + return errors.Errorf("%q is not a valid https URL (%q)", *url, (*url).Scheme) + } + return nil +} diff --git a/internal/dis/component/auth/next/next.go b/internal/dis/component/auth/next/next.go index 9b00040..7415c26 100644 --- a/internal/dis/component/auth/next/next.go +++ b/internal/dis/component/auth/next/next.go @@ -55,7 +55,7 @@ func (next *Next) getInstance(r *http.Request) (wisski *wisski.WissKI, path stri } // find the slug - slug, ok := next.Config.SlugFromHost(url.Host) + slug, ok := next.Config.HTTP.SlugFromHost(url.Host) if slug == "" || !ok { return nil, "", httpx.ErrBadRequest } diff --git a/internal/dis/component/auth/panel/ssh.go b/internal/dis/component/auth/panel/ssh.go index 7d95d6b..846daa7 100644 --- a/internal/dis/component/auth/panel/ssh.go +++ b/internal/dis/component/auth/panel/ssh.go @@ -58,7 +58,7 @@ func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler { return sc, err } - sc.Domain = panel.Config.DefaultDomain + sc.Domain = panel.Config.HTTP.PrimaryDomain sc.Port = panel.Config.PublicSSHPort // pick the first domain that the user has access to as an example @@ -68,7 +68,7 @@ func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler { } else { sc.Slug = "example" } - sc.Hostname = panel.Config.HostFromSlug(sc.Slug) + sc.Hostname = panel.Config.HTTP.HostFromSlug(sc.Slug) sc.Keys, err = panel.Dependencies.Keys.Keys(r.Context(), user.User.User) if err != nil { diff --git a/internal/dis/component/exporter/exporter.go b/internal/dis/component/exporter/exporter.go index e80830e..2f60cd1 100644 --- a/internal/dis/component/exporter/exporter.go +++ b/internal/dis/component/exporter/exporter.go @@ -30,7 +30,7 @@ type Exporter struct { // Path returns the path that contains all snapshot related data. func (dis *Exporter) Path() string { - return filepath.Join(dis.Config.DeployRoot, "snapshots") + return filepath.Join(dis.Config.Paths.Root, "snapshots") } // StagingPath returns the path to the directory containing a temporary staging area for snapshots. diff --git a/internal/dis/component/exporter/extras_config.go b/internal/dis/component/exporter/extras_config.go index f91cdc6..b6a62fc 100644 --- a/internal/dis/component/exporter/extras_config.go +++ b/internal/dis/component/exporter/extras_config.go @@ -38,8 +38,8 @@ func (control *Config) Backup(scontext component.StagingContext) error { func (control *Config) backupFiles() []string { return []string{ control.Config.ConfigPath, - control.Config.ExecutablePath(), - control.Config.SelfOverridesFile, - control.Config.SelfResolverBlockFile, + control.Config.Paths.ExecutablePath(), + control.Config.Paths.OverridesJSON, + control.Config.Paths.ResolverBlocks, } } diff --git a/internal/dis/component/exporter/prune.go b/internal/dis/component/exporter/prune.go index cb0cf83..5f374f5 100644 --- a/internal/dis/component/exporter/prune.go +++ b/internal/dis/component/exporter/prune.go @@ -12,7 +12,7 @@ import ( // ShouldPrune determines if a file with the provided modification time should be // removed from the export log. func (exporter *Exporter) ShouldPrune(modtime time.Time) bool { - return time.Since(modtime) > time.Duration(exporter.Config.MaxBackupAge)*24*time.Hour + return time.Since(modtime) > exporter.Config.MaxBackupAge } // Prune prunes all old exports diff --git a/internal/dis/component/instances/create.go b/internal/dis/component/instances/create.go index 5600e5a..b1bfab6 100644 --- a/internal/dis/component/instances/create.go +++ b/internal/dis/component/instances/create.go @@ -5,8 +5,8 @@ import ( "path/filepath" "strings" + "github.com/FAU-CDI/wisski-distillery/internal/config/validators" "github.com/FAU-CDI/wisski-distillery/internal/wisski" - "github.com/FAU-CDI/wisski-distillery/pkg/stringparser" ) var ( @@ -37,8 +37,8 @@ func (instances *Instances) Create(slug string) (wissKI *wisski.WissKI, err erro // sql - wissKI.Liquid.Instance.SqlDatabase = instances.Config.MysqlDatabasePrefix + slug - wissKI.Liquid.Instance.SqlUsername = instances.Config.MysqlUserPrefix + slug + wissKI.Liquid.Instance.SqlDatabase = instances.Config.SQL.DataPrefix + slug + wissKI.Liquid.Instance.SqlUsername = instances.Config.SQL.UserPrefix + slug wissKI.Liquid.Instance.SqlPassword, err = instances.Config.NewPassword() if err != nil { @@ -47,8 +47,8 @@ func (instances *Instances) Create(slug string) (wissKI *wisski.WissKI, err erro // triplestore - wissKI.Liquid.Instance.GraphDBRepository = instances.Config.GraphDBRepoPrefix + slug - wissKI.Liquid.Instance.GraphDBUsername = instances.Config.GraphDBUserPrefix + slug + wissKI.Liquid.Instance.GraphDBRepository = instances.Config.TS.DataPrefix + slug + wissKI.Liquid.Instance.GraphDBUsername = instances.Config.TS.UserPrefix + slug wissKI.Liquid.Instance.GraphDBPassword, err = instances.Config.NewPassword() if err != nil { @@ -73,7 +73,7 @@ var restrictedSlugs = []string{"www", "admin"} // IsValidSlug checks if slug represents a valid slug for an instance. func (instances *Instances) IsValidSlug(slug string) (string, error) { // check that it is a slug - slug, err := stringparser.ParseSlug(instances.Environment, slug) + err := validators.ValidateSlug(&slug, "") if err != nil { return "", errInvalidSlug } @@ -84,5 +84,5 @@ func (instances *Instances) IsValidSlug(slug string) (string, error) { } // return the slug - return strings.ToLower(slug), nil + return slug, nil } diff --git a/internal/dis/component/instances/instances.go b/internal/dis/component/instances/instances.go index 3c1053a..f8ceffb 100644 --- a/internal/dis/component/instances/instances.go +++ b/internal/dis/component/instances/instances.go @@ -28,7 +28,7 @@ type Instances struct { } func (instances *Instances) Path() string { - return filepath.Join(instances.Still.Config.DeployRoot, "instances") + return filepath.Join(instances.Still.Config.Paths.Root, "instances") } // ErrWissKINotFound is returned when a WissKI is not found diff --git a/internal/dis/component/instances/runtime.go b/internal/dis/component/instances/runtime.go index 1812c2d..3def554 100644 --- a/internal/dis/component/instances/runtime.go +++ b/internal/dis/component/instances/runtime.go @@ -22,7 +22,7 @@ var runtimeResources embed.FS // Update installs or updates runtime components needed by this component. func (instances *Instances) Update(ctx context.Context, progress io.Writer) error { - err := unpack.InstallDir(instances.Still.Environment, instances.Config.RuntimeDir(), "runtime", runtimeResources, func(dst, src string) { + err := unpack.InstallDir(instances.Still.Environment, instances.Config.Paths.RuntimeDir(), "runtime", runtimeResources, func(dst, src string) { logging.ProgressF(progress, ctx, "[copy] %s\n", dst) }) if err != nil { diff --git a/internal/dis/component/resolver/resolver.go b/internal/dis/component/resolver/resolver.go index 08412ee..0062378 100644 --- a/internal/dis/component/resolver/resolver.go +++ b/internal/dis/component/resolver/resolver.go @@ -84,14 +84,14 @@ func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.H } // handle the default domain name! - domainName := resolver.Config.DefaultDomain + domainName := resolver.Config.HTTP.PrimaryDomain if domainName != "" { fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domainName))] = fmt.Sprintf("https://$1.%s", domainName) logger.Info().Str("name", domainName).Msg("registering default domain") } // handle the extra domains! - for _, domain := range resolver.Config.SelfExtraDomains { + for _, domain := range resolver.Config.HTTP.ExtraDomains { fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domain))] = fmt.Sprintf("https://$1.%s", domainName) logger.Info().Str("name", domainName).Msg("registering legacy domain") } diff --git a/internal/dis/component/server/admin/html/index.html b/internal/dis/component/server/admin/html/index.html index 2fe2a0b..e3cc4a4 100644 --- a/internal/dis/component/server/admin/html/index.html +++ b/internal/dis/component/server/admin/html/index.html @@ -119,7 +119,7 @@ root - {{.Config.DeployRoot}} + {{.Config.Paths.Root}} @@ -161,7 +161,7 @@ Docker Network Name - {{.Config.DockerNetworkName}} + {{.Config.Docker.Network}} diff --git a/internal/dis/component/server/home/home.go b/internal/dis/component/server/home/home.go index d9bf898..c7f775c 100644 --- a/internal/dis/component/server/home/home.go +++ b/internal/dis/component/server/home/home.go @@ -51,7 +51,7 @@ func (home *Home) HandleRoute(ctx context.Context, route string) (http.Handler, dflt.Fallback = home.publicHandler(ctx) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - slug, ok := home.Config.SlugFromHost(r.Host) + slug, ok := home.Config.HTTP.SlugFromHost(r.Host) switch { case !ok: http.NotFound(w, r) diff --git a/internal/dis/component/server/home/public.go b/internal/dis/component/server/home/public.go index cadf705..ba67206 100644 --- a/internal/dis/component/server/home/public.go +++ b/internal/dis/component/server/home/public.go @@ -68,7 +68,7 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler { // prepare about pc.aboutContext.Logo = logoHTML pc.aboutContext.Instances = home.homeInstances.Get(nil) - pc.aboutContext.SelfRedirect = home.Config.SelfRedirect.String() + pc.aboutContext.SelfRedirect = home.Config.Theme.SelfRedirect.String() // render the about template diff --git a/internal/dis/component/server/home/redirect.go b/internal/dis/component/server/home/redirect.go index e3ee615..e4913f2 100644 --- a/internal/dis/component/server/home/redirect.go +++ b/internal/dis/component/server/home/redirect.go @@ -18,7 +18,7 @@ func (home *Home) loadRedirect(ctx context.Context) (redirect Redirect, err erro redirect.Permanent = false // load the overrides file - overrides, err := home.Environment.Open(home.Config.SelfOverridesFile) + overrides, err := home.Environment.Open(home.Config.Paths.OverridesJSON) if err != nil { return redirect, err } diff --git a/internal/dis/component/server/server.go b/internal/dis/component/server/server.go index 6e8f332..075800b 100644 --- a/internal/dis/component/server/server.go +++ b/internal/dis/component/server/server.go @@ -39,7 +39,7 @@ func (server *Server) Server(ctx context.Context, progress io.Writer) (public ht var publicM, internalM mux.Mux[component.RouteContext] publicM.Context = func(r *http.Request) component.RouteContext { - slug, ok := server.Still.Config.SlugFromHost(r.Host) + slug, ok := server.Still.Config.HTTP.SlugFromHost(r.Host) return component.RouteContext{ DefaultDomain: slug == "" && ok, } @@ -112,7 +112,7 @@ func (server *Server) Server(ctx context.Context, progress io.Writer) (public ht // CSRF returns a CSRF handler for the given function func (server *Server) csrf() func(http.Handler) http.Handler { var opts []csrf.Option - if !server.Config.HTTPSEnabled() { + if !server.Config.HTTP.HTTPSEnabled() { opts = append(opts, csrf.Secure(false)) } opts = append(opts, csrf.SameSite(csrf.SameSiteStrictMode)) diff --git a/internal/dis/component/server/stack.go b/internal/dis/component/server/stack.go index aa54170..75b72c1 100644 --- a/internal/dis/component/server/stack.go +++ b/internal/dis/component/server/stack.go @@ -13,7 +13,7 @@ import ( ) func (control Server) Path() string { - return filepath.Join(control.Still.Config.DeployRoot, "core", "dis") + return filepath.Join(control.Still.Config.Paths.Root, "core", "dis") } //go:embed all:server server.env @@ -26,15 +26,15 @@ func (server *Server) Stack(env environment.Environment) component.StackWithReso EnvPath: "server.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": server.Config.DockerNetworkName, - "HOST_RULE": server.Config.DefaultHostRule(), - "HTTPS_ENABLED": server.Config.HTTPSEnabledEnv(), + "DOCKER_NETWORK_NAME": server.Config.Docker.Network, + "HOST_RULE": server.Config.HTTP.DefaultHostRule(), + "HTTPS_ENABLED": server.Config.HTTP.HTTPSEnabledEnv(), "CONFIG_PATH": server.Config.ConfigPath, - "DEPLOY_ROOT": server.Config.DeployRoot, + "DEPLOY_ROOT": server.Config.Paths.Root, - "SELF_OVERRIDES_FILE": server.Config.SelfOverridesFile, - "SELF_RESOLVER_BLOCK_FILE": server.Config.SelfResolverBlockFile, + "SELF_OVERRIDES_FILE": server.Config.Paths.OverridesJSON, + "SELF_RESOLVER_BLOCK_FILE": server.Config.Paths.ResolverBlocks, "CUSTOM_ASSETS_PATH": server.Dependencies.Templating.CustomAssetsPath(), }, @@ -50,6 +50,6 @@ func (server *Server) Trigger(ctx context.Context, env environment.Environment) func (server *Server) Context(parent component.InstallationContext) component.InstallationContext { return component.InstallationContext{ - bootstrap.Executable: server.Config.CurrentExecutable(server.Environment), // TODO: Does this make sense? + bootstrap.Executable: server.Config.Paths.CurrentExecutable(server.Environment), // TODO: Does this make sense? } } diff --git a/internal/dis/component/server/templating/assets.go b/internal/dis/component/server/templating/assets.go index a3bba81..59d786e 100644 --- a/internal/dis/component/server/templating/assets.go +++ b/internal/dis/component/server/templating/assets.go @@ -8,7 +8,7 @@ import ( // CustomAssetsPath is the path custom assets are stored at func (tpl *Templating) CustomAssetsPath() string { - return filepath.Join(tpl.Config.DeployRoot, "core", "assets") + return filepath.Join(tpl.Config.Paths.Root, "core", "assets") } func (tpl *Templating) CustomAssetPath(name string) string { diff --git a/internal/dis/component/solr/solr.go b/internal/dis/component/solr/solr.go index 4a670e1..6c16fef 100644 --- a/internal/dis/component/solr/solr.go +++ b/internal/dis/component/solr/solr.go @@ -22,7 +22,7 @@ var ( ) func (s *Solr) Path() string { - return filepath.Join(s.Still.Config.DeployRoot, "core", "solr") + return filepath.Join(s.Still.Config.Paths.Root, "core", "solr") } func (*Solr) Context(parent component.InstallationContext) component.InstallationContext { @@ -40,7 +40,7 @@ func (solr *Solr) Stack(env environment.Environment) component.StackWithResource EnvPath: "solr.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": solr.Config.DockerNetworkName, + "DOCKER_NETWORK_NAME": solr.Config.Docker.Network, }, MakeDirs: []string{ diff --git a/internal/dis/component/sql/connect.go b/internal/dis/component/sql/connect.go index 6b2af55..835862e 100644 --- a/internal/dis/component/sql/connect.go +++ b/internal/dis/component/sql/connect.go @@ -52,7 +52,7 @@ func (sql *SQL) QueryTable(ctx context.Context, table component.Table) (*gorm.DB // queryTable returns a gorm.DB to connect to the provided distillery database table func (sql *SQL) queryTable(ctx context.Context, silent bool, table string) (*gorm.DB, error) { - conn, err := sql.connect(sql.Config.DistilleryDatabase) + conn, err := sql.connect(sql.Config.SQL.Database) if err != nil { return nil, err } @@ -117,8 +117,8 @@ func (ssql *SQL) connect(database string) (*sql.DB, error) { // dsn returns a dsn fof connecting to the database func (sql *SQL) dsn(database string) string { - user := sql.Config.MysqlAdminUser - pass := sql.Config.MysqlAdminPassword + user := sql.Config.SQL.AdminUsername + pass := sql.Config.SQL.AdminPassword network := sql.network() server := sql.ServerURL diff --git a/internal/dis/component/sql/sql.go b/internal/dis/component/sql/sql.go index 807b58f..134d9b5 100644 --- a/internal/dis/component/sql/sql.go +++ b/internal/dis/component/sql/sql.go @@ -31,7 +31,7 @@ var ( ) func (sql *SQL) Path() string { - return filepath.Join(sql.Still.Config.DeployRoot, "core", "sql") + return filepath.Join(sql.Still.Config.Paths.Root, "core", "sql") } func (*SQL) Context(parent component.InstallationContext) component.InstallationContext { @@ -49,8 +49,8 @@ func (sql *SQL) Stack(env environment.Environment) component.StackWithResources EnvPath: "sql.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": sql.Config.DockerNetworkName, - "HTTPS_ENABLED": sql.Config.HTTPSEnabledEnv(), + "DOCKER_NETWORK_NAME": sql.Config.Docker.Network, + "HTTPS_ENABLED": sql.Config.HTTP.HTTPSEnabledEnv(), }, MakeDirsPerm: environment.DefaultDirPerm, diff --git a/internal/dis/component/sql/update.go b/internal/dis/component/sql/update.go index ec16e0e..19cf3b5 100644 --- a/internal/dis/component/sql/update.go +++ b/internal/dis/component/sql/update.go @@ -54,8 +54,8 @@ func (sql *SQL) Update(ctx context.Context, progress io.Writer) error { } logging.LogMessage(progress, ctx, "Creating administrative user") { - username := sql.Config.MysqlAdminUser - password := sql.Config.MysqlAdminPassword + username := sql.Config.SQL.AdminUsername + password := sql.Config.SQL.AdminPassword if err := sql.CreateSuperuser(ctx, username, password, true); err != nil { return errSQLUnableToCreateUser } @@ -65,10 +65,10 @@ func (sql *SQL) Update(ctx context.Context, progress io.Writer) error { // create the admin user logging.LogMessage(progress, ctx, "Creating sql database") { - if !sqle.IsSafeDatabaseLiteral(sql.Config.DistilleryDatabase) { + if !sqle.IsSafeDatabaseLiteral(sql.Config.SQL.Database) { return errSQLUnsafeDatabaseName } - createDBSQL := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`;", sql.Config.DistilleryDatabase) + createDBSQL := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`;", sql.Config.SQL.Database) if err := sql.Exec(createDBSQL); err != nil { return err } diff --git a/internal/dis/component/ssh2/api.go b/internal/dis/component/ssh2/api.go index dc0da1f..783a7e9 100644 --- a/internal/dis/component/ssh2/api.go +++ b/internal/dis/component/ssh2/api.go @@ -26,7 +26,7 @@ func (ssh2 *SSH2) HandleRoute(ctx context.Context, path string) (http.Handler, e } // find the host - slug, ok := ssh2.Config.SlugFromHost(r.Host) + slug, ok := ssh2.Config.HTTP.SlugFromHost(r.Host) if slug == "" || !ok { httpx.TextInterceptor.Intercept(w, r, httpx.ErrNotFound) return diff --git a/internal/dis/component/ssh2/server_forward.go b/internal/dis/component/ssh2/server_forward.go index 227b4ee..285acfd 100644 --- a/internal/dis/component/ssh2/server_forward.go +++ b/internal/dis/component/ssh2/server_forward.go @@ -36,14 +36,15 @@ func (ssh2 *SSH2) handleDirectTCP(srv *ssh.Server, conn *gossh.ServerConn, newCh return } - slug, ok := ssh2.Config.SlugFromHost(d.DestAddr) + slug, ok := ssh2.Config.HTTP.SlugFromHost(d.DestAddr) if !ok || d.DestPort != 22 || !hasPermission(ctx, slug) { newChan.Reject(gossh.Prohibited, "permission denied") return } // TODO: move this into an instance function somewhere - dest := net.JoinHostPort(slug+"."+ssh2.Config.DefaultDomain+".wisski", "22") + // NOTE(twiesing): This should be moved + dest := net.JoinHostPort(slug+"."+ssh2.Config.HTTP.PrimaryDomain+".wisski", "22") var dialer net.Dialer dconn, err := dialer.DialContext(ctx, "tcp", dest) diff --git a/internal/dis/component/ssh2/server_handler.go b/internal/dis/component/ssh2/server_handler.go index 5f10a05..5c00a5f 100644 --- a/internal/dis/component/ssh2/server_handler.go +++ b/internal/dis/component/ssh2/server_handler.go @@ -80,8 +80,8 @@ func (ssh2 *SSH2) handleConnection(session ssh.Session) { banner := welcomeMessage for _, oldnew := range [][2]string{ {"${SLUG}", slug}, - {"${DOMAIN}", ssh2.Config.DefaultDomain}, - {"${HOSTNAME}", slug + "." + ssh2.Config.DefaultDomain}, + {"${DOMAIN}", ssh2.Config.HTTP.PrimaryDomain}, + {"${HOSTNAME}", slug + "." + ssh2.Config.HTTP.PrimaryDomain}, {"${PORT}", strconv.FormatUint(uint64(ssh2.Config.PublicSSHPort), 10)}, } { banner = strings.ReplaceAll(banner, oldnew[0], oldnew[1]) diff --git a/internal/dis/component/ssh2/stack.go b/internal/dis/component/ssh2/stack.go index 8692d52..78ef007 100644 --- a/internal/dis/component/ssh2/stack.go +++ b/internal/dis/component/ssh2/stack.go @@ -11,7 +11,7 @@ import ( ) func (ssh SSH2) Path() string { - return filepath.Join(ssh.Still.Config.DeployRoot, "core", "ssh2") + return filepath.Join(ssh.Still.Config.Paths.Root, "core", "ssh2") } //go:embed all:ssh2 ssh2.env @@ -24,15 +24,15 @@ func (ssh *SSH2) Stack(env environment.Environment) component.StackWithResources EnvPath: "ssh2.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": ssh.Config.DockerNetworkName, - "HOST_RULE": ssh.Config.DefaultHostRule(), - "HTTPS_ENABLED": ssh.Config.HTTPSEnabledEnv(), + "DOCKER_NETWORK_NAME": ssh.Config.Docker.Network, + "HOST_RULE": ssh.Config.HTTP.DefaultHostRule(), + "HTTPS_ENABLED": ssh.Config.HTTP.HTTPSEnabledEnv(), "CONFIG_PATH": ssh.Config.ConfigPath, - "DEPLOY_ROOT": ssh.Config.DeployRoot, + "DEPLOY_ROOT": ssh.Config.Paths.Root, - "SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile, - "SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile, + "SELF_OVERRIDES_FILE": ssh.Config.Paths.OverridesJSON, + "SELF_RESOLVER_BLOCK_FILE": ssh.Config.Paths.ResolverBlocks, "SSH_PORT": strconv.FormatUint(uint64(ssh.Config.PublicSSHPort), 10), }, @@ -44,6 +44,6 @@ func (ssh *SSH2) Stack(env environment.Environment) component.StackWithResources func (ssh SSH2) Context(parent component.InstallationContext) component.InstallationContext { return component.InstallationContext{ - bootstrap.Executable: ssh.Config.CurrentExecutable(ssh.Environment), // TODO: Does this make sense? + bootstrap.Executable: ssh.Config.Paths.CurrentExecutable(ssh.Environment), // TODO: Does this make sense? } } diff --git a/internal/dis/component/triplestore/database.go b/internal/dis/component/triplestore/database.go index 23b5235..4dabe2c 100644 --- a/internal/dis/component/triplestore/database.go +++ b/internal/dis/component/triplestore/database.go @@ -86,7 +86,7 @@ func (ts Triplestore) OpenRaw(ctx context.Context, method, url string, body any, if contentType != "" { req.Header.Set("Content-Type", contentType) } - req.SetBasicAuth(ts.Config.TriplestoreAdminUser, ts.Config.TriplestoreAdminPassword) + req.SetBasicAuth(ts.Config.TS.AdminUsername, ts.Config.TS.AdminPassword) // and send it return client.Do(req) diff --git a/internal/dis/component/triplestore/triplestore.go b/internal/dis/component/triplestore/triplestore.go index 5a572ea..411c2cd 100644 --- a/internal/dis/component/triplestore/triplestore.go +++ b/internal/dis/component/triplestore/triplestore.go @@ -25,7 +25,7 @@ var ( ) func (ts *Triplestore) Path() string { - return filepath.Join(ts.Still.Config.DeployRoot, "core", "triplestore") + return filepath.Join(ts.Still.Config.Paths.Root, "core", "triplestore") } func (Triplestore) Context(parent component.InstallationContext) component.InstallationContext { @@ -45,7 +45,7 @@ func (ts *Triplestore) Stack(env environment.Environment) component.StackWithRes EnvPath: "triplestore.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": ts.Config.DockerNetworkName, + "DOCKER_NETWORK_NAME": ts.Config.Docker.Network, }, MakeDirs: []string{ diff --git a/internal/dis/component/triplestore/update.go b/internal/dis/component/triplestore/update.go index 77d9fa4..e764740 100644 --- a/internal/dis/component/triplestore/update.go +++ b/internal/dis/component/triplestore/update.go @@ -20,8 +20,8 @@ func (ts Triplestore) Update(ctx context.Context, progress io.Writer) error { logging.LogMessage(progress, ctx, "Resetting admin user password") { - res, err := ts.OpenRaw(ctx, "PUT", "/rest/security/users/"+ts.Config.TriplestoreAdminUser, TriplestoreUserPayload{ - Password: ts.Config.TriplestoreAdminPassword, + res, err := ts.OpenRaw(ctx, "PUT", "/rest/security/users/"+ts.Config.TS.AdminUsername, TriplestoreUserPayload{ + Password: ts.Config.TS.AdminPassword, AppSettings: TriplestoreUserAppSettings{ DefaultInference: true, DefaultVisGraphSchema: true, diff --git a/internal/dis/component/web/web.go b/internal/dis/component/web/web.go index d234a2e..5865301 100644 --- a/internal/dis/component/web/web.go +++ b/internal/dis/component/web/web.go @@ -20,7 +20,7 @@ var ( ) func (web *Web) Path() string { - return filepath.Join(web.Still.Config.DeployRoot, "core", "web") + return filepath.Join(web.Still.Config.Paths.Root, "core", "web") } func (*Web) Context(parent component.InstallationContext) component.InstallationContext { @@ -28,7 +28,7 @@ func (*Web) Context(parent component.InstallationContext) component.Installation } func (web Web) Stack(env environment.Environment) component.StackWithResources { - if web.Config.HTTPSEnabled() { + if web.Config.HTTP.HTTPSEnabled() { return web.stackHTTPS(env) } else { return web.stackHTTP(env) @@ -46,8 +46,8 @@ func (web *Web) stackHTTPS(env environment.Environment) component.StackWithResou EnvPath: "web.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": web.Config.DockerNetworkName, - "CERT_EMAIL": web.Config.CertbotEmail, + "DOCKER_NETWORK_NAME": web.Config.Docker.Network, + "CERT_EMAIL": web.Config.HTTP.CertbotEmail, }, TouchFilesPerm: 0600, TouchFiles: []string{"acme.json"}, @@ -65,8 +65,8 @@ func (web *Web) stackHTTP(env environment.Environment) component.StackWithResour EnvPath: "web.env", EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": web.Config.DockerNetworkName, - "CERT_EMAIL": web.Config.CertbotEmail, + "DOCKER_NETWORK_NAME": web.Config.Docker.Network, + "CERT_EMAIL": web.Config.HTTP.CertbotEmail, }, }) } diff --git a/internal/dis/init.go b/internal/dis/init.go index 9074c74..74b3474 100644 --- a/internal/dis/init.go +++ b/internal/dis/init.go @@ -15,7 +15,7 @@ var errNoConfigFile = exit.Error{ var errOpenConfig = exit.Error{ ExitCode: exit.ExitGeneralArguments, - Message: "error loading configuration file: %s", + Message: "error loading configuration file: %q", } // NewDistillery creates a new distillery from the provided flags diff --git a/internal/wisski/ingredient/barrel/stack.go b/internal/wisski/ingredient/barrel/stack.go index a2714c4..e1af88a 100644 --- a/internal/wisski/ingredient/barrel/stack.go +++ b/internal/wisski/ingredient/barrel/stack.go @@ -23,14 +23,14 @@ func (barrel *Barrel) Stack() component.StackWithResources { EnvPath: filepath.Join("barrel.env"), EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": barrel.Malt.Config.DockerNetworkName, + "DOCKER_NETWORK_NAME": barrel.Malt.Config.Docker.Network, "SLUG": barrel.Slug, "VIRTUAL_HOST": barrel.Domain(), - "HTTPS_ENABLED": barrel.Malt.Config.HTTPSEnabledEnv(), + "HTTPS_ENABLED": barrel.Malt.Config.HTTP.HTTPSEnabledEnv(), "DATA_PATH": filepath.Join(barrel.FilesystemBase, "data"), - "RUNTIME_DIR": barrel.Malt.Config.RuntimeDir(), + "RUNTIME_DIR": barrel.Malt.Config.Paths.RuntimeDir(), }, MakeDirs: []string{"data", ".composer"}, diff --git a/internal/wisski/ingredient/php/extras/prefixes.go b/internal/wisski/ingredient/php/extras/prefixes.go index 3767b30..7c8b06f 100644 --- a/internal/wisski/ingredient/php/extras/prefixes.go +++ b/internal/wisski/ingredient/php/extras/prefixes.go @@ -82,7 +82,7 @@ func (wisski *Prefixes) database(ctx context.Context, server *phpx.Server) (pref func (prefixes *Prefixes) blocked() ([]string, error) { // open the resolver block file // TODO: move this to the distillery - file, err := prefixes.Malt.Environment.Open(prefixes.Malt.Config.SelfResolverBlockFile) + file, err := prefixes.Malt.Environment.Open(prefixes.Malt.Config.Paths.ResolverBlocks) if err != nil { return nil, err } diff --git a/internal/wisski/ingredient/reserve/reserve.go b/internal/wisski/ingredient/reserve/reserve.go index a5a5e31..69fcebb 100644 --- a/internal/wisski/ingredient/reserve/reserve.go +++ b/internal/wisski/ingredient/reserve/reserve.go @@ -30,11 +30,11 @@ func (reserve *Reserve) Stack() component.StackWithResources { EnvPath: filepath.Join("reserve.env"), EnvContext: map[string]string{ - "DOCKER_NETWORK_NAME": reserve.Malt.Config.DockerNetworkName, + "DOCKER_NETWORK_NAME": reserve.Malt.Config.Docker.Network, "SLUG": reserve.Slug, "VIRTUAL_HOST": reserve.Domain(), - "HTTPS_ENABLED": reserve.Malt.Config.HTTPSEnabledEnv(), + "HTTPS_ENABLED": reserve.Malt.Config.HTTP.HTTPSEnabledEnv(), }, } } diff --git a/internal/wisski/liquid/domain.go b/internal/wisski/liquid/domain.go index 0fbc1b7..7f174c8 100644 --- a/internal/wisski/liquid/domain.go +++ b/internal/wisski/liquid/domain.go @@ -6,7 +6,7 @@ import ( // Domain returns the full domain name of this WissKI func (liquid *Liquid) Domain() string { - return liquid.Config.HostFromSlug(liquid.Slug) + return liquid.Config.HTTP.HostFromSlug(liquid.Slug) } // URL returns the public URL of this instance @@ -18,7 +18,7 @@ func (liquid *Liquid) URL() *url.URL { } // use http or https scheme depending on if the distillery has it enabled - if liquid.Malt.Config.HTTPSEnabled() { + if liquid.Malt.Config.HTTP.HTTPSEnabled() { url.Scheme = "https" } else { url.Scheme = "http" diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go new file mode 100644 index 0000000..c9af2b9 --- /dev/null +++ b/pkg/validator/validator.go @@ -0,0 +1,88 @@ +package validator + +import ( + "errors" + "fmt" + "reflect" +) + +const ( + validateTag = "validate" + recurseTag = "recurse" + dfltTag = "default" +) + +// Validate validates an object of type T, setting defaults where appropriate. +// +// T must be a struct type, when this is not the case, returns ErrNotAStruct. +// validators should contain a set of validators. +// +// Validate iterates over the fields and tags of those fields as follows: +// - If the 'validate' tag is not the empty string, read the appropriate validator from the map, and call the function. +// If the element in the validators map does not exist, returns an error that unwraps to type UnknownValidator. +// If the element in the validators map is not a validator, returns an error that unwraps to type NotAValidator. +// If the type of validator function does not match the field type, returns an error that unwraps to type IncompatibleValidator. +// - If the 'recurse' tag is not the empty string, recurse into the struct type by calling Validate on it. +// If the annotated field is not a struct, return an error. +// +// Any error is wrapped in a FieldError, indicating the field they occured in. +// Recursive validate calls may result in FieldError wraps. +// For a description of struct tags, see [reflect.StructTag]. +func Validate[T any](data *T, validators map[string]any) error { + return validate(reflect.ValueOf(data).Elem(), validators) +} + +// FieldError wraps an error to indicate which field it occured in. +type FieldError struct { + Field string + Err error +} + +func (fe FieldError) Error() string { + return fmt.Sprintf("field %q: %s", fe.Field, fe.Err) +} + +func (fe FieldError) Unwrap() error { + return fe.Err +} + +var ErrNotAStruct = errors.New("validate called on non-struct type") + +func validate(datum reflect.Value, validators Collection) error { + // make sure that we have a struct type + typ := datum.Type() + if typ.Kind() != reflect.Struct { + return ErrNotAStruct + } + + fieldC := typ.NumField() + for i := 0; i < fieldC; i++ { + field := typ.Field(i) + + // if the recurse tag is set, do the recursion! + if field.Tag.Get(recurseTag) != "" { + if err := validate(datum.FieldByName(field.Name), validators); err != nil { + return FieldError{Field: field.Name, Err: err} + } + continue + } + + // check if there is a validator associated with this tag + // and if not, skip it! + validator := field.Tag.Get(validateTag) + if validator == "" { + continue + } + + // call the actual validator + if err := validators.Call( + validator, + datum.FieldByName(field.Name), + field.Tag.Get(dfltTag), + ); err != nil { + return FieldError{Field: field.Name, Err: err} + } + } + + return nil +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go new file mode 100644 index 0000000..2bf81d7 --- /dev/null +++ b/pkg/validator/validator_test.go @@ -0,0 +1,123 @@ +package validator + +import ( + "errors" + "fmt" + "strconv" +) + +func ExampleValidate() { + var value struct { + Number int `validate:"positive" default:"234"` + String string `validate:"nonempty" default:"stuff"` + Recursive struct { + Number int `validate:"positive" default:"45"` + String string `validate:"nonempty" default:"more"` + } `recurse:"true"` + } + + collection := make(Collection, 2) + Add(collection, "positive", func(value *int, dflt string) error { + if *value == 0 { + i, err := strconv.ParseInt(dflt, 10, 64) + if err != nil { + return err + } + *value = int(i) + return nil + } + if *value < 0 { + return errors.New("not positive") + } + return nil + }) + Add(collection, "nonempty", func(value *string, dflt string) error { + if *value == "" { + *value = dflt + } + if *value == "" { + return errors.New("empty string") + } + return nil + }) + + err := Validate(&value, collection) + fmt.Printf("%v\n", value) + fmt.Println(err) + // Output: {234 stuff {45 more}} + // +} + +func ExampleValidate_fail() { + var value struct { + Number int `validate:"positive" default:"12"` + String string `validate:"nonempty" default:"stuff"` + Recursive struct { + Number int `validate:"positive" default:"12"` + String string `validate:"nonempty"` + } `recurse:"true"` + } + + collection := make(Collection, 2) + Add(collection, "positive", func(value *int, dflt string) error { + if *value == 0 { + i, err := strconv.ParseInt(dflt, 10, 64) + if err != nil { + return err + } + *value = int(i) + return nil + } + if *value < 0 { + return errors.New("not positive") + } + return nil + }) + Add(collection, "nonempty", func(value *string, dflt string) error { + if *value == "" { + *value = dflt + } + if *value == "" { + return errors.New("empty string") + } + return nil + }) + + err := Validate(&value, collection) + fmt.Printf("%v\n", value) + fmt.Println(err) + // Output: {12 stuff {12 }} + // field "Recursive": field "String": empty string +} + +func ExampleValidate_notastruct() { + var value int + err := Validate(&value, nil) + fmt.Println(err) +} + +func ExampleValidate_notavalidator() { + var value struct { + Field int `validate:"generic"` + } + collection := make(Collection, 2) + collection["generic"] = func(x, y int) error { + panic("never reached") + } + err := Validate(&value, collection) + fmt.Println(err) + // Output: field "Field": entry "generic" in validators is not a valiator +} + +func ExampleValidate_invalid() { + var value struct { + Field int `validate:"string"` + } + collection := make(Collection, 2) + collection["string"] = func(value *string, dflt string) error { + panic("never reached") + } + err := Validate(&value, collection) + fmt.Println(err) + // Output: field "Field": validator "string": got type string, expected type int +} diff --git a/pkg/validator/vmap.go b/pkg/validator/vmap.go new file mode 100644 index 0000000..7d86196 --- /dev/null +++ b/pkg/validator/vmap.go @@ -0,0 +1,131 @@ +package validator + +import ( + "fmt" + "reflect" + "strings" + + "github.com/tkw1536/goprogram/lib/reflectx" +) + +// Collection represents a set of validators. +// The zero value is not ready to use; it should be created using make(). +// +// A validator is a non-nil function with signature func(value *F, dflt string) error. +// Here F is the type of a value of a field. +// The value is the initialized value to be validated. +// The validator may perform abitrary normalization on the value. +// dflt is the default value (read from the 'default' tag). +// error should be an appropriate error that occured. +// +// A validator function is applied by calling it. +type Collection map[string]any + +// Add adds a Validator to the provided collection of validators. +// Any previously validator of the same name is overwritten. +func Add[F any](coll Collection, name string, validator func(value *F, dflt string) error) { + coll[name] = validator +} + +// AddSlice adds a Validator to the provided collection of validators that validates a slice of the given type. The default is seperated by seperator. +func AddSlice[F any](coll Collection, name string, sep string, validator func(value *F, dflt string) error) { + Add(coll, name, func(value *[]F, dflt string) error { + // some value is set, so we do not need to set the default! + if *value != nil { + for i := range *value { + if err := validator(&(*value)[i], ""); err != nil { + return err + } + } + } + + // no default provided => set if to an empty slice + if dflt == "" { + *value = make([]F, 0) + return nil + } + + // some default provided => iterate over the underlying validator + dflts := strings.Split(dflt, sep) + *value = make([]F, len(dflts)) + for i := range *value { + if err := validator(&(*value)[i], dflts[i]); err != nil { + return err + } + } + + return nil + }) +} + +var ( + errTyp = reflectx.TypeOf[error]() + strTyp = reflectx.TypeOf[string]() +) + +// UnknownValidator is an error returned from Validate if a validator does not exist +type UnknownValidator string + +func (uv UnknownValidator) Error() string { + return fmt.Sprintf("unknown validator %q", string(uv)) +} + +// NotAValidator is an error returned from Validate if an entry in the validators map is not a validator +type NotAValidator string + +func (nv NotAValidator) Error() string { + return fmt.Sprintf("entry %q in validators is not a valiator", string(nv)) +} + +// IncompatibleValidator is returned when a validator in the validators map is incompatible +type IncompatibleValidator struct { + Validator string + GotType reflect.Type + ExpectedType reflect.Type +} + +func (iv IncompatibleValidator) Error() string { + return fmt.Sprintf("validator %q: got type %s, expected type %s", iv.Validator, iv.GotType, iv.ExpectedType) +} + +// Call calls the validator with the given name, on the given value, and with the provided default. +// See documentation of [Validate] for details. +func (coll Collection) Call(name string, field reflect.Value, dflt string) error { + validator, ok := coll[name] + if !ok { + return UnknownValidator(name) + } + + // get the type of the validator + vFunc := reflect.ValueOf(validator) + vTyp := vFunc.Type() + + // ensure that vTyp is of type func(*F,string) error + // where T is the type of the field + // + // - the first if assumes checks for some type F + // - the second if checks if the F is the right one + if validator == nil || vTyp.Kind() != reflect.Func || // func + vTyp.NumIn() != 2 || vTyp.In(0).Kind() != reflect.Pointer || vTyp.In(1) != strTyp || // (*F,string) + vTyp.NumOut() != 1 || vTyp.Out(0) != errTyp { // error + return NotAValidator(name) + } + if vTyp.In(0).Elem() != field.Type() { // the correct *F + return IncompatibleValidator{ + Validator: name, + GotType: vTyp.In(0).Elem(), + ExpectedType: field.Type(), + } + } + + // call the validator function, and return an error + results := vFunc.Call([]reflect.Value{field.Addr(), reflect.ValueOf(dflt)}) + + // turn the result into an error + // NOTE: We can't just .(error) here because that panic()s on err == nil + err := results[0].Interface() + if err, ok := err.(error); ok { + return err + } + return nil +} diff --git a/program.go b/program.go index 6c18274..591230e 100644 --- a/program.go +++ b/program.go @@ -72,8 +72,8 @@ func NewProgram() Program { // when not running inside docker and we need a distillery // then we should warn if we are not using the distillery executable. - if dis := context.Environment; !context.Args.Flags.InternalInDocker && context.Description.Requirements.NeedsDistillery && !dis.Config.UsingDistilleryExecutable(dis.Environment) { - context.EPrintf(warnNoDeployWdcli, bootstrap.Executable, dis.Config.ExecutablePath()) + if dis := context.Environment; !context.Args.Flags.InternalInDocker && context.Description.Requirements.NeedsDistillery && !dis.Config.Paths.UsingDistilleryExecutable(dis.Environment) { + context.EPrintf(warnNoDeployWdcli, bootstrap.Executable, dis.Config.Paths.ExecutablePath()) } return nil