Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a "build dev" command #1357

Merged
merged 3 commits into from
Feb 3, 2025
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
42 changes: 39 additions & 3 deletions lib/kamal/cli/build.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ class BuildError < StandardError; end

desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver
push
pull
invoke :push
invoke :pull
end

desc "push", "Build and push app image to registry"
option :output, type: :string, default: "registry", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def push
cli = self

Expand Down Expand Up @@ -49,7 +50,7 @@ def push
end

# Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push
push = KAMAL.builder.push(cli.options[:output])

KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
Expand Down Expand Up @@ -108,6 +109,41 @@ def details
end
end

desc "dev", "Build using the working directory, tag it as dirty, and push to local image store."
option :output, type: :string, default: "docker", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def dev
cli = self

ensure_docker_installed

docker_included_files = Set.new(Kamal::Docker.included_files)
git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files)
git_untracked_files = Set.new(Kamal::Git.untracked_files)

docker_uncommitted_files = docker_included_files & git_uncommitted_files
if docker_uncommitted_files.any?
say "WARNING: Files with uncommitted changes will be present in the dev container:", :yellow
docker_uncommitted_files.sort.each { |f| say " #{f}", :yellow }
say
end

docker_untracked_files = docker_included_files & git_untracked_files
if docker_untracked_files.any?
say "WARNING: Untracked files will be present in the dev container:", :yellow
docker_untracked_files.sort.each { |f| say " #{f}", :yellow }
say
end

with_env(KAMAL.config.builder.secrets) do
run_locally do
build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true)
KAMAL.with_verbosity(:debug) do
execute(*build)
end
end
end
end

private
def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host)
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/commands/builder.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require "active_support/core_ext/string/filters"

class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
delegate :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
delegate :local?, :remote?, :cloud?, to: "config.builder"

include Clone
Expand Down
17 changes: 12 additions & 5 deletions lib/kamal/commands/builder/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ def clean
docker :image, :rm, "--force", config.absolute_image
end

def push
def push(export_action = "registry", tag_as_dirty: false)
docker :buildx, :build,
"--push",
"--output=type=#{export_action}",
*platform_options(arches),
*([ "--builder", builder_name ] unless docker_driver?),
*build_tag_options(tag_as_dirty: tag_as_dirty),
*build_options,
build_context
end
Expand All @@ -37,7 +38,7 @@ def inspect_builder
end

def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
[ *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
end

def build_context
Expand All @@ -58,8 +59,14 @@ def first_mirror
end

private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
def build_tag_names(tag_as_dirty: false)
tag_names = [ config.absolute_image, config.latest_image ]
tag_names.map! { |t| "#{t}-dirty" } if tag_as_dirty
tag_names
end

def build_tag_options(tag_as_dirty: false)
build_tag_names(tag_as_dirty: tag_as_dirty).flat_map { |name| [ "-t", name ] }
end

def build_cache
Expand Down
30 changes: 30 additions & 0 deletions lib/kamal/docker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require "tempfile"
require "open3"

module Kamal::Docker
extend self
BUILD_CHECK_TAG = "kamal-local-build-check"

def included_files
Tempfile.create do |dockerfile|
dockerfile.write(<<~DOCKERFILE)
FROM busybox
COPY . app
WORKDIR app
CMD find . -type f | sed "s|^\./||"
DOCKERFILE
dockerfile.close

cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
system(cmd) || raise("failed to build check image")
end

cmd = "docker run --rm #{BUILD_CHECK_TAG}"
out, err, status = Open3.capture3(cmd)
unless status
raise "failed to run check image:\n#{err}"
end

out.lines.map(&:strip)
end
end
10 changes: 10 additions & 0 deletions lib/kamal/git.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,14 @@ def uncommitted_changes
def root
`git rev-parse --show-toplevel`.strip
end

# returns an array of relative path names of files with uncommitted changes
def uncommitted_files
`git ls-files --modified`.lines.map(&:strip)
end

# returns an array of relative path names of untracked files, including gitignored files
def untracked_files
`git ls-files --others`.lines.map(&:strip)
end
end
55 changes: 51 additions & 4 deletions test/cli/build_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,30 @@ class CliBuildTest < CliTestCase
assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end
end
end

test "push --output=docker" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }

SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)

SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")

run_command("push", "--output=docker", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables
assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end
end
end
Expand All @@ -49,7 +72,7 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init")

SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")

SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
Expand All @@ -74,7 +97,7 @@ class CliBuildTest < CliTestCase
assert_no_match /Cloning repo into build directory/, output
assert_hook_ran "pre-build", output, **hook_variables
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output
assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output
end
end

Expand Down Expand Up @@ -140,7 +163,7 @@ class CliBuildTest < CliTestCase
.returns("")

SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")

run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output
Expand Down Expand Up @@ -275,6 +298,30 @@ class CliBuildTest < CliTestCase
end
end

test "dev" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)

run_command("dev", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end

test "dev --output=local" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)

run_command("dev", "--output=local", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=local --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end

private
def run_command(*command, fixture: :with_accessories)
stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
Expand Down
Loading