feat(config): store server config in file

Ayman Bagabas created

Change summary

server/config/config.go | 103 ++++++++++++++++++++++++++++++++----------
server/config/file.go   |  68 ++++++++++++++++++++++++++++
2 files changed, 145 insertions(+), 26 deletions(-)

Detailed changes

server/config/config.go 🔗

@@ -1,97 +1,148 @@
 package config
 
 import (
+	"os"
 	"path/filepath"
 
 	"github.com/caarlos0/env/v6"
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
+	"gopkg.in/yaml.v3"
 )
 
 // SSHConfig is the configuration for the SSH server.
 type SSHConfig struct {
 	// ListenAddr is the address on which the SSH server will listen.
-	ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"`
+	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 
 	// PublicURL is the public URL of the SSH server.
-	PublicURL string `env:"PUBLIC_URL" envDefault:"ssh://localhost:23231"`
+	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 
 	// KeyPath is the path to the SSH server's private key.
-	KeyPath string `env:"KEY_PATH"`
+	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 
 	// InternalKeyPath is the path to the SSH server's internal private key.
-	InternalKeyPath string `env:"INTERNAL_KEY_PATH"`
+	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
 
 	// MaxTimeout is the maximum number of seconds a connection can take.
-	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout`
 
 	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
-	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"`
+	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 }
 
 // GitConfig is the Git daemon configuration for the server.
 type GitConfig struct {
 	// ListenAddr is the address on which the Git daemon will listen.
-	ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"`
+	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 
 	// MaxTimeout is the maximum number of seconds a connection can take.
-	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 
 	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
-	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"`
+	IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"`
 
 	// MaxConnections is the maximum number of concurrent connections.
-	MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"`
+	MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
 }
 
 // HTTPConfig is the HTTP configuration for the server.
 type HTTPConfig struct {
 	// ListenAddr is the address on which the HTTP server will listen.
-	ListenAddr string `env:"LISTEN_ADDR" envDefault:":8080"`
+	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 
 	// PublicURL is the public URL of the HTTP server.
-	PublicURL string `env:"PUBLIC_URL" envDefault:"http://localhost:8080"`
+	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 }
 
 // Config is the configuration for Soft Serve.
 type Config struct {
 	// Name is the name of the server.
-	Name string `env:"NAME" envDefault:"Soft Serve"`
+	Name string `env:"NAME" yaml:"name"`
 
 	// SSH is the configuration for the SSH server.
-	SSH SSHConfig `envPrefix:"SSH_"`
+	SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"`
 
 	// Git is the configuration for the Git daemon.
-	Git GitConfig `envPrefix:"GIT_"`
+	Git GitConfig `envPrefix:"GIT_" yaml:"git"`
 
 	// HTTP is the configuration for the HTTP server.
-	HTTP HTTPConfig `envPrefix:"HTTP_"`
+	HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"`
 
 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
-	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
+	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n" yaml:"initial_admin_keys"`
 
 	// DataPath is the path to the directory where Soft Serve will store its data.
-	DataPath string `env:"DATA_PATH" envDefault:"data"`
+	DataPath string `env:"DATA_PATH" envDefault:"data" yaml:"-"`
 
 	// Backend is the Git backend to use.
-	Backend backend.Backend
+	Backend backend.Backend `yaml:"-"`
+}
+
+// ParseConfig parses the configuration from the given file.
+func ParseConfig(path string) (*Config, error) {
+	cfg := &Config{}
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
+		return nil, err
+	}
+
+	return cfg, nil
 }
 
 // DefaultConfig returns a Config with the values populated with the defaults
 // or specified environment variables.
 func DefaultConfig() *Config {
-	cfg := &Config{}
+	dataPath := os.Getenv("SOFT_SERVE_DATA_PATH")
+	if dataPath == "" {
+		dataPath = "data"
+	}
+
+	cfg := &Config{
+		Name:     "Soft Serve",
+		DataPath: dataPath,
+		SSH: SSHConfig{
+			ListenAddr:      ":23231",
+			PublicURL:       "ssh://localhost:23231",
+			KeyPath:         filepath.Join("ssh", "soft_serve"),
+			InternalKeyPath: filepath.Join("ssh", "soft_serve_internal"),
+			MaxTimeout:      0,
+			IdleTimeout:     120,
+		},
+		Git: GitConfig{
+			ListenAddr:     ":9418",
+			MaxTimeout:     0,
+			IdleTimeout:    3,
+			MaxConnections: 32,
+		},
+		HTTP: HTTPConfig{
+			ListenAddr: ":8080",
+			PublicURL:  "http://localhost:8080",
+		},
+	}
+	cp := filepath.Join(cfg.DataPath, "config.yaml")
+	f, err := os.Open(cp)
+	if err == nil {
+		defer f.Close()
+		if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
+			log.Error("failed to decode config", "err", err)
+		}
+	} else {
+		defer func() {
+			os.WriteFile(cp, []byte(newConfigFile(cfg)), 0o600) // nolint: errcheck
+		}()
+	}
+
 	if err := env.Parse(cfg, env.Options{
 		Prefix: "SOFT_SERVE_",
 	}); err != nil {
 		log.Fatal(err)
 	}
