complement/internal/config/config.go
kegsay 39b3b9dbb2
Add COMPLEMENT_ENABLE_DIRTY_RUNS to vastly speed up test runs (#677)
* WIP: enable dirty runs

* Env var up dirty runs, fix up destroy function

* Play nicely with post-test scripts and always printing server logs

* Re-add in bad rebase
2023-10-18 14:23:40 +01:00

286 lines
11 KiB
Go

package config
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"regexp"
"strconv"
"strings"
"time"
)
type HostMount struct {
HostPath string
ContainerPath string
ReadOnly bool
}
// The config for running Complement. This is configured using environment variables. The comments
// in this struct are structured so they can be automatically parsed via gendoc. See /cmd/gendoc.
type Complement struct {
// Name: COMPLEMENT_BASE_IMAGE
// Description: **Required.** The name of the Docker image to use as a base homeserver when generating
// blueprints. This image must conform to Complement's rules on containers, such as listening on the
// correct ports.
BaseImageURI string
// Name: COMPLEMENT_DEBUG
// Default: 0
// Description: If 1, prints out more verbose logging such as HTTP request/response bodies.
DebugLoggingEnabled bool
// Name: COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS
// Default: 0
// Description: If 1, always prints the Homeserver container logs even on success. When used with
// COMPLEMENT_ENABLE_DIRTY_RUNS, server logs are only printed once for reused deployments, at the very
// end of the test suite.
AlwaysPrintServerLogs bool
// Name: COMPLEMENT_SHARE_ENV_PREFIX
// Description: If set, all environment variables on the host with this prefix will be shared with
// every homeserver, with the prefix removed. For example, if the prefix was `FOO_` then setting
// `FOO_BAR=baz` on the host would translate to `BAR=baz` on the container. Useful for passing through
// extra Homeserver configuration options without sharing all host environment variables.
EnvVarsPropagatePrefix string
// Name: COMPLEMENT_SPAWN_HS_TIMEOUT_SECS
// Default: 30
// Description: The number of seconds to wait for a Homeserver container to be responsive after
// starting the container. Responsiveness is detected by `HEALTHCHECK` being healthy *and*
// the `/versions` endpoint returning 200 OK.
SpawnHSTimeout time.Duration
// Name: COMPLEMENT_KEEP_BLUEPRINTS
// Description: A list of space separated blueprint names to not clean up after running. For example,
// `one_to_one_room alice` would not delete the homeserver images for the blueprints `alice` and
// `one_to_one_room`. This can speed up homeserver runs if you frequently run the same base image
// over and over again. If the base image changes, this should not be set as it means an older version
// of the base image will be used for the named blueprints.
KeepBlueprints []string
// Name: COMPLEMENT_HOST_MOUNTS
// Description: A list of semicolon separated host mounts to mount on every container. The structure
// of the mount is `host-path:container-path:[ro]` for example `/path/on/host:/path/on/container` - you
// can optionally specify `:ro` to mount the path as readonly. A complete example with multiple mounts
// would look like `/host/a:/container/a:ro;/host/b:/container/b;/host/c:/container/c`
HostMounts []HostMount
// Name: COMPLEMENT_BASE_IMAGE_*
// Description: This allows you to override the base image used for a particular named homeserver.
// For example, `COMPLEMENT_BASE_IMAGE_HS1=complement-dendrite:latest` would use `complement-dendrite:latest`
// for the `hs1` homeserver in blueprints, but not any other homeserver (e.g `hs2`). This matching
// is case-insensitive. This allows Complement to test how different homeserver implementations work with each other.
BaseImageURIs map[string]string
// The namespace for all complement created blueprints and deployments
PackageNamespace string
// Certificate Authority generated values for this run of complement. Homeservers will use this
// as a base to derive their own signed Federation certificates.
CACertificate *x509.Certificate
CAPrivateKey *rsa.PrivateKey
BestEffort bool
// Name: COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT
// Default: host.docker.internal
// Description: The hostname of Complement from the perspective of a Homeserver running inside a container.
// This can be useful for container runtimes using another hostname to access the host from a container,
// like Podman that uses `host.containers.internal` instead.
HostnameRunningComplement string
// Name: COMPLEMENT_ENABLE_DIRTY_RUNS
// Default: 0
// Description: If 1, eligible tests will be provided with reusable deployments rather than a clean deployment.
// Eligible tests are tests run with `Deploy(t, numHomeservers)`. If enabled, COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS
// and COMPLEMENT_POST_TEST_SCRIPT are run exactly once, at the end of all tests in the package. The post test script
// is run with the test name "COMPLEMENT_ENABLE_DIRTY_RUNS", and failed=false.
//
// Enabling dirty runs can greatly speed up tests, at the cost of clear server logs and the chance of tests
// polluting each other. Tests using `OldDeploy` and blueprints will still have a fresh image for each test.
// Fresh images can still be desirable e.g user directory tests need a clean homeserver else search results can
// be polluted, tests which can blacklist a server over federation also need isolated deployments to stop failures
// impacting other tests. For these reasons, there will always be a way for a test to override this setting and
// get a dedicated deployment.
//
// Eventually, dirty runs will become the default running mode of Complement, with an environment variable to
// disable this behaviour being added later, once this has stablised.
EnableDirtyRuns bool
HSPortBindingIP string
// Name: COMPLEMENT_POST_TEST_SCRIPT
// Default: ""
// Description: An arbitrary script to execute after a test was executed and before the container is removed.
// This can be used to extract, for example, server logs or database files. The script is passed the parameters:
// ContainerID, TestName, TestFailed (true/false). When combined with COMPLEMENT_ENABLE_DIRTY_RUNS, the script is
// called exactly once at the end of the test suite, and is called with the TestName of "COMPLEMENT_ENABLE_DIRTY_RUNS"
// and TestFailed=false.
PostTestScript string
}
var hsRegex = regexp.MustCompile(`COMPLEMENT_BASE_IMAGE_(.+)=(.+)$`)
func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement {
cfg := &Complement{BaseImageURIs: map[string]string{}}
cfg.BaseImageURI = os.Getenv("COMPLEMENT_BASE_IMAGE")
if cfg.BaseImageURI == "" {
cfg.BaseImageURI = baseImageURI
}
cfg.DebugLoggingEnabled = os.Getenv("COMPLEMENT_DEBUG") == "1"
cfg.AlwaysPrintServerLogs = os.Getenv("COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS") == "1"
cfg.EnableDirtyRuns = os.Getenv("COMPLEMENT_ENABLE_DIRTY_RUNS") == "1"
cfg.EnvVarsPropagatePrefix = os.Getenv("COMPLEMENT_SHARE_ENV_PREFIX")
cfg.PostTestScript = os.Getenv("COMPLEMENT_POST_TEST_SCRIPT")
cfg.SpawnHSTimeout = time.Duration(parseEnvWithDefault("COMPLEMENT_SPAWN_HS_TIMEOUT_SECS", 30)) * time.Second
if os.Getenv("COMPLEMENT_VERSION_CHECK_ITERATIONS") != "" {
fmt.Fprintln(os.Stderr, "Deprecated: COMPLEMENT_VERSION_CHECK_ITERATIONS will be removed in a later version. Use COMPLEMENT_SPAWN_HS_TIMEOUT_SECS instead which does the same thing and is clearer.")
// each iteration had a 50ms sleep between tries so the timeout is 50 * iteration ms
cfg.SpawnHSTimeout = time.Duration(50*parseEnvWithDefault("COMPLEMENT_VERSION_CHECK_ITERATIONS", 100)) * time.Millisecond
}
cfg.KeepBlueprints = strings.Split(os.Getenv("COMPLEMENT_KEEP_BLUEPRINTS"), " ")
var err error
hostMounts := os.Getenv("COMPLEMENT_HOST_MOUNTS")
if hostMounts != "" {
cfg.HostMounts, err = newHostMounts(strings.Split(hostMounts, ";"))
if err != nil {
panic("COMPLEMENT_HOST_MOUNTS parse error: " + err.Error())
}
}
if cfg.BaseImageURI == "" {
panic("COMPLEMENT_BASE_IMAGE must be set")
}
// Parse HS specific base images
for _, env := range os.Environ() {
// FindStringSubmatch returns the complete match as well as the capture groups.
// In this case we expect there to be 3 matches.
if matches := hsRegex.FindStringSubmatch(env); len(matches) == 3 {
hs := matches[1] // first capture group; homeserver name
cfg.BaseImageURIs[hs] = matches[2] // second capture group; homeserver image
}
}
cfg.PackageNamespace = pkgNamespace
// create CA certs and keys
if err := cfg.GenerateCA(); err != nil {
panic("Failed to generate CA certificate/key: " + err.Error())
}
if cfg.PackageNamespace == "" {
panic("package namespace must be set")
}
HostnameRunningComplement := os.Getenv("COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT")
if HostnameRunningComplement != "" {
cfg.HostnameRunningComplement = HostnameRunningComplement
} else {
cfg.HostnameRunningComplement = "host.docker.internal"
}
// HSPortBindingIP is fixed here, but used by homerunner to override.
cfg.HSPortBindingIP = "127.0.0.1"
return cfg
}
func (c *Complement) GenerateCA() error {
cert, key, err := generateCAValues()
if err != nil {
return err
}
c.CACertificate = cert
c.CAPrivateKey = key
return nil
}
func (c *Complement) CACertificateBytes() ([]byte, error) {
cert := bytes.NewBuffer(nil)
err := pem.Encode(cert, &pem.Block{Type: "CERTIFICATE", Bytes: c.CACertificate.Raw})
return cert.Bytes(), err
}
func (c *Complement) CAPrivateKeyBytes() ([]byte, error) {
caKey := bytes.NewBuffer(nil)
err := pem.Encode(caKey, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(c.CAPrivateKey),
})
return caKey.Bytes(), err
}
func parseEnvWithDefault(key string, def int) int {
s := os.Getenv(key)
if s != "" {
i, err := strconv.Atoi(s)
if err != nil {
// Don't bother trying to report it
return def
}
return i
}
return def
}
func newHostMounts(mounts []string) ([]HostMount, error) {
var hostMounts []HostMount
for _, m := range mounts {
segments := strings.Split(m, ":")
if len(segments) < 2 {
return nil, fmt.Errorf("mount '%s' malformed", m)
}
var ro string
if len(segments) == 3 {
ro = segments[2]
}
hostMounts = append(hostMounts, HostMount{
HostPath: segments[0],
ContainerPath: segments[1],
ReadOnly: ro == "ro" || ro == "readonly",
})
}
return hostMounts, nil
}
// Generate a certificate and private key
func generateCAValues() (*x509.Certificate, *rsa.PrivateKey, error) {
// valid for 10 years
certificateDuration := time.Hour * 24 * 365 * 10
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
notBefore := time.Now()
notAfter := notBefore.Add(certificateDuration)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, err
}
caCert := x509.Certificate{
SerialNumber: serialNumber,
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
Subject: pkix.Name{
Organization: []string{"matrix.org"},
Country: []string{"GB"},
Province: []string{"London"},
Locality: []string{"London"},
StreetAddress: []string{"123 Street"},
PostalCode: []string{"12345"},
CommonName: "Complement Test CA",
},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &caCert, &caCert, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
selfSignedCert, err := x509.ParseCertificates(derBytes)
if err != nil {
return nil, nil, err
}
return selfSignedCert[0], priv, nil
}