From da563b8bc697c992877b566b8d713f886b17bf60 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 11 Jun 2026 13:38:07 -0400 Subject: [PATCH] Add build.container.resources with VM under-provisioning preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a project declare the CPU/memory its containerized build expects from the Docker VM. dev preflight-warns (with remediation) when the host VM is smaller, so an under-provisioned VM surfaces immediately instead of silently serializing the build — UBT sizes parallel compile actions off available memory (~1.5 GiB/action), so an 8 GiB VM throttles a ~15min build into hours. These are requirements, not grants: Docker Desktop's single shared VM size is a host-wide setting a project can't raise on its own, so the check is purely advisory (never blocks, skips when docker info is unavailable) and we don't translate them into docker run --memory caps (capping at the requirement leaves no headroom and invites the OOM killer). Co-authored-by: Cursor --- lib/build_container.rb | 18 ++++++ src/dev/build_container_config.rb | 20 ++++++- src/dev/command_runner.rb | 41 ++++++++++++++ src/dev/config_parser.rb | 13 ++++- src/dev/container_resources.rb | 85 ++++++++++++++++++++++++++++ test/dev/config_parser_test.rb | 56 ++++++++++++++++++ test/dev/container_resources_test.rb | 58 +++++++++++++++++++ 7 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 src/dev/container_resources.rb create mode 100644 test/dev/container_resources_test.rb diff --git a/lib/build_container.rb b/lib/build_container.rb index c2bf226..ae28a52 100644 --- a/lib/build_container.rb +++ b/lib/build_container.rb @@ -108,6 +108,24 @@ def docker_run_command(image_tag, project_root:, shell_cmd:, volumes: [], env: { ] end + # CPU/memory the Docker VM currently exposes, for preflight checks. + # + # Returns nil when Docker isn't reachable or the output can't be parsed, so + # callers treat the check as best-effort and never block on it. + # + # @return [Hash{Symbol => Integer}, nil] { cpus:, mem_bytes: } + def host_resources + out = `docker info --format '{{.NCPU}} {{.MemTotal}}' 2>/dev/null`.strip + return nil if out.empty? + + ncpu, mem_bytes = out.split + return nil if ncpu.nil? || mem_bytes.nil? + + { cpus: ncpu.to_i, mem_bytes: mem_bytes.to_i } + rescue StandardError + nil + end + # --- internal helpers ------------------------------------------------ def local_image?(image_tag) diff --git a/src/dev/build_container_config.rb b/src/dev/build_container_config.rb index a88090c..adadaf2 100644 --- a/src/dev/build_container_config.rb +++ b/src/dev/build_container_config.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require_relative "container_resources" + module Dev # Value object for the build.container block in dev.yml. # @@ -12,6 +14,9 @@ module Dev # container: # image: snappy-linux # registry: jpduchesne89 + # resources: + # cpus: 16 + # memory_gb: 24 # volumes: # - "~/.dev/engines/unreal-engine-css:/ue" # build_args: @@ -26,6 +31,9 @@ module Dev # run_env maps docker `run -e` env var names to the same "namespace/key" # references, resolved when a containerized command runs. Use it for # secrets a command needs at runtime (not baked into the image). + # + # resources declares the CPU/memory the build expects from the Docker VM; + # dev preflight-warns when the host is smaller (see ContainerResources). class BuildContainerConfig extend T::Sig @@ -44,6 +52,9 @@ class BuildContainerConfig sig { returns(T::Hash[String, String]) } attr_reader :run_env + sig { returns(ContainerResources) } + attr_reader :resources + sig do params( image: String, @@ -51,14 +62,17 @@ class BuildContainerConfig volumes: T::Array[String], build_args: T::Hash[String, String], run_env: T::Hash[String, String], + resources: ContainerResources, ).void end - def initialize(image:, registry:, volumes: [], build_args: {}, run_env: {}) + def initialize(image:, registry:, volumes: [], build_args: {}, run_env: {}, + resources: ContainerResources.new) @image = T.let(image, String) @registry = T.let(registry, String) @volumes = T.let(volumes, T::Array[String]) @build_args = T.let(build_args, T::Hash[String, String]) @run_env = T.let(run_env, T::Hash[String, String]) + @resources = T.let(resources, ContainerResources) end # Full image reference without tag (e.g. "jpduchesne89/snappy-linux"). @@ -73,7 +87,7 @@ def ==(other) @image == other.image && @registry == other.registry && @volumes == other.volumes && @build_args == other.build_args && - @run_env == other.run_env + @run_env == other.run_env && @resources == other.resources end sig { params(other: Object).returns(T::Boolean) } @@ -83,7 +97,7 @@ def eql?(other) sig { returns(Integer) } def hash - [@image, @registry, @volumes, @build_args, @run_env].hash + [@image, @registry, @volumes, @build_args, @run_env, @resources].hash end end end diff --git a/src/dev/command_runner.rb b/src/dev/command_runner.rb index b0d6ff4..e9f65d5 100644 --- a/src/dev/command_runner.rb +++ b/src/dev/command_runner.rb @@ -69,6 +69,7 @@ def use_container?(cmd) def run_in_container(_cmd, shell_command) require "build_container" config = T.must(@build_container) + warn_if_under_provisioned!(config) image_tag = BuildContainer.ensure_image!( config, project_root: @project_root, @@ -87,6 +88,46 @@ def run_in_container(_cmd, shell_command) Kernel.exec(*docker_argv) end + # Preflight the Docker VM against the project's declared resources. + # + # Docker Desktop's VM size is a host-wide setting a project can't raise on + # its own, so an under-provisioned VM can't be fixed here — but a silent + # 8 GiB VM throttles UBT to a handful of parallel actions and turns a ~15min + # build into hours. We surface that loudly with remediation instead. Purely + # advisory: never blocks, and skips silently when Docker info is unavailable. + # + # @param config [Dev::BuildContainerConfig] + sig { params(config: Dev::BuildContainerConfig).void } + def warn_if_under_provisioned!(config) + resources = config.resources + return if resources.empty? + + host = BuildContainer.host_resources + return if host.nil? + + available_memory_gb = Integer(host.fetch(:mem_bytes)) / (1024 * 1024 * 1024) + shortfalls = resources.shortfalls( + available_cpus: Integer(host.fetch(:cpus)), + available_memory_gb: available_memory_gb, + ) + return if shortfalls.empty? + + print_under_provisioned_warning(shortfalls) + end + + sig { params(shortfalls: T::Array[String]).void } + def print_under_provisioned_warning(shortfalls) + $stderr.puts + $stderr.puts "dev: ⚠ Docker VM is smaller than this project declares:" + shortfalls.each { |line| $stderr.puts "dev: #{line}" } + $stderr.puts "dev: The build will run but parallelism (and speed) suffer." + $stderr.puts "dev: Raise Docker Desktop → Settings → Resources, or set" + $stderr.puts "dev: \"Cpus\" / \"MemoryMiB\" in" + $stderr.puts "dev: ~/Library/Group Containers/group.com.docker/settings-store.json" + $stderr.puts "dev: and restart Docker." + $stderr.puts + end + # Resolve docker build args declared in dev.yml from Dev::Credentials. # Only invoked when the image actually needs building. Normally `dev up` # has already prompted and stored these, so this resolves silently. diff --git a/src/dev/config_parser.rb b/src/dev/config_parser.rb index 3d3a073..62f08e2 100644 --- a/src/dev/config_parser.rb +++ b/src/dev/config_parser.rb @@ -50,9 +50,20 @@ def parse_build_container(yaml) volumes = Array(container["volumes"]).map(&:to_s) build_args = (container["build_args"] || {}).to_h { |k, v| [k.to_s, v.to_s] } run_env = (container["run_env"] || {}).to_h { |k, v| [k.to_s, v.to_s] } + resources = parse_resources(container["resources"]) BuildContainerConfig.new( image: image, registry: registry, volumes: volumes, - build_args: build_args, run_env: run_env, + build_args: build_args, run_env: run_env, resources: resources, + ) + end + + sig { params(raw: T.untyped).returns(ContainerResources) } + def parse_resources(raw) + return ContainerResources.new unless raw.is_a?(Hash) + + ContainerResources.new( + cpus: raw["cpus"]&.to_i, + memory_gb: raw["memory_gb"]&.to_i, ) end end diff --git a/src/dev/container_resources.rb b/src/dev/container_resources.rb new file mode 100644 index 0000000..d2433a4 --- /dev/null +++ b/src/dev/container_resources.rb @@ -0,0 +1,85 @@ +# typed: strict +# frozen_string_literal: true + +module Dev + # Declared CPU/memory the build container expects from the Docker VM. + # + # These are *requirements*, not grants. Docker Desktop runs a single shared + # Linux VM whose size is a host-wide setting, so a project cannot give itself + # more cores or memory than the VM has. dev uses these values only for a + # preflight check: when the VM is smaller than declared it warns loudly with + # remediation, so an under-provisioned host surfaces immediately instead of + # silently serializing the build (UBT sizes its parallelism off available + # memory, ~1.5 GiB per compile action). + # + # We deliberately do NOT translate these into `docker run --memory` caps: the + # declared memory is a floor the build needs, and capping a container at its + # requirement leaves no headroom and invites the OOM killer. + # + # dev.yml: + # build: + # container: + # resources: + # cpus: 16 + # memory_gb: 24 + class ContainerResources + extend T::Sig + + sig { returns(T.nilable(Integer)) } + attr_reader :cpus + + sig { returns(T.nilable(Integer)) } + attr_reader :memory_gb + + sig { params(cpus: T.nilable(Integer), memory_gb: T.nilable(Integer)).void } + def initialize(cpus: nil, memory_gb: nil) + @cpus = T.let(cpus, T.nilable(Integer)) + @memory_gb = T.let(memory_gb, T.nilable(Integer)) + end + + sig { returns(T::Boolean) } + def empty? + @cpus.nil? && @memory_gb.nil? + end + + # Human-readable shortfalls when the host VM is smaller than declared. + # Pure by design so the preflight logic is testable without Docker. + # + # @param available_cpus [Integer] cores the Docker VM exposes + # @param available_memory_gb [Integer] GiB the Docker VM exposes + # @return [Array] one message per dimension that falls short (empty if ok) + sig { params(available_cpus: Integer, available_memory_gb: Integer).returns(T::Array[String]) } + def shortfalls(available_cpus:, available_memory_gb:) + messages = [] + + required_cpus = @cpus + if required_cpus && available_cpus < required_cpus + messages << "CPUs: #{available_cpus} available < #{required_cpus} declared" + end + + required_memory_gb = @memory_gb + if required_memory_gb && available_memory_gb < required_memory_gb + messages << "Memory: #{available_memory_gb} GiB available < #{required_memory_gb} GiB declared" + end + + messages + end + + sig { params(other: Object).returns(T::Boolean) } + def ==(other) + return false unless other.is_a?(ContainerResources) + + @cpus == other.cpus && @memory_gb == other.memory_gb + end + + sig { params(other: Object).returns(T::Boolean) } + def eql?(other) + self == other + end + + sig { returns(Integer) } + def hash + [@cpus, @memory_gb].hash + end + end +end diff --git a/test/dev/config_parser_test.rb b/test/dev/config_parser_test.rb index 7ad74d4..c09add4 100644 --- a/test/dev/config_parser_test.rb +++ b/test/dev/config_parser_test.rb @@ -224,6 +224,62 @@ class ConfigParserTest < Minitest::Test tmp.close! end + test "#parse extracts build.container resources" do + Given "a dev.yml file with container resources" + tmp = Tempfile.new(["dev", ".yml"]) + tmp.write(<<~YAML) + name: snappy + build: + container: + image: snappy-linux + registry: jpduchesne89 + resources: + cpus: 16 + memory_gb: 24 + commands: + build: + run: ./bin/build.sh + YAML + tmp.flush + + When "the config is parsed" + parser = Dev::ConfigParser.new(command_parser: Dev::CommandParser.new) + config = parser.parse(Pathname.new(tmp.path)) + + Then + config.build_container.resources.cpus == 16 + config.build_container.resources.memory_gb == 24 + + Cleanup + tmp.close! + end + + test "#parse defaults build.container resources to empty when absent" do + Given "a dev.yml file without container resources" + tmp = Tempfile.new(["dev", ".yml"]) + tmp.write(<<~YAML) + name: snappy + build: + container: + image: snappy-linux + registry: jpduchesne89 + commands: + build: + run: ./bin/build.sh + YAML + tmp.flush + + When "the config is parsed" + parser = Dev::ConfigParser.new(command_parser: Dev::CommandParser.new) + config = parser.parse(Pathname.new(tmp.path)) + + Then + config.build_container.resources.empty? + + Cleanup + tmp.close! + end + test "#parse defaults build.container volumes to empty" do Given "a dev.yml file without container volumes" tmp = Tempfile.new(["dev", ".yml"]) diff --git a/test/dev/container_resources_test.rb b/test/dev/container_resources_test.rb new file mode 100644 index 0000000..c1fba9a --- /dev/null +++ b/test/dev/container_resources_test.rb @@ -0,0 +1,58 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/container_resources" + +transform!(RSpock::AST::Transformation) +class Dev::ContainerResourcesTest < Minitest::Test + test "empty? is true only when neither dimension is declared" do + Expect + Dev::ContainerResources.new.empty? + !Dev::ContainerResources.new(cpus: cpus, memory_gb: memory_gb).empty? + + Where + cpus | memory_gb + 16 | nil + nil | 24 + 16 | 24 + end + + test "shortfalls reports each dimension the host falls short on" do + Given "a resource declaration" + resources = Dev::ContainerResources.new(cpus: 16, memory_gb: 24) + + Expect "shortfalls match what the host lacks" + resources.shortfalls(available_cpus: available_cpus, available_memory_gb: available_memory_gb).size == count + + Where + available_cpus | available_memory_gb | count + 16 | 24 | 0 + 16 | 8 | 1 + 4 | 24 | 1 + 4 | 8 | 2 + end + + test "shortfalls ignores undeclared dimensions" do + Given "only memory is declared" + resources = Dev::ContainerResources.new(memory_gb: 24) + + When "the host has plenty of memory but few cores" + shortfalls = resources.shortfalls(available_cpus: 1, available_memory_gb: 32) + + Then "the undeclared cpu dimension is not reported" + shortfalls == [] + end + + test "equality and hash compare both dimensions" do + Given "two identical declarations" + a = Dev::ContainerResources.new(cpus: 16, memory_gb: 24) + b = Dev::ContainerResources.new(cpus: 16, memory_gb: 24) + + Expect + a == b + a.eql?(b) + a.hash == b.hash + a != Dev::ContainerResources.new(cpus: 8, memory_gb: 24) + end +end