-	if cfg.SSH.KeyPath == "" {
-		cfg.SSH.KeyPath = filepath.Join(cfg.DataPath, "ssh", "soft_serve")
-	}
-	if cfg.SSH.InternalKeyPath == "" {
-		cfg.SSH.InternalKeyPath = filepath.Join(cfg.DataPath, "ssh", "soft_serve_internal")
-	}
+
 	return cfg
 }
 

server/config/file.go 🔗

@@ -0,0 +1,68 @@
+package config
+
+import (
+	"bytes"
+	"text/template"
+)
+
+var (
+	configFileTmpl = template.Must(template.New("config").Parse(`# Soft Serve Server configurations
+
+# The name of the server.
+# This is the name that will be displayed in the UI.
+name: "{{ .Name }}"
+
+# The SSH server configuration.
+ssh:
+  # The address on which the SSH server will listen.
+  listen_addr: "{{ .SSH.ListenAddr }}"
+
+  # The public URL of the SSH server.
+  # This is the address will be used to clone repositories.
+  public_url: "{{ .SSH.PublicURL }}"
+
+  # The relative path to the SSH server's private key.
+  key_path: "{{ .SSH.KeyPath }}"
+
+  # The relative path to the SSH server's internal api private key.
+  internal_key_path: "{{ .SSH.InternalKeyPath }}"
+
+  # The maximum number of seconds a connection can take.
+  # A value of 0 means no timeout.
+  max_timeout: {{ .SSH.MaxTimeout }}
+
+  # The number of seconds a connection can be idle before it is closed.
+  idle_timeout: {{ .SSH.IdleTimeout }}
+
+# The Git daemon configuration.
+git:
+  # The address on which the Git daemon will listen.
+  listen_addr: "{{ .Git.ListenAddr }}"
+
+  # The maximum number of seconds a connection can take.
+  # A value of 0 means no timeout.
+  max_timeout: {{ .Git.MaxTimeout }}
+
+  # The number of seconds a connection can be idle before it is closed.
+  idle_timeout: {{ .Git.IdleTimeout }}
+
+  # The maximum number of concurrent connections.
+  max_connections: {{ .Git.MaxConnections }}
+
+# The HTTP server configuration.
+http:
+  # The address on which the HTTP server will listen.
+  listen_addr: "{{ .HTTP.ListenAddr }}"
+
+  # The public URL of the HTTP server.
+  # This is the address will be used to clone repositories.
+  public_url: "{{ .HTTP.PublicURL }}"
+
+`))
+)
+
+func newConfigFile(cfg *Config) string {
+	var b bytes.Buffer
+	configFileTmpl.Execute(&b, cfg)
+	return b.String()
+}