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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,32 @@ type Referral struct {
}

type DockerConfig struct {
Image string `json:"image" yaml:"image"`
Platforms []string `json:"platforms" yaml:"platforms"`
Env []EnvVar `json:"env" yaml:"env"`
Ports []string `json:"ports" yaml:"ports"`
Volumes []string `json:"volumes" yaml:"volumes"`
Command string `json:"command" yaml:"command"`
NetworkMode string `json:"networkMode" yaml:"network_mode"`
CapAdd []string `json:"capAdd" yaml:"cap_add"`
Privileged bool `json:"privileged" yaml:"privileged"`
StopTimeout int `json:"stopTimeout" yaml:"stop_timeout"`
Setup string `json:"setup" yaml:"setup"`
Notes string `json:"notes" yaml:"notes"`
Image string `json:"image" yaml:"image"`
Platforms []string `json:"platforms" yaml:"platforms"`
Env []EnvVar `json:"env" yaml:"env"`
Ports []string `json:"ports" yaml:"ports"`
Volumes []string `json:"volumes" yaml:"volumes"`
Command string `json:"command" yaml:"command"`
NetworkMode string `json:"networkMode" yaml:"network_mode"`
CapAdd []string `json:"capAdd" yaml:"cap_add"`
Privileged bool `json:"privileged" yaml:"privileged"`
StopTimeout int `json:"stopTimeout" yaml:"stop_timeout"`
Resources ResourceLimits `json:"resources" yaml:"resources"`
Setup string `json:"setup" yaml:"setup"`
Notes string `json:"notes" yaml:"notes"`
}

// ResourceLimits is the optional docker.resources block from a service YAML. Its
// fields map to Docker HostConfig knobs applied at container creation (see
// internal/runtime.applyResourceLimits) so a service's memory ceiling and OOM
// priority survive restarts instead of being set out-of-band. MemLimit and
// MemReservation are Docker-style size strings ("768m", "2g"); an empty string
// leaves that limit unset. OomScoreAdj is a pointer so an absent value is
// distinguishable from an explicit 0 and is applied only when present.
type ResourceLimits struct {
MemLimit string `json:"memLimit" yaml:"mem_limit"`
MemReservation string `json:"memReservation" yaml:"mem_reservation"`
OomScoreAdj *int `json:"oomScoreAdj" yaml:"oom_score_adj"`
}

type EnvVar struct {
Expand Down
61 changes: 61 additions & 0 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,64 @@ docker:
t.Fatal("docker-backed service should not be manual-only")
}
}

