diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 3559a64..05cd255 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -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 { diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 11a0a51..ad724a2 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -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) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 170b79b..b52a62b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -9,6 +9,7 @@ import ( "io" "os/exec" goruntime "runtime" + "strconv" "strings" "sync" "time" @@ -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) @@ -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 { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 1fbce0e..ee294eb 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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) + } + } +} diff --git a/services/_schema.yml b/services/_schema.yml index 91b3eb6..e4deb3a 100644 --- a/services/_schema.yml +++ b/services/_schema.yml @@ -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: diff --git a/services/bandwidth/bitping.yml b/services/bandwidth/bitping.yml index d450070..c9d91da 100644 --- a/services/bandwidth/bitping.yml +++ b/services/bandwidth/bitping.yml @@ -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. diff --git a/services/bandwidth/earnapp.yml b/services/bandwidth/earnapp.yml index 630d54d..641441e 100644 --- a/services/bandwidth/earnapp.yml +++ b/services/bandwidth/earnapp.yml @@ -34,6 +34,9 @@ docker: command: "" network_mode: "" privileged: false + resources: + mem_limit: "256m" + oom_score_adj: 300 requirements: residential_ip: true diff --git a/services/bandwidth/earnfm.yml b/services/bandwidth/earnfm.yml index 544456a..657c3e6 100644 --- a/services/bandwidth/earnfm.yml +++ b/services/bandwidth/earnfm.yml @@ -26,6 +26,9 @@ docker: command: "" network_mode: "" privileged: false + resources: + mem_limit: "128m" + oom_score_adj: 200 requirements: residential_ip: true diff --git a/services/bandwidth/honeygain.yml b/services/bandwidth/honeygain.yml index 3ea7da5..9757d01 100644 --- a/services/bandwidth/honeygain.yml +++ b/services/bandwidth/honeygain.yml @@ -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 diff --git a/services/bandwidth/iproyal.yml b/services/bandwidth/iproyal.yml index 03452ce..f0ffd19 100644 --- a/services/bandwidth/iproyal.yml +++ b/services/bandwidth/iproyal.yml @@ -45,6 +45,9 @@ docker: command: "-email ${IPROYALPAWNS_EMAIL} -password ${IPROYALPAWNS_PASSWORD} -device-name ${IPROYALPAWNS_DEVICE_NAME} -device-id ${IPROYALPAWNS_DEVICE_ID} -accept-tos" network_mode: "" privileged: false + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: true diff --git a/services/bandwidth/mysterium.yml b/services/bandwidth/mysterium.yml index bdb27d3..fb71bb8 100644 --- a/services/bandwidth/mysterium.yml +++ b/services/bandwidth/mysterium.yml @@ -26,6 +26,9 @@ docker: network_mode: "host" cap_add: [NET_ADMIN] privileged: false + resources: + mem_limit: "768m" + oom_score_adj: -100 requirements: residential_ip: false diff --git a/services/bandwidth/packetstream.yml b/services/bandwidth/packetstream.yml index c74c0ea..d045cf8 100644 --- a/services/bandwidth/packetstream.yml +++ b/services/bandwidth/packetstream.yml @@ -28,6 +28,9 @@ docker: command: "" network_mode: "" privileged: false + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: true diff --git a/services/bandwidth/proxybase.yml b/services/bandwidth/proxybase.yml index 42bd5f3..e98b35b 100644 --- a/services/bandwidth/proxybase.yml +++ b/services/bandwidth/proxybase.yml @@ -31,6 +31,9 @@ docker: volumes: [] command: "" network_mode: "" + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: true diff --git a/services/bandwidth/proxylite.yml b/services/bandwidth/proxylite.yml index e3e1680..2cb6482 100644 --- a/services/bandwidth/proxylite.yml +++ b/services/bandwidth/proxylite.yml @@ -26,6 +26,9 @@ docker: volumes: [] command: "" network_mode: "" + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: false diff --git a/services/bandwidth/proxyrack.yml b/services/bandwidth/proxyrack.yml index b062707..ea9f6ec 100644 --- a/services/bandwidth/proxyrack.yml +++ b/services/bandwidth/proxyrack.yml @@ -39,6 +39,9 @@ docker: command: "" network_mode: "" privileged: false + resources: + mem_limit: "256m" + oom_score_adj: 300 requirements: residential_ip: false diff --git a/services/bandwidth/repocket.yml b/services/bandwidth/repocket.yml index d4a458c..b268bc7 100644 --- a/services/bandwidth/repocket.yml +++ b/services/bandwidth/repocket.yml @@ -32,6 +32,9 @@ docker: command: "" network_mode: "" privileged: false + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: true diff --git a/services/bandwidth/traffmonetizer.yml b/services/bandwidth/traffmonetizer.yml index fd50f10..ff22bde 100644 --- a/services/bandwidth/traffmonetizer.yml +++ b/services/bandwidth/traffmonetizer.yml @@ -34,6 +34,9 @@ docker: command: "start accept --token ${TRAFFMONETIZER_TOKEN} --device-name ${TRAFFMONETIZER_DEVICE_NAME}" network_mode: "" privileged: false + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: false diff --git a/services/bandwidth/urnetwork.yml b/services/bandwidth/urnetwork.yml index 969ad92..b1ae8c3 100644 --- a/services/bandwidth/urnetwork.yml +++ b/services/bandwidth/urnetwork.yml @@ -27,6 +27,9 @@ docker: - "urnetwork-data:/root/.urnetwork" command: "provide" network_mode: "" + resources: + mem_limit: "128m" + oom_score_adj: 300 requirements: residential_ip: false diff --git a/services/depin/anyone-protocol.yml b/services/depin/anyone-protocol.yml index a644501..7d48e65 100644 --- a/services/depin/anyone-protocol.yml +++ b/services/depin/anyone-protocol.yml @@ -26,6 +26,9 @@ docker: - "anon-run:/run/anon" command: "" network_mode: "" + resources: + mem_limit: "1536m" + oom_score_adj: -100 notes: > Requires an anonrc config file mounted at /etc/anon/anonrc with AgreeToTerms 1. The config dir must be writable (chown 100:101) for notices.log. diff --git a/services/storage/storj.yml b/services/storage/storj.yml index 1fc2a2f..1f88d38 100644 --- a/services/storage/storj.yml +++ b/services/storage/storj.yml @@ -57,6 +57,9 @@ docker: command: "--operator.wallet-features=zksync-era" network_mode: "" stop_timeout: 300 + resources: + mem_limit: "2g" + oom_score_adj: -100 setup: > Before first run, initialize the storage node config: docker run --rm -e SETUP="true"