diff --git a/README.md b/README.md index b80bff5..56ca8a9 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Missing files are empty layers; a key set in the user file wins over the system plans_repo: d3mlabs/plans # org-wide plans repo (dev plan --org) knowledge_repo: d3mlabs/knowledge # org learnings sync source deployment_formula: d3mlabs/d3mlabs/dev # the formula `dev up` self-updates (the deployment names itself) -container_engine: colima # per-user container engine record ("docker" or "colima"; unset = bare docker) +container_engine: docker # per-user opt-out from the host's engine ("docker" = bare dockerd; unset = colima on macOS, bare dockerd elsewhere) ``` Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). Manage the user file with `dev config` instead of hand-editing YAML: `list` shows every known key with its resolved value and source layer (`env` / `user` / `system` / unset) — the settings debugging tool; `get ` prints the resolved value (exit 1 when unset); `set ` writes the user file, creating it if missing. Known keys only; global, works without a `dev.yml`. The tool ships as two kinds of formula (the Debian core-package/config-package split, applied to a tap): @@ -473,9 +473,17 @@ For repos that declare a `build.container`, dev builds and runs commands inside ### The container engine (per-user) -*Which daemon serves a build* is a per-user provisioning decision, not a repo-shape detail: every docker invocation rides a resolved `Dev::ContainerEngine` (argv prefix + env + capabilities). Resolution is config-first, per invoking user: an explicit `DOCKER_HOST` in the environment wins; otherwise the user's `container_engine` settings record (`docker` or `colima`); otherwise bare docker. The human rides Docker Desktop; a no-GUI account (the agent user) records `colima` and gets `DOCKER_HOST` pointed at its **own** `~/.colima/default/docker.sock` — nothing crosses the sudo boundary, and both engines coexist on one machine. The engine's one capability flag, `local_mounts?`, names the single remote-poisoned assumption (bind-mounting local paths); both shipped engines answer true, and a future remote engine joins as config with its own sync strategy rather than an architecture fork. +*Which daemon serves a build* is a per-user provisioning decision, not a repo-shape detail: every docker invocation rides a resolved `Dev::ContainerEngine` (argv prefix + env + capabilities). There is **one supported engine per host OS**, and dev owns its lifecycle end to end — that is what lets `dev up` leave a machine where `docker build` just works, for a human and for the no-GUI agent account alike: -Provisioning is engine-shaped: `DockerDesktopProvisioner` is verify-only (`docker info` — dev never starts the GUI app), while `ColimaProvisioner` idempotently starts the user's VM (`colima start --vm-type vz --vz-rosetta`, so amd64 build images run on Apple silicon), sized from the repo's optional `build.container.resources` hint (`cpus`, `memory_gib`; defaults 4 / 8 GiB — colima applies sizing at VM creation). +| Host | Engine | What `dev up` does | +|---|---|---| +| macOS | **colima** (per-user VM, `vz` + Rosetta so amd64 build images run on Apple silicon) | registers brew's `docker-buildx` with the brew `docker` CLI (`cliPluginsExtraDirs` in `~/.docker/config.json`, merged, never clobbered); starts the VM if it isn't running, sized from the repo's `build.container.resources` hint (`cpus`, `memory_gib`; defaults 4 / 8 GiB — colima applies sizing at VM creation) | +| Linux | **bare dockerd** (the distro's docker packages, a system service) | nothing — there is no VM to own | +| Windows | **bare dockerd inside the WSL2 distro** dev runs in | nothing — same as Linux; the distro's `docker-ce` + `docker-buildx-plugin`, enabled under systemd | + +Resolution is per invoking user: an explicit `DOCKER_HOST` in the environment wins and is left entirely alone (your engine, your problem); otherwise the `container_engine` settings record; otherwise the host OS's engine above. The only record worth writing is `docker` — the opt-out to bare docker with no env, reaching whatever daemon the CLI's own context does. That is where a Docker Desktop user lands: **unsupported but not blocked**. Two colima users on one Mac (a human and the agent account) each get `DOCKER_HOST` pointed at their **own** `~/.colima/default/docker.sock` — nothing crosses the sudo boundary. The engine's one capability flag, `local_mounts?`, names the single remote-poisoned assumption (bind-mounting local paths); every local engine answers true, and a future remote engine joins as config with its own sync strategy rather than an architecture fork. + +**Migrating a Mac off Docker Desktop.** Quit Docker Desktop (and stop it launching at login); `brew upgrade d3mlabs/d3mlabs/dev` brings `colima`, `docker` and `docker-buildx` in as formula dependencies; `dev up` in a containerized repo wires the CLI and starts the VM. Images are re-pulled/rebuilt once into the new engine's store, and a `persist: true` warm container is recreated on first use. Uninstall Desktop whenever you like — dev never touches it. An agent host ends up with two colima VMs (the human's and the agent's), each sized from the repo hint; stopping an idle one is engine-lifecycle work tracked in #187. ### Content-addressed image tag diff --git a/lib/dev/agent_bootstrap.rb b/lib/dev/agent_bootstrap.rb index 1a5390f..e3d557e 100644 --- a/lib/dev/agent_bootstrap.rb +++ b/lib/dev/agent_bootstrap.rb @@ -191,8 +191,8 @@ def converge! # Step 6, invoked by the label contract only when a served repo declares # `build.container` (the only place local-vs-remote is expressed): - # converge the agent's own engine — colima installed (Docker Desktop - # cannot serve a no-GUI user), the agent's `container_engine: colima` + # converge the agent's own engine — colima installed (the macOS engine, + # per-user by nature), the agent's `container_engine: colima` # record written into its own config (resolution never crosses the sudo # boundary), and its VM provisioned, sized from the repo's resources # hint. Every colima invocation crosses to the agent via sudo. diff --git a/lib/dev/build_container.rb b/lib/dev/build_container.rb index a517665..50ac6c5 100644 --- a/lib/dev/build_container.rb +++ b/lib/dev/build_container.rb @@ -356,7 +356,7 @@ def ensure_image!(config, project_root:, push: true, publish: false, # build-context? BuildKit *streams* a build-context from the client on demand; # for a large, randomly-read dependency (e.g. a ~30GB engine read during # compilation) that transport stalls/deadlocks, especially under emulation. A - # plain `-v` volume (virtiofs on Docker Desktop) is the robust path the runtime + # plain `-v` volume (virtiofs on colima) is the robust path the runtime # already uses, so the prewarm reuses it. # # @param tag [String] final content-addressed tag to commit diff --git a/lib/dev/colima_provisioner.rb b/lib/dev/colima_provisioner.rb index f4dc7c7..f35aa8e 100644 --- a/lib/dev/colima_provisioner.rb +++ b/lib/dev/colima_provisioner.rb @@ -5,10 +5,11 @@ module Dev # Per-user provisioning for the colima engine: idempotently ensure the - # invoking user's own colima VM is running. This is what gives a no-GUI - # user (the agent account) a docker daemon of its own — Docker Desktop - # cannot serve it. The VM is vz-virtualized with Rosetta so amd64 build - # images (e.g. the linux cross-compile image) run on Apple silicon. + # invoking user's own colima VM is running. colima is the one macOS + # engine: it serves a human's `dev up` and a no-GUI agent account alike, + # and dev owns its whole lifecycle. The VM is vz-virtualized with Rosetta + # so amd64 build images (e.g. the linux cross-compile image) run on Apple + # silicon. # # Sizing comes from the repo's build.container.resources hint when given # (a UE compile wants more than the shipped defaults); colima applies @@ -73,7 +74,7 @@ def provision!(cpus: nil, memory_gib: nil) ] return if T.unsafe(@executor).run(*args) - raise StartFailedError, "colima start failed — the agent's engine VM could not be brought up." + raise StartFailedError, "colima start failed — the container engine VM could not be brought up." end private diff --git a/lib/dev/container_engine.rb b/lib/dev/container_engine.rb index aa5383b..f2cd881 100644 --- a/lib/dev/container_engine.rb +++ b/lib/dev/container_engine.rb @@ -3,6 +3,7 @@ require "open3" +require "dev/deps" require "dev/settings" module Dev @@ -11,13 +12,15 @@ module Dev # detail; *which daemon serves it* is a per-user provisioning decision, so # the engine is resolved from the invoking user's own config and injected # into everything that composes docker argv (BuildContainer, BuildWatcher, - # CacheGc). The human rides Docker Desktop; the agent user rides its own - # colima VM (Docker Desktop cannot serve a no-GUI user); a remote engine - # later joins as config, never an architecture fork. + # CacheGc). One engine per host OS: macOS users (human and agent alike) + # ride their own colima VM — the only macOS engine whose whole lifecycle + # dev can own (start, idle-stop, resize); Linux and WSL2 ride the bare + # dockerd already running on the host, where there is no VM to own. A + # remote engine later joins as config, never an architecture fork. # # Resolution order (see .resolve): explicit DOCKER_HOST in the caller's - # environment → the per-user `container_engine` settings record → the - # bare-docker default. + # environment → the per-user `container_engine` settings record → the host + # OS's default engine. # # The load-bearing capability predicate is #local_mounts?: bind-mounting # local paths (`-v project_root:/project`) is the single remote-poisoned @@ -32,7 +35,8 @@ class UnknownEngineError < RuntimeError; end # The colima profile dev provisions and points at (colima's own default). COLIMA_PROFILE = "default" - # @return [Symbol] :docker_desktop, :colima, or :explicit (DOCKER_HOST) + # @return [Symbol] :colima (the user's own VM), :docker (bare dockerd — + # whatever daemon the CLI's own context reaches), or :explicit (DOCKER_HOST) sig { returns(Symbol) } attr_reader :kind @@ -50,25 +54,33 @@ class << self # Resolve the invoking user's engine: an explicit DOCKER_HOST wins (the # docker CLI reads it from the inherited environment, so the engine adds - # nothing) → the per-user settings record → the bare-docker default. - # Empty strings count as unset, matching Settings' layer semantics. + # nothing) → the per-user settings record → the host OS's default + # (colima on darwin, bare dockerd elsewhere). Empty strings count as + # unset, matching Settings' layer semantics. A `docker` record is the + # opt-out from the macOS default: bare docker with no env, which reaches + # whatever daemon the CLI's own context does — unsupported but not + # blocked (Docker Desktop users land here). # # @param settings [Dev::Settings] the invoking user's settings # @param env [Hash{String => String}] environment to consult (tests inject) + # @param host_os [String] "darwin" / "linux" / "windows" (tests inject) # @return [Dev::ContainerEngine] # @raise [UnknownEngineError] when the record names an unshipped engine - sig { params(settings: Dev::Settings, env: T::Hash[String, String]).returns(ContainerEngine) } - def resolve(settings: Dev::Settings.new, env: ENV.to_h) + sig do + params(settings: Dev::Settings, env: T::Hash[String, String], host_os: String).returns(ContainerEngine) + end + def resolve(settings: Dev::Settings.new, env: ENV.to_h, host_os: Dev::Deps.detect_host) docker_host = env["DOCKER_HOST"] return new(kind: :explicit) if docker_host && !docker_host.empty? record = settings.container_engine case record - when nil, "docker" then new(kind: :docker_desktop) + when nil then host_os == "darwin" ? colima : new(kind: :docker) + when "docker" then new(kind: :docker) when "colima" then colima else raise UnknownEngineError, - "unknown container_engine #{record.inspect} — dev ships \"docker\" and \"colima\"." + "unknown container_engine #{record.inspect} — dev ships \"colima\" and \"docker\"." end end @@ -128,7 +140,7 @@ def capture(args, env: {}) end # Whether local paths bind-mounted into containers reach this engine's - # daemon. True for every shipped engine (Docker Desktop, colima, explicit + # daemon. True for every shipped engine (colima, bare dockerd, explicit # local DOCKER_HOST); the future remote engine answers false and brings # its sync strategy with it — mount call sites guard on this rather than # assume it. diff --git a/lib/dev/docker_cli_plugins.rb b/lib/dev/docker_cli_plugins.rb new file mode 100644 index 0000000..e4315ad --- /dev/null +++ b/lib/dev/docker_cli_plugins.rb @@ -0,0 +1,84 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "json" + +module Dev + # Points the Homebrew `docker` CLI at Homebrew's CLI plugins so `docker + # buildx` (and therefore `docker build` with --secret / --build-context) + # works without Docker Desktop. + # + # Docker Desktop ships the CLI plugins in ~/.docker/cli-plugins itself; + # brew's `docker-buildx` formula instead drops them under + # $(brew --prefix)/lib/docker/cli-plugins and documents a one-line + # `cliPluginsExtraDirs` entry in ~/.docker/config.json as the way to + # register them. This class owns that one line: it merges into whatever + # config.json already holds (auths, contexts, the user's own dirs) and + # never rewrites a file it cannot parse. + class DockerCliPlugins + extend T::Sig + + # ~/.docker/config.json exists but is not JSON — dev will not clobber a + # file it cannot read back; the user fixes or removes it. + class UnreadableConfigError < RuntimeError; end + + # Where brew's docker-* formulas link their CLI plugins, under the prefix. + PLUGIN_DIR = "lib/docker/cli-plugins" + KEY = "cliPluginsExtraDirs" + + # @param config_path [String] the docker CLI config file (~/.docker/config.json) + # @param brew_prefix [String] the Homebrew prefix the plugins live under + sig { params(config_path: String, brew_prefix: String).void } + def initialize(config_path: File.join(Dir.home, ".docker", "config.json"), brew_prefix: self.class.default_brew_prefix) + @config_path = config_path + @brew_prefix = brew_prefix + end + + # Ensure the brew plugin dir is listed in cliPluginsExtraDirs. + # + # @return [Symbol] :added when the entry was written, :already_present otherwise + # @raise [UnreadableConfigError] when an existing config.json is not JSON + sig { returns(Symbol) } + def ensure! + plugin_dir = File.join(@brew_prefix, PLUGIN_DIR) + config = read_config + dirs = Array(config[KEY]) + return :already_present if dirs.include?(plugin_dir) + + config[KEY] = dirs + [plugin_dir] + FileUtils.mkdir_p(File.dirname(@config_path)) + File.write(@config_path, "#{JSON.pretty_generate(config)}\n") + :added + end + + class << self + extend T::Sig + + # The Homebrew prefix: the shellenv export when present, else the + # platform default (Apple silicon vs Intel). + # + # @return [String] + sig { returns(String) } + def default_brew_prefix + ENV.fetch("HOMEBREW_PREFIX") { RUBY_PLATFORM.include?("arm64") ? "/opt/homebrew" : "/usr/local" } + end + end + + private + + # @return [Hash] the parsed config, {} when the file does not exist + # @raise [UnreadableConfigError] + sig { returns(T::Hash[String, T.untyped]) } + def read_config + return {} unless File.exist?(@config_path) + + parsed = JSON.parse(File.read(@config_path)) + raise UnreadableConfigError, "#{@config_path} is not a JSON object" unless parsed.is_a?(Hash) + + parsed + rescue JSON::ParserError => e + raise UnreadableConfigError, "#{@config_path} is not valid JSON (#{e.message.lines.first&.strip}); fix or remove it" + end + end +end diff --git a/lib/dev/docker_desktop_provisioner.rb b/lib/dev/docker_desktop_provisioner.rb deleted file mode 100644 index 152bfc1..0000000 --- a/lib/dev/docker_desktop_provisioner.rb +++ /dev/null @@ -1,38 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require "dev/container_engine" - -module Dev - # Per-user provisioning for the Docker Desktop engine: verify-only. Docker - # Desktop is a user-managed GUI app — dev never installs or starts it, it - # just proves the daemon answers through the resolved engine so a - # provisioning flow (e.g. `dev runner register`'s bootstrap) fails loud and - # early instead of at the first build. - class DockerDesktopProvisioner - extend T::Sig - - # `docker info` did not answer through the resolved engine — the daemon - # is not running (or DOCKER_HOST points somewhere dead). - class EngineUnreachableError < RuntimeError; end - - # @param engine [Dev::ContainerEngine] the invoking user's resolved engine - sig { params(engine: Dev::ContainerEngine).void } - def initialize(engine:) - @engine = engine - end - - # Prove the daemon answers. Idempotent by nature (a read-only probe). - # - # @return [void] - # @raise [EngineUnreachableError] when the daemon does not answer - sig { void } - def provision! - return if @engine.run(["info"], out: File::NULL, err: File::NULL) - - raise EngineUnreachableError, - "the docker daemon did not answer (engine: #{@engine.kind}) — start Docker Desktop " \ - "(or fix DOCKER_HOST) and retry." - end - end -end diff --git a/lib/dev/engine_provisioner.rb b/lib/dev/engine_provisioner.rb new file mode 100644 index 0000000..41f2567 --- /dev/null +++ b/lib/dev/engine_provisioner.rb @@ -0,0 +1,66 @@ +# typed: strict +# frozen_string_literal: true + +require "dev/build_container_config" +require "dev/colima_provisioner" +require "dev/container_engine" +require "dev/deps" +require "dev/docker_cli_plugins" +require "dev/settings" + +module Dev + # `dev up`'s engine half: bring the resolved container engine to a state + # where `docker build` works for this project. + # + # What that takes depends on the host OS — one engine per OS, see + # ContainerEngine: + # - macOS: the brew docker CLI needs its buildx plugin registered, and the + # colima VM needs to be running (started sized from the repo's + # build.container.resources hint; colima sizes only at creation). + # - Linux / WSL2: bare dockerd is a system service, nothing to start; the + # distro's docker packages ship buildx in place. + # - An explicit DOCKER_HOST is the user's own engine and is left alone. + class EngineProvisioner + extend T::Sig + + # @param settings [Dev::Settings] carries the optional container_engine record + # @param colima [Dev::ColimaProvisioner] starts the macOS VM + # @param cli_plugins [Dev::DockerCliPlugins] wires brew's buildx into the CLI + # @param host_os [String] "darwin" / "linux" / "windows" + # @param env [Hash{String => String}] the process env (DOCKER_HOST wins) + sig do + params( + settings: Dev::Settings, + colima: Dev::ColimaProvisioner, + cli_plugins: Dev::DockerCliPlugins, + host_os: String, + env: T::Hash[String, String], + ).void + end + def initialize(settings: Dev::Settings.new, colima: Dev::ColimaProvisioner.new, + cli_plugins: Dev::DockerCliPlugins.new, host_os: Dev::Deps.detect_host, env: ENV.to_h) + @settings = settings + @colima = colima + @cli_plugins = cli_plugins + @host_os = host_os + @env = env + end + + # Idempotently bring the engine up for a containerized project. + # + # @param resources [BuildContainerConfig::Resources, nil] the repo's VM sizing hint + # @return [void] + # @raise [ContainerEngine::UnknownEngineError] on an unrecognized container_engine record + # @raise [ColimaProvisioner::StartFailedError] when the VM will not start + sig { params(resources: T.nilable(BuildContainerConfig::Resources)).void } + def provision!(resources:) + engine = ContainerEngine.resolve(settings: @settings, env: @env, host_os: @host_os) + return if engine.kind == :explicit + + @cli_plugins.ensure! if @host_os == "darwin" + return unless engine.kind == :colima + + @colima.provision!(cpus: resources&.cpus, memory_gib: resources&.memory_gib) + end + end +end diff --git a/lib/dev/runner_status.rb b/lib/dev/runner_status.rb index a662ff1..c080791 100644 --- a/lib/dev/runner_status.rb +++ b/lib/dev/runner_status.rb @@ -162,7 +162,7 @@ def report_agent_host(runner_dirs) line(File.directory?(@shared_root), "shared root present (#{@shared_root})") return unless @container_required - line(@executor.quiet?("brew", "list", "--formula", "colima"), "colima installed (agent engine)") + line(@executor.quiet?("brew", "list", "--formula", "colima"), "colima installed (container engine)") end # Group + setgid facts via stat, so inspection needs no privileges. diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index 50ee4f2..9ea9652 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -102,8 +102,8 @@ def deployment_formula # The per-user container engine record ("docker" or "colima"), written at # provisioning time (e.g. the agent user's colima, by `dev runner # register`'s bootstrap). Resolution reads the invoking user's own config, - # so nothing crosses the sudo boundary. Unset is a supported state: the - # bare-docker default (see Dev::ContainerEngine.resolve). + # so nothing crosses the sudo boundary. Unset is the normal state: the + # host OS's engine (see Dev::ContainerEngine.resolve). # # @return [String, nil] engine name, or nil for the default sig { returns(T.nilable(String)) } diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 426c22e..fb9e928 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -3,6 +3,7 @@ require "dev/command" require "dev/credentials" +require "dev/engine_provisioner" require "dev/host_service" module Dev @@ -25,12 +26,15 @@ class UpCommand < BuiltinCommand params( install_deps_command: InstallDepsCommand, host_service: Dev::HostService, + engine_provisioner: Dev::EngineProvisioner, ).void end - def initialize(install_deps_command:, host_service: Dev::HostService.new) + def initialize(install_deps_command:, host_service: Dev::HostService.new, + engine_provisioner: Dev::EngineProvisioner.new) super() @install_deps_command = install_deps_command @host_service = host_service + @engine_provisioner = engine_provisioner end sig { override.returns(String) } @@ -70,11 +74,24 @@ def call(args:, context:) end provision_build_credentials(project) + provision_engine(project) @install_deps_command.call(args:, context:) end private + # A containerized project needs a running engine before its first + # `docker build`; `dev up` is where that VM starts (on macOS), sized + # from the repo's resources hint. Non-containerized projects have no + # engine to bring up. + sig { params(project: ProjectContext).void } + def provision_engine(project) + config = project.build_container + return if config.nil? + + @engine_provisioner.provision!(resources: config.resources) + end + # `dev up` is the provisioning command: after it succeeds, every other # command should work unattended. Resolving docker build args here # (prompting and storing credentials on first run) keeps the lazily diff --git a/test/dev/build_watcher_test.rb b/test/dev/build_watcher_test.rb index bd991bf..5860241 100644 --- a/test/dev/build_watcher_test.rb +++ b/test/dev/build_watcher_test.rb @@ -31,7 +31,7 @@ class BuildWatcherTest < Minitest::Test # A real bare-docker engine, so the PATH-fake-docker tests below intercept # the actual spawn (the docker CLI is the boundary under test there). def engine - Dev::ContainerEngine.new(kind: :docker_desktop) + Dev::ContainerEngine.new(kind: :docker) end def watcher(**kwargs) diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index e8d530e..6583127 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -111,12 +111,53 @@ class Dev::Builtins::UpCommandTest < Minitest::Test 0 * Dev::Credentials.resolve_build_args(anything) end + test "call brings the container engine up for a containerized project, sized from its resources, before installing deps" do + Given "a build container with a resources hint; engine and deps install both observed in order" + order = sequence("engine before deps") + engine = typed_mock(Dev::EngineProvisioner) + resources = Dev::BuildContainerConfig::Resources.new(cpus: 8, memory_gib: 24) + engine.expects(:provision!).with(resources: resources).once.in_sequence(order) + install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) + install_deps.expects(:call).once.in_sequence(order) + command = Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, host_service: quiet_host_service, engine_provisioner: engine, + ) + config = Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry", resources: resources) + + When "running up" + command.call(args: [], context: build_context(build_container: config)) + + Then "asserted on the mocks: the engine is up before anything needs it" + true + end + + test "call leaves the engine alone for a project without a build container" do + Given "a plain project" + engine = typed_mock(Dev::EngineProvisioner) + engine.expects(:provision!).never + install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) + install_deps.stubs(:call) + command = Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, host_service: quiet_host_service, engine_provisioner: engine, + ) + + When "running up" + command.call(args: [], context: build_context) + + Then + true + end + private def build_command install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) install_deps.stubs(:call) - Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: quiet_host_service) + engine = typed_mock(Dev::EngineProvisioner) + engine.stubs(:provision!) + Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, host_service: quiet_host_service, engine_provisioner: engine, + ) end def quiet_host_service diff --git a/test/dev/container_engine_test.rb b/test/dev/container_engine_test.rb index 1d675ec..446f12b 100644 --- a/test/dev/container_engine_test.rb +++ b/test/dev/container_engine_test.rb @@ -23,19 +23,25 @@ def build_settings(dir, user_yaml: nil) ) end - test "no env override and no record resolves the bare-docker default" do - Given "empty settings and no DOCKER_HOST" + test "no record on #{host_os} resolves #{kind}: the host OS picks the engine" do + Given "empty settings, no DOCKER_HOST, a #{host_os} host" dir = Dir.mktmpdir("dev-engine-test-") - engine = Dev::ContainerEngine.resolve(settings: build_settings(dir), env: {}) + engine = Dev::ContainerEngine.resolve(settings: build_settings(dir), env: {}, host_os: host_os) - Expect "the Docker Desktop default: bare docker, no extra env, local mounts" - engine.kind == :docker_desktop + Expect "bare docker argv; only colima adds env (its socket)" + engine.kind == kind engine.argv_prefix == ["docker"] - engine.env == {} + engine.env.key?("DOCKER_HOST") == (kind == :colima) engine.local_mounts? Cleanup FileUtils.rm_rf(dir) + + Where + host_os | kind + "darwin" | :colima + "linux" | :docker + "windows" | :docker end test "an explicit DOCKER_HOST wins over any per-user record" do @@ -85,20 +91,33 @@ def build_settings(dir, user_yaml: nil) FileUtils.rm_rf(dir) end - test "a docker record resolves the same default engine explicitly" do - Given "a per-user engine record naming docker" + test "a docker record resolves bare dockerd on any host, overriding the macOS colima default" do + Given "a per-user engine record naming docker, on a darwin host" dir = Dir.mktmpdir("dev-engine-test-") settings = build_settings(dir, user_yaml: "container_engine: docker\n") - engine = Dev::ContainerEngine.resolve(settings: settings, env: {}) + engine = Dev::ContainerEngine.resolve(settings: settings, env: {}, host_os: "darwin") - Expect - engine.kind == :docker_desktop + Expect "bare docker with no env — whatever daemon the CLI's own context reaches" + engine.kind == :docker engine.env == {} Cleanup FileUtils.rm_rf(dir) end + test "a colima record on a non-macOS host still resolves colima (the record is the user's call)" do + Given "a colima record on linux" + dir = Dir.mktmpdir("dev-engine-test-") + settings = build_settings(dir, user_yaml: "container_engine: colima\n") + engine = Dev::ContainerEngine.resolve(settings: settings, env: {}, host_os: "linux") + + Expect + engine.kind == :colima + + Cleanup + FileUtils.rm_rf(dir) + end + test "an unknown engine record raises instead of silently defaulting" do Given "a record naming an engine dev does not ship" dir = Dir.mktmpdir("dev-engine-test-") diff --git a/test/dev/docker_cli_plugins_test.rb b/test/dev/docker_cli_plugins_test.rb new file mode 100644 index 0000000..5f3184a --- /dev/null +++ b/test/dev/docker_cli_plugins_test.rb @@ -0,0 +1,108 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/docker_cli_plugins" +require "fileutils" +require "json" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::DockerCliPluginsTest < Minitest::Test + def build(dir, brew_prefix: "/opt/homebrew") + Dev::DockerCliPlugins.new(config_path: File.join(dir, ".docker", "config.json"), brew_prefix: brew_prefix) + end + + def read_config(dir) = JSON.parse(File.read(File.join(dir, ".docker", "config.json"))) + + test "ensure! creates the config and points the CLI at brew's plugin dir when none exists" do + Given "a home with no ~/.docker at all" + dir = Dir.mktmpdir("dev-docker-cli-plugins-") + + When "ensuring" + result = build(dir).ensure! + + Then "the file exists with exactly the brew plugin dir listed" + result == :added + read_config(dir) == { "cliPluginsExtraDirs" => ["/opt/homebrew/lib/docker/cli-plugins"] } + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! merges into an existing config, keeping every other key and dir" do + Given "a config carrying the user's own keys and a plugin dir of their own" + dir = Dir.mktmpdir("dev-docker-cli-plugins-") + FileUtils.mkdir_p(File.join(dir, ".docker")) + File.write(File.join(dir, ".docker", "config.json"), JSON.generate( + "auths" => { "ghcr.io" => {} }, + "currentContext" => "colima", + "cliPluginsExtraDirs" => ["/Users/me/plugins"], + )) + + When "ensuring" + result = build(dir).ensure! + + Then "the brew dir is appended; nothing else moves" + result == :added + read_config(dir) == { + "auths" => { "ghcr.io" => {} }, + "currentContext" => "colima", + "cliPluginsExtraDirs" => ["/Users/me/plugins", "/opt/homebrew/lib/docker/cli-plugins"], + } + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! is idempotent: a config already listing the dir is left byte-identical" do + Given "a config that already lists brew's plugin dir" + dir = Dir.mktmpdir("dev-docker-cli-plugins-") + path = File.join(dir, ".docker", "config.json") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, %({"cliPluginsExtraDirs":["/opt/homebrew/lib/docker/cli-plugins"]}\n)) + before = File.read(path) + + When "ensuring" + result = build(dir).ensure! + + Then + result == :already_present + File.read(path) == before + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! follows the brew prefix (Intel Macs live under /usr/local)" do + Given "an Intel-style prefix" + dir = Dir.mktmpdir("dev-docker-cli-plugins-") + + When "ensuring" + build(dir, brew_prefix: "/usr/local").ensure! + + Then + read_config(dir)["cliPluginsExtraDirs"] == ["/usr/local/lib/docker/cli-plugins"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! refuses to clobber a config it cannot parse" do + Given "a config.json that is not JSON" + dir = Dir.mktmpdir("dev-docker-cli-plugins-") + path = File.join(dir, ".docker", "config.json") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "{ not json") + + When "ensuring" + build(dir).ensure! + + Then "a typed error names the file; its bytes are untouched" + raises Dev::DockerCliPlugins::UnreadableConfigError + File.read(path) == "{ not json" + + Cleanup + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/docker_desktop_provisioner_test.rb b/test/dev/docker_desktop_provisioner_test.rb deleted file mode 100644 index e7c9041..0000000 --- a/test/dev/docker_desktop_provisioner_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/docker_desktop_provisioner" -require "support/fake_container_engine" - -transform!(RSpock::AST::Transformation) -class Dev::DockerDesktopProvisionerTest < Minitest::Test - test "provision! verifies the daemon answers through the resolved engine" do - Given "an engine whose docker info succeeds" - engine = FakeContainerEngine.new - - When "provisioning" - Dev::DockerDesktopProvisioner.new(engine: engine).provision! - - Then "the probe rode the engine and nothing else happened" - engine.runs == [["info"]] - end - - test "provision! raises when the daemon does not answer (dev never starts the GUI app)" do - Given "an engine whose docker info fails" - engine = FakeContainerEngine.new { |_args| false } - - When "provisioning" - Dev::DockerDesktopProvisioner.new(engine: engine).provision! - - Then - raises Dev::DockerDesktopProvisioner::EngineUnreachableError - end -end diff --git a/test/dev/engine_provisioner_test.rb b/test/dev/engine_provisioner_test.rb new file mode 100644 index 0000000..95c7a88 --- /dev/null +++ b/test/dev/engine_provisioner_test.rb @@ -0,0 +1,137 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/build_container_config" +require "dev/colima_provisioner" +require "dev/container_engine" +require "dev/docker_cli_plugins" +require "dev/engine_provisioner" +require "dev/settings" +require "fileutils" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::EngineProvisionerTest < Minitest::Test + include SorbetHelper + + # Hermetic settings so the machine's own container_engine record never + # decides a test. + def build_settings(dir, record: nil) + if record + path = File.join(dir, "user", "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "container_engine: #{record}\n") + end + Dev::Settings.new( + config_path: File.join(dir, "user", "config.yml"), + system_config_path: File.join(dir, "system", "config.yml"), + ) + end + + def build(dir, host_os:, record: nil, colima:, cli_plugins:) + Dev::EngineProvisioner.new( + settings: build_settings(dir, record: record), + colima: colima, + cli_plugins: cli_plugins, + host_os: host_os, + env: {}, + ) + end + + test "on macOS with the default engine, up wires the docker CLI plugins then starts the colima VM sized from resources" do + Given "a darwin host, no record, and a repo resources hint" + dir = Dir.mktmpdir("dev-engine-provisioner-") + order = sequence("plugins then VM") + cli_plugins = typed_mock(Dev::DockerCliPlugins) + cli_plugins.expects(:ensure!).once.in_sequence(order).returns(:added) + colima = typed_mock(Dev::ColimaProvisioner) + colima.expects(:provision!).with(cpus: 8, memory_gib: 24).once.in_sequence(order) + resources = Dev::BuildContainerConfig::Resources.new(cpus: 8, memory_gib: 24) + + When "provisioning" + build(dir, host_os: "darwin", colima: colima, cli_plugins: cli_plugins).provision!(resources: resources) + + Then "asserted on the mocks: plugins first (docker build needs buildx), then the VM" + true + + Cleanup + FileUtils.rm_rf(dir) + end + + test "no resources hint hands nil sizing to colima (its defaults apply)" do + Given "a darwin host and no hint" + dir = Dir.mktmpdir("dev-engine-provisioner-") + cli_plugins = typed_mock(Dev::DockerCliPlugins) + cli_plugins.stubs(:ensure!).returns(:already_present) + colima = typed_mock(Dev::ColimaProvisioner) + colima.expects(:provision!).with(cpus: nil, memory_gib: nil).once + + When "provisioning" + build(dir, host_os: "darwin", colima: colima, cli_plugins: cli_plugins).provision!(resources: nil) + + Then + true + + Cleanup + FileUtils.rm_rf(dir) + end + + test "on linux there is no VM to start and no brew CLI to wire: up does nothing" do + Given "a linux host with the bare-dockerd default" + dir = Dir.mktmpdir("dev-engine-provisioner-") + cli_plugins = typed_mock(Dev::DockerCliPlugins) + cli_plugins.expects(:ensure!).never + colima = typed_mock(Dev::ColimaProvisioner) + colima.expects(:provision!).never + + When "provisioning" + build(dir, host_os: "linux", colima: colima, cli_plugins: cli_plugins).provision!(resources: nil) + + Then + true + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a docker record on macOS opts out of the VM but still wires the brew CLI's plugins" do + Given "a darwin host whose user recorded container_engine: docker" + dir = Dir.mktmpdir("dev-engine-provisioner-") + cli_plugins = typed_mock(Dev::DockerCliPlugins) + cli_plugins.expects(:ensure!).once.returns(:already_present) + colima = typed_mock(Dev::ColimaProvisioner) + colima.expects(:provision!).never + + When "provisioning" + build(dir, host_os: "darwin", record: "docker", colima: colima, cli_plugins: cli_plugins).provision!(resources: nil) + + Then + true + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an explicit DOCKER_HOST is the user's engine: up leaves it alone entirely" do + Given "a darwin host with DOCKER_HOST set" + dir = Dir.mktmpdir("dev-engine-provisioner-") + cli_plugins = typed_mock(Dev::DockerCliPlugins) + cli_plugins.expects(:ensure!).never + colima = typed_mock(Dev::ColimaProvisioner) + colima.expects(:provision!).never + provisioner = Dev::EngineProvisioner.new( + settings: build_settings(dir), colima: colima, cli_plugins: cli_plugins, + host_os: "darwin", env: { "DOCKER_HOST" => "ssh://build-box" }, + ) + + When "provisioning" + provisioner.provision!(resources: nil) + + Then + true + + Cleanup + FileUtils.rm_rf(dir) + end +end