Merge branch 'main' into pr/223

* main:
  Don't run actions twice on PRs
  Further distinguish dependency verification
  Naming
  Reveal configured dockerfile path
  Style
  Distinguish from server dependencies
  Distinguish from local dependency verification
  Improve clarity and intent
  Style
  Style
  Style
  Add local dependencies check
  Bootstrap: use multi-platform installer
This commit is contained in:
David Heinemeier Hansson
2023-05-02 14:44:16 +02:00
14 changed files with 175 additions and 27 deletions

View File

@@ -1,5 +1,9 @@
name: CI name: CI
on: [push, pull_request] on:
push:
branches:
- main
pull_request:
jobs: jobs:
tests: tests:
strategy: strategy:

View File

@@ -1,4 +1,6 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
class BuildError < StandardError; end
desc "deliver", "Build app and push app image to registry then pull image on servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
with_lock do with_lock do
@@ -14,7 +16,9 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
run_locally do run_locally do
begin begin
if cli.verify_local_dependencies
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
end
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/ if e.message =~ /(no builder)|(no such file or directory)/
error "Missing compatible builder, so creating a new one first" error "Missing compatible builder, so creating a new one first"
@@ -77,4 +81,22 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
puts capture(*MRSK.builder.info) puts capture(*MRSK.builder.info)
end end
end end
desc "", "" # Really a private method, but needed to be invoked from #push
def verify_local_dependencies
run_locally do
begin
execute *MRSK.builder.ensure_local_dependencies_installed
rescue SSHKit::Command::Failed => e
build_error = e.message =~ /command not found/ ?
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise BuildError, build_error
end
end
true
end
end end

View File

@@ -17,9 +17,6 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
invoke_options = deploy_options invoke_options = deploy_options
runtime = print_runtime do runtime = print_runtime do
say "Ensure curl and Docker are installed...", :magenta
invoke "mrsk:cli:server:bootstrap", [], invoke_options
say "Log into image registry...", :magenta say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login", [], invoke_options invoke "mrsk:cli:registry:login", [], invoke_options

View File

@@ -1,17 +1,21 @@
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure curl and Docker are installed on servers" desc "bootstrap", "Set up Docker to run MRSK apps"
def bootstrap def bootstrap
with_lock do missing = []
on(MRSK.hosts + MRSK.accessory_hosts) do
dependencies_to_install = Array.new.tap do |dependencies| on(MRSK.hosts | MRSK.accessory_hosts) do |host|
dependencies << "curl" unless execute "which curl", raise_on_non_zero_exit: false unless execute(*MRSK.docker.installed?, raise_on_non_zero_exit: false)
dependencies << "docker.io" unless execute "which docker", raise_on_non_zero_exit: false if execute(*MRSK.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…"
execute *MRSK.docker.install
else
missing << host
end
end
end end
if dependencies_to_install.any? if missing.any?
execute "apt-get update -y && apt-get install #{dependencies_to_install.join(" ")} -y" raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/"
end
end
end end
end end
end end

View File

@@ -92,6 +92,10 @@ class Mrsk::Commander
@builder ||= Mrsk::Commands::Builder.new(config) @builder ||= Mrsk::Commands::Builder.new(config)
end end
def docker
@docker ||= Mrsk::Commands::Docker.new(config)
end
def healthcheck def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config) @healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end end

View File

@@ -2,7 +2,7 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, to: :target delegate :create, :remove, :push, :clean, :pull, :info, to: :target
def name def name
target.class.to_s.remove("Mrsk::Commands::Builder::").underscore target.class.to_s.remove("Mrsk::Commands::Builder::").underscore.inquiry
end end
def target def target
@@ -33,4 +33,24 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
def multiarch_remote def multiarch_remote
@multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config) @multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config)
end end
def ensure_local_dependencies_installed
if name.native?
ensure_local_docker_installed
else
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
end
private
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end
end end

View File

@@ -1,4 +1,7 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
class BuilderError < StandardError; end
delegate :argumentize, to: Mrsk::Utils delegate :argumentize, to: Mrsk::Utils
def clean def clean
@@ -17,6 +20,7 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
context context
end end
private private
def build_tags def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ] [ "-t", config.absolute_image, "-t", config.latest_image ]
@@ -35,7 +39,11 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
end end
def build_dockerfile def build_dockerfile
if Pathname.new(File.expand_path(dockerfile)).exist?
argumentize "--file", dockerfile argumentize "--file", dockerfile
else
raise BuilderError, "Missing #{dockerfile}"
end
end end
def args def args

View File

@@ -0,0 +1,21 @@
class Mrsk::Commands::Docker < Mrsk::Commands::Base
# Install Docker using the https://github.com/docker/docker-install convenience script.
def install
pipe [ :curl, "-fsSL", "https://get.docker.com" ], :sh
end
# Checks the Docker client version. Fails if Docker is not installed.
def installed?
docker "-v"
end
# Checks the Docker server version. Fails if Docker is not running.
def running?
docker :version
end
# Do we have superuser access to install Docker and start system services?
def superuser?
[ '[ "${EUID:-$(id -u)}" -eq 0 ]' ]
end
end

View File

