Skip to content
Open
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
18 changes: 18 additions & 0 deletions lib/build_container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions src/dev/build_container_config.rb
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -44,21 +52,27 @@ class BuildContainerConfig
sig { returns(T::Hash[String, String]) }
attr_reader :run_env

sig { returns(ContainerResources) }
attr_reader :resources

sig do
params(
image: String,
registry: String,
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").
Expand All @@ -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) }
Expand All @@ -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
41 changes: 41 additions & 0 deletions src/dev/command_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion src/dev/config_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/dev/container_resources.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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
56 changes: 56 additions & 0 deletions test/dev/config_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
58 changes: 58 additions & 0 deletions test/dev/container_resources_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading