diff --git a/TODO.md b/TODO.md index 0499d93..ef527ef 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,7 @@ * ✅ Fix proxy reload / Make it reload on file changes * Implement `faaso help command` * Fix `export examples/hello_crystal` it has a `template/` -* Implement zero-downtime rollout (`faaso deploy`) +* ✅ Implement zero-downtime rollout (`faaso deploy`) * Cleanup `tmp/` after use unless `DEBUG` is set # Things to do but not before release diff --git a/src/commands/deploy.cr b/src/commands/deploy.cr new file mode 100644 index 0000000..3151863 --- /dev/null +++ b/src/commands/deploy.cr @@ -0,0 +1,41 @@ +module Faaso + module Commands + struct Deploy + # FIXME: local only for now + def run(options, funko_name : String) : Int32 + Log.info { "Deploying #{funko_name}" } + funko = Funko::Funko.from_names([funko_name])[0] + # Get scale, check for out-of-date containers + current_scale = funko.scale + latest_image = funko.latest_image + containers = funko.containers + out_of_date = containers.count { |container| container.image_id != latest_image } + Log.info { "Need to update #{out_of_date} containers" } + Log.info { "Scaling from #{current_scale} to #{current_scale + out_of_date}" } + # Increase scale to get enough up-to-date containers + new_containers = funko.scale(current_scale + out_of_date) + + # Wait for them to be healthy + begin + funko.wait_for(current_scale + out_of_date, 120, healthy: true) + rescue ex : Exception + # Failed to start, rollback + Log.error(exception: ex) { "Failed to scale, rolling back" } + docker_api = Docr::API.new(Docr::Client.new) + new_containers.each do |container| + docker_api.containers.stop(container.id) + docker_api.containers.delete(container.id) + end + return 1 + end + + Log.info { "Scaling down to #{current_scale}" } + # Decrease scale to the desired amount + funko.scale(current_scale) + funko.wait_for(current_scale, 30) + Log.info { "Deployed #{funko_name}" } + 0 + end + end + end +end diff --git a/src/faaso.cr b/src/faaso.cr index 1fa9db7..132cf90 100644 --- a/src/faaso.cr +++ b/src/faaso.cr @@ -1,4 +1,5 @@ require "./commands/build.cr" +require "./commands/deploy.cr" require "./commands/export.cr" require "./commands/login.cr" require "./commands/new.cr" diff --git a/src/funko.cr b/src/funko.cr index cb799e4..ec26d4f 100644 --- a/src/funko.cr +++ b/src/funko.cr @@ -85,16 +85,18 @@ module Funko end # Set the number of running instances of this funko - def scale(new_scale : Int) + # Returns the list of IDs started or stopped + def scale(new_scale : Int) : Array(String) docker_api = Docr::API.new(Docr::Client.new) current_scale = self.scale - return if current_scale == new_scale + result = [] of String + return result if current_scale == new_scale Log.info { "Scaling #{name} from #{current_scale} to #{new_scale}" } if new_scale > current_scale (current_scale...new_scale).each { Log.info { "Adding instance" } - id = create_container + result << (id = create_container) start(id) sleep 0.1.seconds } @@ -105,6 +107,7 @@ module Funko }.each { |container| Log.info { "Removing instance" } docker_api.containers.stop(container.@id) + result << container.@id current_scale -= 1 break if current_scale == new_scale sleep 0.1.seconds @@ -116,6 +119,8 @@ module Funko Log.info { "Pruning dead instance" } docker_api.containers.delete(container.@id) } + + result end # Setup the target directory `path` with all the files needed @@ -184,7 +189,7 @@ module Funko docker_api.containers.list(all: true).select { |container| container.@names.any?(&.starts_with?("/faaso-#{name}-")) && container.@state == "running" - } + } || [] of Docr::Types::ContainerSummary end # A comprehensive status for the funko: @@ -207,12 +212,26 @@ module Funko end end - # Wait up to `t` seconds for the funko to reach the requested `state` - def wait_for(new_scale : Int, t) + # Wait up to `t` seconds for the funko to reach the desired scale + # If `healthy` is true, it will wait for the container to be declared + # healthy by the healthcheck + def wait_for(new_scale : Int, t : Int, healthy : Bool = false) + docker_api = Docr::API.new(Docr::Client.new) channel = Channel(Nil).new spawn do loop do - channel.send(nil) if scale == new_scale + if healthy + channel.send(nil) if containers.count { |container| + begin + container = docker_api.containers.inspect(container.@id) + channel.send(nil) if !container.nil? && (container.state.health.status == "healthy") + rescue ex : Docr::Errors::DockerAPIError + Log.error { "#{ex}" } unless ex.status_code == 304 # This just happens + end + } == new_scale + else + channel.send(nil) if scale == new_scale + end sleep 0.2.seconds end end @@ -221,7 +240,7 @@ module Funko when channel.receive Log.info { "Funko #{name} reached scale #{new_scale}" } when timeout(t.seconds) - Log.error { "Funko #{name} did not reach scale #{new_scale} in #{t} seconds" } + raise Exception.new("Funko #{name} did not reach scale #{new_scale} in #{t} seconds") end end diff --git a/src/main.cr b/src/main.cr index c0c2f4d..9af328a 100644 --- a/src/main.cr +++ b/src/main.cr @@ -42,6 +42,8 @@ status : Int32 = 0 case ans when .fetch("build", false) status = Faaso::Commands::Build.new.run(ans, ans["FOLDER"].as(Array(String))) +when .fetch("deploy", false) + status = Faaso::Commands::Deploy.new.run(ans, ans["FUNKO"].as(String)) when .fetch("export", false) status = Faaso::Commands::Export.new.run(ans, ans["SOURCE"].as(String), ans["DESTINATION"].as(String)) when .fetch("login", false)