// TestLoadEmbeddedParsesDockerResources verifies the optional docker.resources
// block maps into DockerConfig.Resources, and that a service without the block
// leaves the fields at their zero values (empty strings and a nil OomScoreAdj, so
// an absent OOM score is distinguishable from an explicit 0).
func TestLoadEmbeddedParsesDockerResources(t *testing.T) {
fsys := fstest.MapFS{
"services/bandwidth/limited.yml": {
Data: []byte(`
name: Limited
slug: limited
category: bandwidth
status: active
docker:
image: example/limited
resources:
mem_limit: "256m"
mem_reservation: "128m"
oom_score_adj: -100
`),
},
"services/bandwidth/plain.yml": {
Data: []byte(`
name: Plain
slug: plain
category: bandwidth
status: active
docker:
image: example/plain
`),
},
}

cat, err := LoadEmbedded(fsys)
if err != nil {
t.Fatalf("LoadEmbedded returned error: %v", err)
}

limited, ok := cat.Get("limited")
if !ok {
t.Fatal("expected limited service")
}
res := limited.Docker.Resources
if res.MemLimit != "256m" {
t.Fatalf("MemLimit = %q, want %q", res.MemLimit, "256m")
}
if res.MemReservation != "128m" {
t.Fatalf("MemReservation = %q, want %q", res.MemReservation, "128m")
}
if res.OomScoreAdj == nil || *res.OomScoreAdj != -100 {
t.Fatalf("OomScoreAdj = %v, want -100", res.OomScoreAdj)
}

plain, ok := cat.Get("plain")
if !ok {
t.Fatal("expected plain service")
}
if pr := plain.Docker.Resources; pr.MemLimit != "" || pr.MemReservation != "" || pr.OomScoreAdj != nil {
t.Fatalf("expected empty resources for a service without the block, got %+v", pr)
}
}
75 changes: 75 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"os/exec"
goruntime "runtime"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -172,6 +173,9 @@ func (p *DockerProvider) Deploy(ctx context.Context, spec DeploySpec, progress f
if svc.Docker.NetworkMode == "" {
hostConfig.NetworkMode = "bridge"
}
if err := applyResourceLimits(hostConfig, svc.Docker.Resources); err != nil {
return ContainerInfo{}, err
}

if progress != nil {
progress("Creating " + name)
Expand Down Expand Up @@ -570,6 +574,77 @@ func buildMounts(raw []string, env map[string]string) []mount.Mount {
return mounts
}

// applyResourceLimits sets the optional memory and OOM-priority knobs from a
// service's docker.resources block onto the container HostConfig. Each limit is
// applied only when present in the YAML: an empty MemLimit/MemReservation leaves
// Docker's default (unlimited) in place, and a nil OomScoreAdj leaves the daemon
// default. Memory strings use Docker's binary units ("768m" = 768 MiB, "2g" =
// 2 GiB), matching `docker run --memory` / compose mem_limit. memory-swap is left
// unset (0) on purpose so Docker derives it from Memory rather than pinning swap.
// It returns an error for a malformed size string so a bad service definition
// fails fast at deploy instead of silently running unbounded.
func applyResourceLimits(hostConfig *container.HostConfig, res catalog.ResourceLimits) error {
if res.MemLimit != "" {
bytes, err := parseMemoryBytes(res.MemLimit)
if err != nil {
return fmt.Errorf("invalid mem_limit %q: %w", res.MemLimit, err)
}
hostConfig.Memory = bytes
}
if res.MemReservation != "" {
bytes, err := parseMemoryBytes(res.MemReservation)
if err != nil {
return fmt.Errorf("invalid mem_reservation %q: %w", res.MemReservation, err)
}
hostConfig.MemoryReservation = bytes
}
if res.OomScoreAdj != nil {
hostConfig.OomScoreAdj = *res.OomScoreAdj
}
return nil
}

// parseMemoryBytes converts a Docker-style memory size string into a byte count
// using binary units, matching `docker run --memory` and compose mem_limit: a bare
// number is bytes, and a k/m/g/t suffix (case-insensitive, with an optional
// trailing "b", e.g. "768m" or "2gb") multiplies by 1024, 1024^2, 1024^3 or
// 1024^4. So "768m" is 768*1024*1024 = 805306368 bytes and "2g" is 2147483648. A
// fractional mantissa ("1.5g") is allowed and truncated toward zero. It returns an
// error for an empty string, a non-positive value, or an unparseable number.
func parseMemoryBytes(s string) (int64, error) {
raw := strings.ToLower(strings.TrimSpace(s))
if raw == "" {
return 0, errors.New("empty memory value")
}
// An explicit trailing "b" ("768mb") is treated the same as the bare unit.
raw = strings.TrimSuffix(raw, "b")
if raw == "" {
return 0, fmt.Errorf("%q has no numeric value", s)
}
var multiplier int64 = 1
switch raw[len(raw)-1] {
case 'k':
multiplier = 1 << 10
case 'm':
multiplier = 1 << 20
case 'g':
multiplier = 1 << 30
case 't':
multiplier = 1 << 40
}
if multiplier != 1 {
raw = raw[:len(raw)-1]
}
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return 0, fmt.Errorf("%q is not a valid size: %w", s, err)
}
if value <= 0 {
return 0, fmt.Errorf("%q must be a positive size", s)
}
return int64(value * float64(multiplier)), nil
}

func managedContainerVolumes(ctx context.Context, cli *client.Client, name string) ([]string, error) {
inspect, err := cli.ContainerInspect(ctx, name)
if err != nil {
Expand Down
129 changes: 129 additions & 0 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,132 @@ func TestDockerStatsIntegrationReportsLiveCPU(t *testing.T) {
t.Fatalf("expected memory > 0 MB for a running container, got %v", mem)
}
}

// oomPtr returns a pointer to i for building catalog.ResourceLimits.OomScoreAdj
// (a *int, so an absent value is distinguishable from an explicit 0) in tests.
func oomPtr(i int) *int { return &i }

// TestParseMemoryBytes pins the Docker-style binary-unit parsing used for the
// docker.resources mem_limit/mem_reservation strings: k/m/g/t are 1024-based (so
// "768m" is 768 MiB, not 768 MB), an optional trailing "b", surrounding
// whitespace, mixed case and a fractional mantissa are accepted, and
// empty/non-positive/garbage values are rejected so a bad service definition fails
// fast instead of deploying an earner unbounded.
func TestParseMemoryBytes(t *testing.T) {
cases := []struct {
in string
want int64
wantErr bool
}{
{in: "768m", want: 768 * 1024 * 1024}, // mysterium
{in: "2g", want: 2 * 1024 * 1024 * 1024}, // storj
{in: "1536m", want: 1536 * 1024 * 1024}, // anyone-protocol
{in: "256m", want: 256 * 1024 * 1024}, // honeygain/earnapp/proxyrack
{in: "128m", want: 128 * 1024 * 1024}, // expendable earners
{in: "512k", want: 512 * 1024}, // kibibytes
{in: "1024", want: 1024}, // bare byte count, no suffix
{in: "2gb", want: 2 * 1024 * 1024 * 1024}, // explicit trailing "b"
{in: "768M", want: 768 * 1024 * 1024}, // case-insensitive suffix
{in: " 256m ", want: 256 * 1024 * 1024}, // surrounding whitespace
{in: "1.5g", want: 1536 * 1024 * 1024}, // fractional mantissa
{in: "", wantErr: true}, // empty
{in: "b", wantErr: true}, // unit only, no number
{in: "m", wantErr: true}, // unit only, no number
{in: "abc", wantErr: true}, // not a number
{in: "0m", wantErr: true}, // non-positive
{in: "-5m", wantErr: true}, // negative
}
for _, tc := range cases {
got, err := parseMemoryBytes(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("parseMemoryBytes(%q) = %d, want error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("parseMemoryBytes(%q) unexpected error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("parseMemoryBytes(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}

// TestApplyResourceLimitsSetsHostConfig verifies a protected service's
// docker.resources block lands on the container HostConfig: the hard memory
// ceiling is parsed to bytes, the negative OOM score is applied, and memory-swap
// is deliberately left unset (0) so Docker derives it from Memory instead of
// pinning swap.
func TestApplyResourceLimitsSetsHostConfig(t *testing.T) {
hc := &container.HostConfig{}
res := catalog.ResourceLimits{MemLimit: "2g", OomScoreAdj: oomPtr(-100)}
if err := applyResourceLimits(hc, res); err != nil {
t.Fatalf("applyResourceLimits returned error: %v", err)
}
if want := int64(2 * 1024 * 1024 * 1024); hc.Memory != want {
t.Fatalf("Memory = %d, want %d", hc.Memory, want)
}
if hc.OomScoreAdj != -100 {
t.Fatalf("OomScoreAdj = %d, want -100", hc.OomScoreAdj)
}
if hc.MemorySwap != 0 {
t.Fatalf("MemorySwap = %d, want 0 (must be left unset)", hc.MemorySwap)
}
if hc.MemoryReservation != 0 {
t.Fatalf("MemoryReservation = %d, want 0 (not specified)", hc.MemoryReservation)
}
}

// TestApplyResourceLimitsSetsReservation covers the optional soft limit: when
// mem_reservation is present it is parsed and set alongside the hard mem_limit.
func TestApplyResourceLimitsSetsReservation(t *testing.T) {
hc := &container.HostConfig{}
res := catalog.ResourceLimits{MemLimit: "768m", MemReservation: "256m", OomScoreAdj: oomPtr(200)}
if err := applyResourceLimits(hc, res); err != nil {
t.Fatalf("applyResourceLimits returned error: %v", err)
}
if want := int64(768 * 1024 * 1024); hc.Memory != want {
t.Fatalf("Memory = %d, want %d", hc.Memory, want)
}
if want := int64(256 * 1024 * 1024); hc.MemoryReservation != want {
t.Fatalf("MemoryReservation = %d, want %d", hc.MemoryReservation, want)
}
if hc.OomScoreAdj != 200 {
t.Fatalf("OomScoreAdj = %d, want 200", hc.OomScoreAdj)
}
}

// TestApplyResourceLimitsAbsentLeavesDefaults verifies an empty docker.resources
// block touches nothing: memory stays unlimited (0) and OomScoreAdj stays at the
// daemon default (0). A nil OomScoreAdj must be left alone rather than written as
// an explicit 0.
func TestApplyResourceLimitsAbsentLeavesDefaults(t *testing.T) {
hc := &container.HostConfig{}
if err := applyResourceLimits(hc, catalog.ResourceLimits{}); err != nil {
t.Fatalf("applyResourceLimits returned error: %v", err)
}
if hc.Memory != 0 || hc.MemoryReservation != 0 || hc.MemorySwap != 0 || hc.OomScoreAdj != 0 {
t.Fatalf("expected all limits unset, got Memory=%d MemoryReservation=%d MemorySwap=%d OomScoreAdj=%d",
hc.Memory, hc.MemoryReservation, hc.MemorySwap, hc.OomScoreAdj)
}
}

// TestApplyResourceLimitsRejectsBadValue makes a malformed size fail the deploy
// (rather than silently running the earner unbounded) and leaves HostConfig
// unmodified.
func TestApplyResourceLimitsRejectsBadValue(t *testing.T) {
for _, res := range []catalog.ResourceLimits{
{MemLimit: "notasize"},
{MemReservation: "12x"},
} {
hc := &container.HostConfig{}
if err := applyResourceLimits(hc, res); err == nil {
t.Errorf("applyResourceLimits(%+v) = nil error, want error", res)
}
if hc.Memory != 0 || hc.MemoryReservation != 0 {
t.Errorf("HostConfig mutated on error: %+v", hc)
}
}
}
5 changes: 5 additions & 0 deletions services/_schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
# network_mode: "" (optional, e.g. "host")
# cap_add: [] (optional)
# stop_timeout: 30 (optional, seconds to wait before SIGKILL on stop — default 30)
# resources: (optional, per-container limits applied at creation on HostConfig)
# mem_limit: "768m" (hard memory ceiling; Docker size string like "256m"/"2g". Omit = unlimited)
# mem_reservation: "256m" (optional soft memory limit; Docker size string)
# oom_score_adj: -100 (optional OOM-killer priority, -1000..1000; negative protects the
# container, positive makes the kernel kill it first under memory pressure)
# setup: "" (optional, one-time setup instructions/commands to run before first deploy)

# requirements:
Expand Down
3 changes: 3 additions & 0 deletions services/bandwidth/bitping.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ docker:
command: ""
network_mode: ""
privileged: false
resources:
mem_limit: "128m"
oom_score_adj: 200
notes: >
Initial authentication is interactive via the container's web UI.
Credentials are then persisted in the /root/.bitping volume mount.
Expand Down
3 changes: 3 additions & 0 deletions services/bandwidth/earnapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ docker:
command: ""
network_mode: ""
privileged: false
resources:
mem_limit: "256m"
oom_score_adj: 300

requirements:
residential_ip: true
Expand Down
3 changes: 3 additions & 0 deletions services/bandwidth/earnfm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ docker:
command: ""
network_mode: ""
privileged: false
resources:
mem_limit: "128m"
oom_score_adj: 200

requirements:
residential_ip: true
Expand Down
3 changes: 3 additions & 0 deletions services/bandwidth/honeygain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ docker:
command: "-tou-accept -email ${HONEYGAIN_EMAIL} -pass ${HONEYGAIN_PASSWORD} -device ${HONEYGAIN_DEVICE_NAME}"
network_mode: ""
privileged: false
resources:
mem_limit: "256m"
oom_score_adj: 200

requirements:
residential_ip: true
Expand Down
Loading
Loading