From 1360e4d8e2799172baf8d7bc471a084de635bd14 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 11 Dec 2025 14:40:13 -0600 Subject: [PATCH 01/14] Limit CPU --- internal/docker/deployer.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index 0a8a511a..0734c965 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -20,6 +20,7 @@ import ( "crypto/tls" "fmt" "log" + "math" "net/http" "net/url" "os" @@ -375,6 +376,11 @@ func deployImage( log.Printf("Sharing %v host environment variables with container", env) } + // Number of microseconds per CPU period. + constrainedCPUPeriod := int64(100000) + // Number of cores to constrain the container to (can be fractional). + constrainedCPUCores := float64(0.5) + body, err := docker.ContainerCreate(ctx, &container.Config{ Image: imageID, Env: env, @@ -399,6 +405,11 @@ func deployImage( PublishAllPorts: true, ExtraHosts: extraHosts, Mounts: mounts, + // https://docs.docker.com/engine/containers/resource_constraints/ + Resources: container.Resources{ + CPUPeriod: constrainedCPUPeriod, + CPUQuota: int64(math.Floor(constrainedCPUCores * float64(constrainedCPUPeriod))), + }, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ networkName: { From 5274dd57d8bb9c29f1ca78d09a14f75c76006b55 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 11 Dec 2025 15:27:38 -0600 Subject: [PATCH 02/14] Add `COMPLEMENT_CONTAINER_CPUS` and use simpler `NanoCPUs` --- ENVIRONMENT.md | 5 +++++ cmd/gendoc/ENVIRONMENT.md | 0 config/config.go | 8 ++++++++ internal/docker/deployer.go | 9 +-------- 4 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 cmd/gendoc/ENVIRONMENT.md diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 0dad97ed..5971645b 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -16,6 +16,11 @@ If 1, always prints the Homeserver container logs even on success. When used wit 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. - Type: `map[string]string` +#### `COMPLEMENT_CONTAINER_CPUS` +The number of CPU cores available for the container to use (can be fractional like 0.5). This is passed to Docker as the `--cpus` argument. If 0, no limit is set and the container can use all available host CPUs. This is useful to mimic a resource-constrained environment, like a CI environment. +- Type: `float64` +- Default: 0 + #### `COMPLEMENT_DEBUG` If 1, prints out more verbose logging such as HTTP request/response bodies. - Type: `bool` diff --git a/cmd/gendoc/ENVIRONMENT.md b/cmd/gendoc/ENVIRONMENT.md new file mode 100644 index 00000000..e69de29b diff --git a/config/config.go b/config/config.go index 5260a62e..51dcdaac 100644 --- a/config/config.go +++ b/config/config.go @@ -52,6 +52,13 @@ type Complement struct { // starting the container. Responsiveness is detected by `HEALTHCHECK` being healthy *and* // the `/versions` endpoint returning 200 OK. SpawnHSTimeout time.Duration + // Name: COMPLEMENT_CONTAINER_CPUS + // Default: 0 + // Description: The number of CPU cores available for the container to use (can be + // fractional like 0.5). This is passed to Docker as the `--cpus` argument. If 0, no + // limit is set and the container can use all available host CPUs. This is useful to + // mimic a resource-constrained environment, like a CI environment. + ContainerCPUCores float64 // 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 @@ -145,6 +152,7 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement { // 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.ContainerCPUCores, _ = strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPUS"), 64) cfg.KeepBlueprints = strings.Split(os.Getenv("COMPLEMENT_KEEP_BLUEPRINTS"), " ") var err error hostMounts := os.Getenv("COMPLEMENT_HOST_MOUNTS") diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index 0734c965..f8a27e42 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -20,7 +20,6 @@ import ( "crypto/tls" "fmt" "log" - "math" "net/http" "net/url" "os" @@ -376,11 +375,6 @@ func deployImage( log.Printf("Sharing %v host environment variables with container", env) } - // Number of microseconds per CPU period. - constrainedCPUPeriod := int64(100000) - // Number of cores to constrain the container to (can be fractional). - constrainedCPUCores := float64(0.5) - body, err := docker.ContainerCreate(ctx, &container.Config{ Image: imageID, Env: env, @@ -407,8 +401,7 @@ func deployImage( Mounts: mounts, // https://docs.docker.com/engine/containers/resource_constraints/ Resources: container.Resources{ - CPUPeriod: constrainedCPUPeriod, - CPUQuota: int64(math.Floor(constrainedCPUCores * float64(constrainedCPUPeriod))), + NanoCPUs: int64(cfg.ContainerCPUCores * 1e9), }, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ From 44cbb37d4a3e8f0e67151bf4e9f06aaa7f17ebae Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 11 Dec 2025 15:30:36 -0600 Subject: [PATCH 03/14] Explain why we use `NanoCPUs` --- internal/docker/deployer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index f8a27e42..57655289 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -401,6 +401,10 @@ func deployImage( Mounts: mounts, // https://docs.docker.com/engine/containers/resource_constraints/ Resources: container.Resources{ + // The number of CPU cores in 1e9 increments + // + // `NanoCPUs` is the option that is "Applicable to all platforms" instead of + // `CPUPeriod`/`CPUQuota` (Unix only) or `CPUCount`/`CPUPercent` (Windows only). NanoCPUs: int64(cfg.ContainerCPUCores * 1e9), }, }, &network.NetworkingConfig{ From 3aae727f00df28a1633ce5c5990d3f5d07c62477 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 11 Dec 2025 15:34:05 -0600 Subject: [PATCH 04/14] Update docs `go run ./cmd/gendoc --config config/config.go > ENVIRONMENT.md` --- ENVIRONMENT.md | 2 +- config/config.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 5971645b..855c636b 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -17,7 +17,7 @@ This allows you to override the base image used for a particular named homeserve - Type: `map[string]string` #### `COMPLEMENT_CONTAINER_CPUS` -The number of CPU cores available for the container to use (can be fractional like 0.5). This is passed to Docker as the `--cpus` argument. If 0, no limit is set and the container can use all available host CPUs. This is useful to mimic a resource-constrained environment, like a CI environment. +The number of CPU cores available for the container to use (can be fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument. If 0, no limit is set and the container can use all available host CPUs. This is useful to mimic a resource-constrained environment, like a CI environment. - Type: `float64` - Default: 0 diff --git a/config/config.go b/config/config.go index 51dcdaac..2963fb43 100644 --- a/config/config.go +++ b/config/config.go @@ -55,9 +55,9 @@ type Complement struct { // Name: COMPLEMENT_CONTAINER_CPUS // Default: 0 // Description: The number of CPU cores available for the container to use (can be - // fractional like 0.5). This is passed to Docker as the `--cpus` argument. If 0, no - // limit is set and the container can use all available host CPUs. This is useful to - // mimic a resource-constrained environment, like a CI environment. + // fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument. + // If 0, no limit is set and the container can use all available host CPUs. This is + // useful to mimic a resource-constrained environment, like a CI environment. ContainerCPUCores float64 // Name: COMPLEMENT_KEEP_BLUEPRINTS // Description: A list of space separated blueprint names to not clean up after running. For example, From e4396c520cc5da218c8f8a9c570e12aff40ba8dd Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 13:37:13 -0600 Subject: [PATCH 05/14] Add `COMPLEMENT_CONTAINER_MEMORY` to limit memory --- ENVIRONMENT.md | 5 ++ config/config.go | 109 +++++++++++++++++++++++++++++++++++- internal/docker/deployer.go | 18 +++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 855c636b..7cc1d7d4 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -21,6 +21,11 @@ The number of CPU cores available for the container to use (can be fractional li - Type: `float64` - Default: 0 +#### `COMPLEMENT_CONTAINER_MEMORY` +The maximum amount of memory the container can use. This is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment. +- Type: `int64` +- Default: 0 + #### `COMPLEMENT_DEBUG` If 1, prints out more verbose logging such as HTTP request/response bodies. - Type: `bool` diff --git a/config/config.go b/config/config.go index 2963fb43..4b06d538 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( "math/big" "os" "regexp" + "sort" "strconv" "strings" "time" @@ -59,6 +60,13 @@ type Complement struct { // If 0, no limit is set and the container can use all available host CPUs. This is // useful to mimic a resource-constrained environment, like a CI environment. ContainerCPUCores float64 + // Name: COMPLEMENT_CONTAINER_MEMORY + // Default: 0 + // Description: The maximum amount of memory the container can use. This is passed to + // Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container + // can use all available host memory. This is useful to mimic a resource-constrained + // environment, like a CI environment. + ContainerMemoryBytes int64 // 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 @@ -153,8 +161,12 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement { cfg.SpawnHSTimeout = time.Duration(50*parseEnvWithDefault("COMPLEMENT_VERSION_CHECK_ITERATIONS", 100)) * time.Millisecond } cfg.ContainerCPUCores, _ = strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPUS"), 64) + parsedMemoryBytes, err := parseByteSizeString(os.Getenv("COMPLEMENT_CONTAINER_MEMORY")) + if err != nil { + panic("COMPLEMENT_CONTAINER_MEMORY parse error: " + err.Error()) + } + cfg.ContainerMemoryBytes = parsedMemoryBytes 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, ";")) @@ -235,6 +247,101 @@ func parseEnvWithDefault(key string, def int) int { return def } +// parseByteSizeString parses a byte size string (case insensitive) like "512MB" +// or "2GB" into bytes. If the string is empty, 0 is returned. Returns an error if the +// string does not match one of the valid units or is an invalid integer. +// +// Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", +// "GiB", "TiB", "PiB") or no units (bytes). +func parseByteSizeString(inputString string) (int64, error) { + // Strip spaces and normalize to lowercase + normalizedString := strings.TrimSpace(strings.ToLower(inputString)) + if normalizedString == "" { + return 0, nil + } + unitToByteMultiplierMap := map[string]int64{ + // No unit (bytes) + "": 1, + "b": 1, + "kb": intPow(10, 3), + "mb": intPow(10, 6), + "gb": intPow(10, 9), + "tb": intPow(10, 12), + "kib": 1024, + "mib": intPow(1024, 2), + "gib": intPow(1024, 3), + "tib": intPow(1024, 4), + } + availableUnitsSorted := make([]string, 0, len(unitToByteMultiplierMap)) + for unit := range unitToByteMultiplierMap { + availableUnitsSorted = append(availableUnitsSorted, unit) + } + // Sort units by length descending so that longer units are matched first + // (e.g "mib" before "b") + sort.Slice(availableUnitsSorted, func(i, j int) bool { + return len(availableUnitsSorted[i]) > len(availableUnitsSorted[j]) + }) + + // Find the number part of the string and the unit used + numberPart := "" + byteUnit := "" + byteMultiplier := int64(0) + for _, unit := range availableUnitsSorted { + if strings.HasSuffix(normalizedString, unit) { + byteUnit = unit + // Handle the case where there is a space between the number and the unit (e.g "512 MB") + numberPart = strings.TrimSpace(normalizedString[:len(normalizedString)-len(unit)]) + byteMultiplier = unitToByteMultiplierMap[unit] + break + } + } + + // Failed to find a valid unit + if byteUnit == "" { + return 0, fmt.Errorf("parseByteSizeString: invalid byte unit used in string: %s (supported units: %s)", + inputString, + strings.Join(availableUnitsSorted, ", "), + ) + } + // Assert to sanity check our logic above is sound + if byteMultiplier == 0 { + panic(fmt.Sprintf( + "parseByteSizeString: byteMultiplier is unexpectedly 0 for unit: %s. "+ + "This is probably a problem with the function itself.", byteUnit, + )) + } + + // Parse the number part as an int64 + parsedNumber, err := strconv.ParseInt(strings.TrimSpace(numberPart), 10, 64) + if err != nil { + return 0, fmt.Errorf("parseByteSizeString: failed to parse number part of string: %s (%w)", + numberPart, + err, + ) + } + + // Calculate the total bytes + totalBytes := parsedNumber * byteMultiplier + return totalBytes, nil +} + +// intPow calculates n to the mth power. Since the result is an int, it is assumed that m is a positive power +func intPow(n, m int64) int64 { + if m == 0 { + return 1 + } + + if m == 1 { + return n + } + + result := n + for i := int64(2); i <= m; i++ { + result *= n + } + return result +} + func newHostMounts(mounts []string) ([]HostMount, error) { var hostMounts []HostMount for _, m := range mounts { diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index 57655289..7cddadcd 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -401,11 +401,15 @@ func deployImage( Mounts: mounts, // https://docs.docker.com/engine/containers/resource_constraints/ Resources: container.Resources{ + // Constrain the the number of CPU cores this container can use + // // The number of CPU cores in 1e9 increments // // `NanoCPUs` is the option that is "Applicable to all platforms" instead of // `CPUPeriod`/`CPUQuota` (Unix only) or `CPUCount`/`CPUPercent` (Windows only). NanoCPUs: int64(cfg.ContainerCPUCores * 1e9), + // Constrain the maximum memory the container can use + Memory: cfg.ContainerMemoryBytes, }, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ @@ -423,7 +427,19 @@ func deployImage( containerID := body.ID if cfg.DebugLoggingEnabled { - log.Printf("%s: Created container '%s' using image '%s' on network '%s'", contextStr, containerID, imageID, networkName) + constraintStrings := []string{} + if cfg.ContainerCPUCores > 0 { + constraintStrings = append(constraintStrings, fmt.Sprintf("%.1f CPU cores", cfg.ContainerCPUCores)) + } + if cfg.ContainerMemoryBytes > 0 { + constraintStrings = append(constraintStrings, fmt.Sprintf("%d bytes of memory", cfg.ContainerMemoryBytes)) + } + constrainedResourcesDisplayString := "" + if len(constraintStrings) > 0 { + constrainedResourcesDisplayString = fmt.Sprintf("(%s)", strings.Join(constraintStrings, ", ")) + } + + log.Printf("%s: Created container '%s' using image '%s' on network '%s' %s", contextStr, containerID, imageID, networkName, constrainedResourcesDisplayString) } stubDeployment := &HomeserverDeployment{ ContainerID: containerID, From 0b341dca32bd54555d8e3ea47cd8294dec6941a4 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 13:40:25 -0600 Subject: [PATCH 06/14] Document source --- config/config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.go b/config/config.go index 4b06d538..7a91fed9 100644 --- a/config/config.go +++ b/config/config.go @@ -326,6 +326,8 @@ func parseByteSizeString(inputString string) (int64, error) { } // intPow calculates n to the mth power. Since the result is an int, it is assumed that m is a positive power +// +// via https://stackoverflow.com/questions/64108933/how-to-use-math-pow-with-integers-in-go/66429580#66429580 func intPow(n, m int64) int64 { if m == 0 { return 1 From 6034e6cfb3766ca73eb9d4fb5505c1cd1af4b46a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 13:41:45 -0600 Subject: [PATCH 07/14] Add future TODO --- internal/docker/deployer.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index 7cddadcd..9843a4ad 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -432,6 +432,7 @@ func deployImage( constraintStrings = append(constraintStrings, fmt.Sprintf("%.1f CPU cores", cfg.ContainerCPUCores)) } if cfg.ContainerMemoryBytes > 0 { + // TODO: It would be nice to pretty print this in MB/GB etc. constraintStrings = append(constraintStrings, fmt.Sprintf("%d bytes of memory", cfg.ContainerMemoryBytes)) } constrainedResourcesDisplayString := "" From 2423d24fe12e168c205a48509288eeb66d625a44 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 13:54:34 -0600 Subject: [PATCH 08/14] Document units --- ENVIRONMENT.md | 2 +- config/config.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 7cc1d7d4..decd2c0b 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -22,7 +22,7 @@ The number of CPU cores available for the container to use (can be fractional li - Default: 0 #### `COMPLEMENT_CONTAINER_MEMORY` -The maximum amount of memory the container can use. This is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment. +The maximum amount of memory the container can use (ex. "1GB"). Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", "PiB") or no units (bytes) (case-insensitive). The number of bytes is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment. - Type: `int64` - Default: 0 diff --git a/config/config.go b/config/config.go index 7a91fed9..f0217fc2 100644 --- a/config/config.go +++ b/config/config.go @@ -62,7 +62,9 @@ type Complement struct { ContainerCPUCores float64 // Name: COMPLEMENT_CONTAINER_MEMORY // Default: 0 - // Description: The maximum amount of memory the container can use. This is passed to + // Description: The maximum amount of memory the container can use (ex. "1GB"). Valid units are + // "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", + // "PiB") or no units (bytes) (case-insensitive). The number of bytes is passed to // Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container // can use all available host memory. This is useful to mimic a resource-constrained // environment, like a CI environment. From 026287321f6960552048bc0647b605e01b0e72de Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 13:58:17 -0600 Subject: [PATCH 09/14] Support bare units to match Docker's CLI --- ENVIRONMENT.md | 2 +- config/config.go | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index decd2c0b..362b744b 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -22,7 +22,7 @@ The number of CPU cores available for the container to use (can be fractional li - Default: 0 #### `COMPLEMENT_CONTAINER_MEMORY` -The maximum amount of memory the container can use (ex. "1GB"). Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", "PiB") or no units (bytes) (case-insensitive). The number of bytes is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment. +The maximum amount of memory the container can use (ex. "1GB"). Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", "PiB") or no units (bytes) (case-insensitive). We also support "K", "M", "G" as per Docker's CLI. The number of bytes is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment. - Type: `int64` - Default: 0 diff --git a/config/config.go b/config/config.go index f0217fc2..10715b89 100644 --- a/config/config.go +++ b/config/config.go @@ -62,12 +62,13 @@ type Complement struct { ContainerCPUCores float64 // Name: COMPLEMENT_CONTAINER_MEMORY // Default: 0 - // Description: The maximum amount of memory the container can use (ex. "1GB"). Valid units are - // "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", - // "PiB") or no units (bytes) (case-insensitive). The number of bytes is passed to - // Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container - // can use all available host memory. This is useful to mimic a resource-constrained - // environment, like a CI environment. + // Description: The maximum amount of memory the container can use (ex. "1GB"). Valid + // units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", + // "TiB", "PiB") or no units (bytes) (case-insensitive). We also support "K", "M", "G" + // as per Docker's CLI. The number of bytes is passed to Docker as the + // `--memory`/`Memory` argument. If 0, no limit is set and the container can use all + // available host memory. This is useful to mimic a resource-constrained environment, + // like a CI environment. ContainerMemoryBytes int64 // Name: COMPLEMENT_KEEP_BLUEPRINTS // Description: A list of space separated blueprint names to not clean up after running. For example, @@ -254,7 +255,8 @@ func parseEnvWithDefault(key string, def int) int { // string does not match one of the valid units or is an invalid integer. // // Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", -// "GiB", "TiB", "PiB") or no units (bytes). +// "GiB", "TiB", "PiB") or no units (bytes). We also support "K", "M", "G" as per +// Docker's CLI. func parseByteSizeString(inputString string) (int64, error) { // Strip spaces and normalize to lowercase normalizedString := strings.TrimSpace(strings.ToLower(inputString)) @@ -273,6 +275,10 @@ func parseByteSizeString(inputString string) (int64, error) { "mib": intPow(1024, 2), "gib": intPow(1024, 3), "tib": intPow(1024, 4), + // These are also supported to match Docker's CLI + "k": 1024, + "m": intPow(1024, 2), + "g": intPow(1024, 3), } availableUnitsSorted := make([]string, 0, len(unitToByteMultiplierMap)) for unit := range unitToByteMultiplierMap { From 5cf419c988aa8231b3884b3770974fde4b407ef1 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 15 Dec 2025 14:10:09 -0600 Subject: [PATCH 10/14] Remove stray generated file --- cmd/gendoc/ENVIRONMENT.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 cmd/gendoc/ENVIRONMENT.md diff --git a/cmd/gendoc/ENVIRONMENT.md b/cmd/gendoc/ENVIRONMENT.md deleted file mode 100644 index e69de29b..00000000 From ea793c371569fc9789ad12034ca562b8901308e3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 24 Dec 2025 10:02:53 -0600 Subject: [PATCH 11/14] Document usage --- cmd/gendoc/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/gendoc/main.go b/cmd/gendoc/main.go index eaa6938a..a55b5c30 100644 --- a/cmd/gendoc/main.go +++ b/cmd/gendoc/main.go @@ -1,3 +1,5 @@ +// Usage: `go run ./cmd/gendoc --config config/config.go > ENVIRONMENT.md` + package main import ( From 2061f06c48f3ccb8e0d3dedb248b7ee68fff06a3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 24 Dec 2025 10:11:53 -0600 Subject: [PATCH 12/14] Account for error while parsing float --- config/config.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 10715b89..c7bb129c 100644 --- a/config/config.go +++ b/config/config.go @@ -163,7 +163,11 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement { // 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.ContainerCPUCores, _ = strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPUS"), 64) + parsedCPUCores, err := strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPUS"), 64) + if err != nil { + panic("COMPLEMENT_CONTAINER_CPUS parse error: " + err.Error()) + } + cfg.ContainerCPUCores = parsedCPUCores parsedMemoryBytes, err := parseByteSizeString(os.Getenv("COMPLEMENT_CONTAINER_MEMORY")) if err != nil { panic("COMPLEMENT_CONTAINER_MEMORY parse error: " + err.Error()) From 75b6f5d83d021e1f65d84589b938467cb8e4451a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 24 Dec 2025 10:14:59 -0600 Subject: [PATCH 13/14] Rename option to `COMPLEMENT_CONTAINER_CPU_CORES` --- ENVIRONMENT.md | 2 +- config/config.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 362b744b..cac8bd5f 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -16,7 +16,7 @@ If 1, always prints the Homeserver container logs even on success. When used wit 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. - Type: `map[string]string` -#### `COMPLEMENT_CONTAINER_CPUS` +#### `COMPLEMENT_CONTAINER_CPU_CORES` The number of CPU cores available for the container to use (can be fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument. If 0, no limit is set and the container can use all available host CPUs. This is useful to mimic a resource-constrained environment, like a CI environment. - Type: `float64` - Default: 0 diff --git a/config/config.go b/config/config.go index c7bb129c..f5eb4c0d 100644 --- a/config/config.go +++ b/config/config.go @@ -53,7 +53,7 @@ type Complement struct { // starting the container. Responsiveness is detected by `HEALTHCHECK` being healthy *and* // the `/versions` endpoint returning 200 OK. SpawnHSTimeout time.Duration - // Name: COMPLEMENT_CONTAINER_CPUS + // Name: COMPLEMENT_CONTAINER_CPU_CORES // Default: 0 // Description: The number of CPU cores available for the container to use (can be // fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument. @@ -163,9 +163,9 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement { // 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 } - parsedCPUCores, err := strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPUS"), 64) + parsedCPUCores, err := strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPU_CORES"), 64) if err != nil { - panic("COMPLEMENT_CONTAINER_CPUS parse error: " + err.Error()) + panic("COMPLEMENT_CONTAINER_CPU_CORES parse error: " + err.Error()) } cfg.ContainerCPUCores = parsedCPUCores parsedMemoryBytes, err := parseByteSizeString(os.Getenv("COMPLEMENT_CONTAINER_MEMORY")) From d8f0ee7d946a776c0bff685e723b216b829d8e75 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 24 Dec 2025 10:30:06 -0600 Subject: [PATCH 14/14] Introduce `parseEnvAsFloatWithDefault` --- config/config.go | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/config/config.go b/config/config.go index f5eb4c0d..9394b37a 100644 --- a/config/config.go +++ b/config/config.go @@ -163,11 +163,7 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement { // 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 } - parsedCPUCores, err := strconv.ParseFloat(os.Getenv("COMPLEMENT_CONTAINER_CPU_CORES"), 64) - if err != nil { - panic("COMPLEMENT_CONTAINER_CPU_CORES parse error: " + err.Error()) - } - cfg.ContainerCPUCores = parsedCPUCores + cfg.ContainerCPUCores = parseEnvAsFloatWithDefault("COMPLEMENT_CONTAINER_CPU_CORES", 0) parsedMemoryBytes, err := parseByteSizeString(os.Getenv("COMPLEMENT_CONTAINER_MEMORY")) if err != nil { panic("COMPLEMENT_CONTAINER_MEMORY parse error: " + err.Error()) @@ -241,17 +237,30 @@ func (c *Complement) CAPrivateKeyBytes() ([]byte, error) { 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 +func parseEnvWithDefault(key string, defaultValue int) int { + inputString := os.Getenv(key) + if inputString == "" { + return defaultValue + } + + parsedNumber, err := strconv.Atoi(inputString) + if err != nil { + panic(key + " parse error: " + err.Error()) + } + return parsedNumber +} + +func parseEnvAsFloatWithDefault(key string, defaultValue float64) float64 { + inputString := os.Getenv(key) + if inputString == "" { + return defaultValue + } + + parsedNumber, err := strconv.ParseFloat(inputString, 64) + if err != nil { + panic(key + " parse error: " + err.Error()) } - return def + return parsedNumber } // parseByteSizeString parses a byte size string (case insensitive) like "512MB"