diff --git a/.ameba.yml b/.ameba.yml index d514865..b35720f 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -1,19 +1,17 @@ # This configuration file was generated by `ameba --gen-config` -# on 2024-07-02 22:36:34 UTC using Ameba version 1.6.1. +# on 2024-07-03 14:31:04 UTC using Ameba version 1.6.1. # The point is for the user to remove these configuration records # one by one as the reported problems are removed from the code base. -# Problems found: 12 +# Problems found: 5 # Run `ameba --only Documentation/DocumentationAdmonition` for details Documentation/DocumentationAdmonition: Description: Reports documentation admonitions Timezone: UTC Excluded: - - src/faaso.cr - src/secrets.cr - src/daemon/main.cr - src/daemon/secrets.cr - - src/funko.cr - spec/faaso_spec.cr Admonitions: - TODO @@ -29,8 +27,8 @@ Naming/BlockParameterName: MinNameLength: 3 AllowNamesEndingInNumbers: true Excluded: - - src/faaso.cr - src/daemon/funko.cr + - src/commands/build.cr AllowedNames: - _ - e diff --git a/src/commands/build.cr b/src/commands/build.cr new file mode 100644 index 0000000..d06b19f --- /dev/null +++ b/src/commands/build.cr @@ -0,0 +1,68 @@ +module Faaso + module Commands + # Build images for one or more funkos from source + struct Build + def run(options, folders : Array(String)) + funkos = Funko::Funko.from_paths(folders) + + if options["--local"] + funkos.each do |funko| + # Create temporary build location + tmp_dir = Path.new("tmp", UUID.random.to_s) + Dir.mkdir_p(tmp_dir) unless File.exists? tmp_dir + funko.prepare_build tmp_dir + + Log.info { "Building function... #{funko.name} in #{tmp_dir}" } + funko.build tmp_dir + end + else # Running against a server + funkos.each do |funko| + # Create a tarball for the funko + buf = IO::Memory.new + Compress::Gzip::Writer.open(buf) do |gzip| + Crystar::Writer.open(gzip) do |tw| + Dir.glob("#{funko.path}/**/*").each do |path| + next unless File.file? path + rel_path = Path[path].relative_to funko.path + file_info = File.info(path) + hdr = Crystar::Header.new( + name: rel_path.to_s, + mode: file_info.permissions.to_u32, + size: file_info.size, + ) + tw.write_header(hdr) + tw.write(File.read(path).to_slice) + end + end + end + + tmp = File.tempname + File.open(tmp, "w") do |outf| + outf << buf + end + + url = "#{FAASO_SERVER}funkos/build/" + + begin + Log.info { "Uploading funko to #{FAASO_SERVER}" } + response = Crest.post( + url, + {"funko.tgz" => File.open(tmp), "name" => "funko.tgz"}, + user: "admin", password: "admin" + ) + Log.info { "Build finished successfully." } + body = JSON.parse(response.body) + Log.info { body["stdout"] } + rescue ex : Crest::InternalServerError + Log.error { "Error building funko #{funko.name} from #{funko.path}" } + body = JSON.parse(ex.response.body) + Log.info { body["stdout"] } + Log.error { body["stderr"] } + exit 1 + end + end + end + end + end + end +end diff --git a/src/commands/export.cr b/src/commands/export.cr new file mode 100644 index 0000000..a9ae80d --- /dev/null +++ b/src/commands/export.cr @@ -0,0 +1,18 @@ +module Faaso + module Commands + struct Export + def run(options, source : String, destination : String) + funko = Funko::Funko.from_paths([source])[0] + # Create temporary build location + dst_path = destination + if File.exists? dst_path + Log.error { "#{dst_path} already exists, not exporting #{funko.path}" } + return 1 + end + Log.info { "Exporting #{funko.path} to #{dst_path}" } + Dir.mkdir_p(dst_path) + funko.prepare_build Path[dst_path] + end + end + end +end diff --git a/src/commands/scale.cr b/src/commands/scale.cr new file mode 100644 index 0000000..4a8bb88 --- /dev/null +++ b/src/commands/scale.cr @@ -0,0 +1,85 @@ +module Faaso + module Commands + # Bring up one or more funkos by name. + # + # This doesn't guarantee that they will be running the latest + # version, and it will try to recicle paused and exited containers. + # + # If there is no other way, it will create a brand new container with + # the latest known image and start it. + # + # If there are no images for the funko, it will fail to bring it up. + struct Scale + def local(options, name, scale) + funko = Funko::Funko.from_names([name])[0] + # Asked about scale + if !scale + Log.info { "Funko #{name} has a scale of #{funko.scale}" } + return 0 + end + # Asked to set scale + if funko.image_history.empty? + Log.error { "Error: no images available for #{funko.name}:latest" } + exit 1 + end + funko.scale(scale.as(String).to_i) + end + + def remote(options, name, scale) + if !scale + response = Crest.get( + "#{FAASO_SERVER}funkos/#{name}/scale/", \ + user: "admin", password: "admin") + else + response = Crest.post( + "#{FAASO_SERVER}funkos/#{name}/scale/", + {"scale" => scale}, user: "admin", password: "admin") + end + body = JSON.parse(response.body) + Log.info { body["output"] } + rescue ex : Crest::InternalServerError + Log.error { "Error scaling funko #{name}" } + body = JSON.parse(ex.response.body) + Log.info { body["output"] } + exit 1 + end + + def run(options, name, scale) + if options["--local"] + return local(options, name, scale) + end + remote(options, name, scale) + + # case self + # when .running? + # # If it's already up, do nothing + # # FIXME: bring back out-of-date warning + # Log.info { "#{funko.name} is already up" } + # when .paused? + # # If it is paused, unpause it + # Log.info { "Resuming existing paused container" } + # funko.unpause + # when .exited? + # Log.info { "Starting function #{funko.name}" } + # Log.info { "Restarting existing exited container" } + # funko.start + # else + # # Only have an image, deploy from scratch + # Faaso.setup_network # We need it + # Log.info { "Creating and starting new container" } + # funko.create_container(autostart: true) + + # (1..5).each { |_| + # break if funko.running? + # sleep 0.1.seconds + # } + # if !funko.running? + # Log.warn { "Container for #{funko.name} is not running yet" } + # next + # end + # Log.info { "Container for #{funko.name} is running" } + # end + end + end + end +end diff --git a/src/commands/status.cr b/src/commands/status.cr new file mode 100644 index 0000000..a2d6519 --- /dev/null +++ b/src/commands/status.cr @@ -0,0 +1,31 @@ +module Faaso + module Commands + struct Status + def local(options, name) + funko = Funko::Funko.from_names([name])[0] + Log.info { "Name: #{funko.name}" } + Log.info { "Scale: #{funko.scale}" } + containers = funko.containers + Log.info { "Containers: #{funko.containers.size}" } + containers.each do |container| + Log.info { " #{container.@names[0]} #{container.status}" } + end + images = funko.images + Log.info { "Images: #{images.size}" } + images.each do |image| + Log.info { " #{image.repo_tags} #{image.created}" } + end + end + + def remote(options, name) + end + + def run(options, name) + if options["--local"] + return local(options, name) + end + remote(options, name) + end + end + end +end diff --git a/src/daemon/funko.cr b/src/daemon/funko.cr index cc028d2..1549779 100644 --- a/src/daemon/funko.cr +++ b/src/daemon/funko.cr @@ -5,6 +5,32 @@ require "../funko.cr" module Funko extend self + # Get the funko's scale + get "/funkos/:name/scale/" do |env| + name = env.params.url["name"] + response = run_faaso(["scale", name]) + + if response["exit_code"] != 0 + halt env, status_code: 500, response: response.to_json + else + response.to_json + end + end + + # Set the funko's scale + post "/funkos/:name/scale/" do |env| + name = env.params.url["name"] + scale = env.params.body["scale"].as(String) + response = run_faaso(["scale", name, scale]) + if response["exit_code"] != 0 + Log.error { response } + halt env, status_code: 500, response: response.to_json + else + Log.info { response } + response.to_json + end + end + get "/funkos/:name/pause/" do |env| funko = Funko.from_names([env.params.url["name"]])[0] funko.pause @@ -33,6 +59,36 @@ module Funko end end + # Build image for funko received as "funko.tgz" + # TODO: This may take a while, consider using something like + # mosquito-cr/mosquito to make it a job queue + post "/funkos/build/" do |env| + # Create place to build funko + tmp_dir = Path.new("tmp", UUID.random.to_s) + Dir.mkdir_p(tmp_dir) unless File.exists? tmp_dir + + # Expand tarball in there + file = env.params.files["funko.tgz"].tempfile + Compress::Gzip::Reader.open(file) do |gzip| + Crystar::Reader.open(gzip) do |tar| + tar.each_entry do |entry| + File.open(Path.new(tmp_dir, entry.name), "w") do |dst| + IO.copy entry.io, dst + end + end + end + end + + # Build the thing + response = run_faaso(["build", tmp_dir.to_s]) + + if response["exit_code"] != 0 + halt env, status_code: 500, response: response.to_json + else + response.to_json + end + end + get "/funkos/" do |env| funkos = Funko.from_docker funkos.sort! { |a, b| a.name <=> b.name } @@ -62,4 +118,20 @@ module Funko result.to_json end end + + def run_faaso(args : Array(String)) + Log.info { "Running faaso [#{args.join(", ")}, -l]" } + output = IO::Memory.new + status = Process.run( + command: "faaso", + args: args + ["-l"], # Always local in the server + output: output, + error: output, + ) + result = { + "exit_code" => status.exit_code, + "output" => output.to_s, + } + result + end end diff --git a/src/daemon/main.cr b/src/daemon/main.cr index 243de1b..fb727e4 100644 --- a/src/daemon/main.cr +++ b/src/daemon/main.cr @@ -11,62 +11,4 @@ require "uuid" # FIXME: make configurable basic_auth "admin", "admin" -# Bring up the funko -get "/funko/:name/up/" do |env| - name = env.params.url["name"] - response = run_faaso(["up", name]) - - if response["exit_code"] != 0 - halt env, status_code: 500, response: response.to_json - else - response.to_json - end -end - -# Build image for funko received as "funko.tgz" -# TODO: This may take a while, consider using something like -# mosquito-cr/mosquito to make it a job queue -post "/funko/build/" do |env| - # Create place to build funko - tmp_dir = Path.new("tmp", UUID.random.to_s) - Dir.mkdir_p(tmp_dir) unless File.exists? tmp_dir - - # Expand tarball in there - file = env.params.files["funko.tgz"].tempfile - Compress::Gzip::Reader.open(file) do |gzip| - Crystar::Reader.open(gzip) do |tar| - tar.each_entry do |entry| - File.open(Path.new(tmp_dir, entry.name), "w") do |dst| - IO.copy entry.io, dst - end - end - end - end - - # Build the thing - response = run_faaso(["build", tmp_dir.to_s]) - - if response["exit_code"] != 0 - halt env, status_code: 500, response: response.to_json - else - response.to_json - end -end - -def run_faaso(args : Array(String)) - stderr = IO::Memory.new - stdout = IO::Memory.new - status = Process.run( - command: "faaso", - args: args + ["-l"], # Always local in the server - output: stdout, - error: stderr, - ) - { - "exit_code" => status.exit_code, - "stdout" => stdout.to_s, - "stderr" => stderr.to_s, - } -end - Kemal.run diff --git a/src/faaso.cr b/src/faaso.cr index 15bbed4..65f238d 100644 --- a/src/faaso.cr +++ b/src/faaso.cr @@ -1,3 +1,7 @@ +require "./commands/build.cr" +require "./commands/export.cr" +require "./commands/scale.cr" +require "./commands/status.cr" require "./funko.cr" require "crest" require "docr" @@ -26,161 +30,5 @@ module Faaso end module Commands - # Build images for one or more funkos - class Build - def run(options, folders : Array(String)) - funkos = Funko::Funko.from_paths(folders) - - if options["--local"] - funkos.each do |funko| - # Create temporary build location - tmp_dir = Path.new("tmp", UUID.random.to_s) - Dir.mkdir_p(tmp_dir) unless File.exists? tmp_dir - funko.prepare_build tmp_dir - - Log.info { "Building function... #{funko.name} in #{tmp_dir}" } - funko.build tmp_dir - end - else # Running against a server - funkos.each do |funko| - # Create a tarball for the funko - buf = IO::Memory.new - Compress::Gzip::Writer.open(buf) do |gzip| - Crystar::Writer.open(gzip) do |tw| - Dir.glob("#{funko.path}/**/*").each do |path| - next unless File.file? path - rel_path = Path[path].relative_to funko.path - file_info = File.info(path) - hdr = Crystar::Header.new( - name: rel_path.to_s, - mode: file_info.permissions.to_u32, - size: file_info.size, - ) - tw.write_header(hdr) - tw.write(File.read(path).to_slice) - end - end - end - - tmp = File.tempname - File.open(tmp, "w") do |outf| - outf << buf - end - - url = "#{FAASO_SERVER}funko/build/" - - begin - Log.info { "Uploading funko to #{FAASO_SERVER}" } - response = Crest.post( - url, - {"funko.tgz" => File.open(tmp), "name" => "funko.tgz"}, - user: "admin", password: "admin" - ) - Log.info { "Build finished successfully." } - body = JSON.parse(response.body) - Log.info { body["stdout"] } - rescue ex : Crest::InternalServerError - Log.error { "Error building funko #{funko.name} from #{funko.path}" } - body = JSON.parse(ex.response.body) - Log.info { body["stdout"] } - Log.error { body["stderr"] } - exit 1 - end - end - end - end - end - - # Bring up one or more funkos by name. - # - # This doesn't guarantee that they will be running the latest - # version, and it will try to recicle paused and exited containers. - # - # If there is no other way, it will create a brand new container with - # the latest known image and start it. - # - # If there are no images for the funko, it will fail to bring it up. - class Up - @arguments : Array(String) = [] of String - @options : Hash(String, Bool) - - def initialize(options, arguments) - @options = options - @arguments = arguments - end - - def run - funkos = Funko::Funko.from_names(@arguments) - funkos.each do |funko| - local = @options.@bool["local"] - - if !local - begin - response = Crest.get("#{FAASO_SERVER}funko/#{funko.name}/up/", - user: "admin", password: "admin") - body = JSON.parse(response.body) - Log.info { body["stdout"] } - next - rescue ex : Crest::InternalServerError - Log.error { "Error bringing up #{funko.name}" } - body = JSON.parse(ex.response.body) - Log.info { body["stdout"] } - Log.error { body["stderr"] } - exit 1 - end - end - - if funko.image_history.empty? - Log.error { "Error: no images available for #{funko.name}:latest" } - exit 1 - end - - case funko - when .running? - # If it's already up, do nothing - # FIXME: bring back out-of-date warning - Log.info { "#{funko.name} is already up" } - when .paused? - # If it is paused, unpause it - Log.info { "Resuming existing paused container" } - funko.unpause - when .exited? - Log.info { "Starting function #{funko.name}" } - Log.info { "Restarting existing exited container" } - funko.start - else - # Only have an image, deploy from scratch - Faaso.setup_network # We need it - Log.info { "Creating and starting new container" } - funko.create_container(autostart: true) - - (1..5).each { |_| - break if funko.running? - sleep 0.1.seconds - } - if !funko.running? - Log.warn { "Container for #{funko.name} is not running yet" } - next - end - Log.info { "Container for #{funko.name} is running" } - end - end - end - end - - class Export - def run(options, source : String, destination : String) - funko = Funko::Funko.from_paths([source])[0] - # Create temporary build location - dst_path = destination - if File.exists? dst_path - Log.error { "#{dst_path} already exists, not exporting #{funko.path}" } - return 1 - end - Log.info { "Exporting #{funko.path} to #{dst_path}" } - Dir.mkdir_p(dst_path) - funko.prepare_build Path[dst_path] - end - end end end diff --git a/src/funko.cr b/src/funko.cr index 2d5b172..f4ac2ac 100644 --- a/src/funko.cr +++ b/src/funko.cr @@ -67,8 +67,10 @@ module Funko # Get the number of running instances of this funko def scale docker_api = Docr::API.new(Docr::Client.new) - docker_api.containers.list.count { |container| - container.@name.starts_with? "faaso-#{name}-" && container.@state == "running" + docker_api.containers.list.select { |container| + container.@state == "running" + }.count { |container| + container.@names.any?(&.starts_with?("/faaso-#{name}-")) } end @@ -78,12 +80,19 @@ module Funko current_scale = self.scale return 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 { start(create_container) } + Log.info { "Adding instance" } + (current_scale...new_scale).each { + id = create_container + sleep 0.2.seconds + start(id) + } else - containers.select { |contatiner| container.@state == "running" }.sort! { |i, j| + containers.select { |container| container.@state == "running" }.sort! { |i, j| i.@created <=> j.@created }.each { |container| + Log.info { "Removing instance" } docker_api.containers.stop(container.@id) current_scale -= 1 break if current_scale == new_scale @@ -127,6 +136,14 @@ module Funko tags: ["faaso-#{name}:latest"]) { |x| Log.info { x } } end + def images + docker_api = Docr::API.new(Docr::Client.new) + docker_api.images.list.select { |image| + false if image.@repo_tags.nil? + true if image.@repo_tags.as(Array(String)).any?(&.starts_with?("faaso-#{name}:")) + } + end + # Return a list of image IDs for this funko, most recent first def image_history docker_api = Docr::API.new(Docr::Client.new) @@ -143,10 +160,9 @@ module Funko # Get all containers related to this funko def containers docker_api = Docr::API.new(Docr::Client.new) - docker_api.containers.list( - all: true, - filters: {"name" => ["faaso-#{name}"]} - ) + docker_api.containers.list(all: true).select { |container| + container.@names.any?(&.starts_with?("/faaso-#{name}-")) + } end # Descriptive status for the funko @@ -202,6 +218,16 @@ module Funko } end + # Start container with given id + def start(id : String) + docker_api = Docr::API.new(Docr::Client.new) + begin + docker_api.containers.start(id) + rescue ex : Docr::Errors::DockerAPIError + Log.error { "#{ex}" } unless ex.status_code == 304 # This just happens + end + end + # Start exited container with the newer image # or unpause paused container def start @@ -258,7 +284,7 @@ module Funko Dir.mkdir_p(secrets_mount) conf = Docr::Types::CreateContainerConfig.new( image: "faaso-#{name}:latest", - hostname: name, + hostname: "#{name}", # Port in the container side host_config: Docr::Types::HostConfig.new( network_mode: "faaso-net", @@ -273,7 +299,7 @@ module Funko ) docker_api = Docr::API.new(Docr::Client.new) - response = docker_api.containers.create(name: "faaso-#{name}", config: conf) + response = docker_api.containers.create(name: "faaso-#{name}-#{randstr}", config: conf) response.@warnings.each { |msg| Log.warn { msg } } docker_api.containers.start(response.@id) if autostart response.@id @@ -313,3 +339,8 @@ module Funko end end end + +def randstr(length = 6) : String + chars = "abcdefghijklmnopqrstuvwxyz0123456789" + String.new(Bytes.new(chars.to_slice.sample(length).to_unsafe, length)) +end diff --git a/src/main.cr b/src/main.cr index 93b9fa6..b9e3aa5 100644 --- a/src/main.cr +++ b/src/main.cr @@ -2,21 +2,6 @@ require "./faaso.cr" require "colorize" require "docopt" -doc = <<-DOC -FaaSO CLI tool. - -Usage: - faaso build FOLDER ... [-l] [-v=] - faaso scale FUNKO_NAME SCALE [-l] [-v=] - faaso export SOURCE DESTINATION [-v=] - -Options: - -h --help Show this screen. - --version Show version. - -l --local Run commands locally instead of against a FaaSO server. - -v=level Control the logging verbosity, 0 to 5 [default: 3] -DOC - # Log formatter for struct LogFormat < Log::StaticFormatter @@colors = { @@ -29,7 +14,7 @@ struct LogFormat < Log::StaticFormatter } def run - string "[#{Time.local}] #{@entry.severity.label}: #{@entry.message}".colorize(@@colors[@entry.severity.label]) + string "#{@entry.message}".colorize(@@colors[@entry.severity.label]) end def self.setup(verbosity) @@ -48,6 +33,22 @@ struct LogFormat < Log::StaticFormatter end end +doc = <<-DOC +FaaSO CLI tool. + +Usage: + faaso build FOLDER ... [-l] [-v=] + faaso scale FUNKO_NAME [SCALE] [-l] [-v=] + faaso status FUNKO_NAME [-l] [-v=] + faaso export SOURCE DESTINATION [-v=] + +Options: + -l --local Run commands locally instead of against a FaaSO server. + -h --help Show this screen. + --version Show version. + -v=level Control the logging verbosity, 0 to 5 [default: 3] +DOC + ans = Docopt.docopt(doc, ARGV) LogFormat.setup(ans["-v"].to_s.to_i) @@ -56,4 +57,8 @@ when .fetch("build", false) Faaso::Commands::Build.new.run(ans, ans["FOLDER"].as(Array(String))) when .fetch("export", false) Faaso::Commands::Export.new.run(ans, ans["SOURCE"].as(String), ans["DESTINATION"].as(String)) +when .fetch("scale", false) + Faaso::Commands::Scale.new.run(ans, ans["FUNKO_NAME"].as(String), ans["SCALE"]) +when .fetch("status", false) + Faaso::Commands::Status.new.run(ans, ans["FUNKO_NAME"].as(String)) end diff --git a/tinyproxy.conf b/tinyproxy.conf index eeb89e3..6ad239d 100644 --- a/tinyproxy.conf +++ b/tinyproxy.conf @@ -8,4 +8,23 @@ ReverseOnly Yes ReverseMagic Yes ReversePath "/admin/" "http://127.0.0.1:3000/" - ReversePath "/faaso/hello/" "http://hello:3000/" \ No newline at end of file + ReversePath "/faaso/hello-101807275100116/" "http://hello-101807275100116:3000/" +ReversePath "/faaso/hello-109717610410253/" "http://hello-109717610410253:3000/" +ReversePath "/faaso/hello-115791188481102/" "http://hello-115791188481102:3000/" +ReversePath "/faaso/hello-489850679780/" "http://hello-489850679780:3000/" +ReversePath "/faaso/hello-52122101707950/" "http://hello-52122101707950:3000/" +ReversePath "/faaso/hello-53828586108120/" "http://hello-53828586108120:3000/" +ReversePath "/faaso/hello-5412177121100122/" "http://hello-5412177121100122:3000/" +ReversePath "/faaso/hello-679912011112183/" "http://hello-679912011112183:3000/" +ReversePath "/faaso/hello-687849738368/" "http://hello-687849738368:3000/" +ReversePath "/faaso/hello-689880877456/" "http://hello-689880877456:3000/" +ReversePath "/faaso/hello-6987113768753/" "http://hello-6987113768753:3000/" +ReversePath "/faaso/hello-7273704811390/" "http://hello-7273704811390:3000/" +ReversePath "/faaso/hello-761221081008155/" "http://hello-761221081008155:3000/" +ReversePath "/faaso/hello-9798100678476/" "http://hello-9798100678476:3000/" +ReversePath "/faaso/hello-98103104100103100/" "http://hello-98103104100103100:3000/" +ReversePath "/faaso/hello-foo/" "http://hello-foo:3000/" +ReversePath "/faaso/hello-gfvij3/" "http://hello-gfvij3:3000/" +ReversePath "/faaso/hello-ngisvh/" "http://hello-ngisvh:3000/" +ReversePath "/faaso/hello-rqp8o3/" "http://hello-rqp8o3:3000/" +ReversePath "/faaso/hello-xtpu69/" "http://hello-xtpu69:3000/" \ No newline at end of file