@@ -9,6 +9,7 @@ class CliBuildTest < CliTestCase
end end
test "push" do test "push" do
Mrsk::Cli::Build.any_instance.stubs(:verify_local_dependencies).returns(true)
run_command("push").tap do |output| run_command("push").tap do |output|
assert_match /docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder mrsk-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output assert_match /docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder mrsk-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end end
@@ -16,6 +17,7 @@ class CliBuildTest < CliTestCase
test "push without builder" do test "push without builder" do
stub_locking stub_locking
Mrsk::Cli::Build.any_instance.stubs(:verify_local_dependencies).returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg| arg == :docker } .with { |arg| arg == :docker }
.raises(SSHKit::Command::Failed.new("no builder")) .raises(SSHKit::Command::Failed.new("no builder"))
@@ -68,6 +70,23 @@ class CliBuildTest < CliTestCase
end end
end end
test "verify local dependencies" do
Mrsk::Commands::Builder.any_instance.stubs(:name).returns("remote".inquiry)
run_command("verify_local_dependencies").tap do |output|
assert_match /docker --version && docker buildx version/, output
end
end
test "verify local dependencies with no buildx plugin" do
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
.raises(SSHKit::Command::Failed.new("no buildx"))
Mrsk::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false)
assert_raises(Mrsk::Cli::Build::BuildError) { run_command("verify_local_dependencies") }
end
private private
def run_command(*command) def run_command(*command)
stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }

View File

@@ -12,7 +12,6 @@ class CliMainTest < CliTestCase
test "deploy" do test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options)
@@ -22,7 +21,6 @@ class CliMainTest < CliTestCase
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options)
run_command("deploy").tap do |output| run_command("deploy").tap do |output|
assert_match /Ensure curl and Docker are installed/, output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
@@ -35,7 +33,6 @@ class CliMainTest < CliTestCase
test "deploy with skip_push" do test "deploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options)
@@ -46,7 +43,6 @@ class CliMainTest < CliTestCase
run_command("deploy", "--skip_push").tap do |output| run_command("deploy", "--skip_push").tap do |output|
assert_match /Acquiring the deploy lock/, output assert_match /Acquiring the deploy lock/, output
assert_match /Ensure curl and Docker are installed/, output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Pull app image/, output assert_match /Pull app image/, output
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
@@ -87,7 +83,6 @@ class CliMainTest < CliTestCase
test "deploy errors during critical section leave lock in place" do test "deploy errors during critical section leave lock in place" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:stale_containers", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:stale_containers", [], invoke_options)
@@ -106,7 +101,7 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke) Mrsk::Cli::Main.any_instance.expects(:invoke)
.with("mrsk:cli:server:bootstrap", [], invoke_options) .with("mrsk:cli:registry:login", [], invoke_options)
.raises(RuntimeError) .raises(RuntimeError)
assert !MRSK.holding_lock? assert !MRSK.holding_lock?

View File

@@ -1,11 +1,30 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliServerTest < CliTestCase class CliServerTest < CliTestCase
test "bootstrap" do test "bootstrap already installed" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once
assert_equal "", run_command("bootstrap")
end
test "bootstrap install as non-root user" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(false).at_least_once
assert_raise RuntimeError, "Docker is not installed on 1.1.1.1, 1.1.1.3, 1.1.1.4, 1.1.1.2 and can't be automatically intalled without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/" do
run_command("bootstrap")
end
end
test "bootstrap install as root user" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(true).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:curl, "-fsSL", "https://get.docker.com", "|", :sh).at_least_once
run_command("bootstrap").tap do |output| run_command("bootstrap").tap do |output|
assert_match /which curl/, output ("1.1.1.1".."1.1.1.4").map do |host|
assert_match /which docker/, output assert_match "Missing Docker on #{host}. Installing…", output
assert_match /apt-get update -y && apt-get install curl docker.io -y/, output end
end end
end end

View File

@@ -52,12 +52,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "build dockerfile" do test "build dockerfile" do
Pathname.any_instance.expects(:exist?).returns(true).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" }) builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz", "-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
test "missing dockerfile" do
Pathname.any_instance.expects(:exist?).returns(false).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_raises(Mrsk::Commands::Builder::Base::BuilderError) do
builder.target.build_options.join(" ")
end
end
test "build context" do test "build context" do
builder = new_builder_command(builder: { "context" => ".." }) builder = new_builder_command(builder: { "context" => ".." })
assert_equal \ assert_equal \

View File

@@ -0,0 +1,26 @@
require "test_helper"
class CommandsDockerTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
}
@docker = Mrsk::Commands::Docker.new(Mrsk::Configuration.new(@config))
end
test "install" do
assert_equal "curl -fsSL https://get.docker.com | sh", @docker.install.join(" ")
end
test "installed?" do
assert_equal "docker -v", @docker.installed?.join(" ")
end
test "running?" do
assert_equal "docker version", @docker.running?.join(" ")
end
test "superuser?" do
assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ]', @docker.superuser?.join(" ")
end
end

View File

@@ -48,7 +48,7 @@ class ConfigurationTest < ActiveSupport::TestCase
end end
test "role" do test "role" do
assert_equal "web", @config.role(:web).name assert @config.role(:web).name.web?
assert_equal "workers", @config_with_roles.role(:workers).name assert_equal "workers", @config_with_roles.role(:workers).name
assert_nil @config.role(:missing) assert_nil @config.role(:missing)
end end