diff --git a/bake/async/container/notify/log.rb b/bake/async/container/notify/log.rb new file mode 100644 index 0000000..2b4c5c0 --- /dev/null +++ b/bake/async/container/notify/log.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +def initialize(...) + super + + require "async/container/notify/log" +end + +# Check if the log file exists and the service is ready. +# @parameter path [String] The path to the notification log file, uses the `NOTIFY_LOG` environment variable if not provided. +def ready?(path: Async::Container::Notify::Log.path) + if File.exist?(path) + File.foreach(path) do |line| + message = JSON.parse(line) + if message["ready"] == true + $stderr.puts "Service is ready: #{line}" + return true + end + end + + raise "Service is not ready yet." + else + raise "Notification log file does not exist at #{path}" + end +end diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 7154ceb..32fa693 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -85,25 +85,3 @@ controller.run `SIGKILL` is the kill signal. The only behavior is to kill the process, immediately. As the process cannot catch the signal, it cannot cleanup, and thus this is a signal of last resort. `SIGSTOP` is the pause signal. The only behavior is to pause the process; the signal cannot be caught or ignored. The shell uses pausing (and its counterpart, resuming via `SIGCONT`) to implement job control. - -## Integration - -### systemd - -Install a template file into `/etc/systemd/system/`: - -``` -# my-daemon.service -[Unit] -Description=My Daemon -AssertPathExists=/srv/ - -[Service] -Type=notify -WorkingDirectory=/srv/my-daemon -ExecStart=bundle exec my-daemon -Nice=5 - -[Install] -WantedBy=multi-user.target -``` diff --git a/guides/kubernetes-integration/readme.md b/guides/kubernetes-integration/readme.md new file mode 100644 index 0000000..8c25082 --- /dev/null +++ b/guides/kubernetes-integration/readme.md @@ -0,0 +1,39 @@ +# Kubernetes Integration + +This guide explains how to use `async-container` with Kubernetes to manage your application as a containerized service. + +## Deployment Configuration + +Create a deployment configuration file for your application: + +```yaml +# my-app-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + replicas: 1 + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: my-app + image: my-app-image:latest + env: + - name: NOTIFY_LOG + value: "/tmp/notify.log" + ports: + - containerPort: 3000 + readinessProbe: + exec: + command: ["bundle", "exec", "bake", "async:container:notify:log:ready?"] + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +``` diff --git a/guides/links.yaml b/guides/links.yaml new file mode 100644 index 0000000..a59f55f --- /dev/null +++ b/guides/links.yaml @@ -0,0 +1,8 @@ +getting-started: + order: 1 +systemd-integration: + order: 2 +kubernetes-integration: + order: 3 +docker-integration: + order: 4 \ No newline at end of file diff --git a/guides/systemd-integration/readme.md b/guides/systemd-integration/readme.md new file mode 100644 index 0000000..7624340 --- /dev/null +++ b/guides/systemd-integration/readme.md @@ -0,0 +1,22 @@ +# Systemd Integration + +This guide explains how to use `async-container` with systemd to manage your application as a service. + +## Service File + +Install a template file into `/etc/systemd/system/`: + +``` +# my-daemon.service +[Unit] +Description=My Daemon + +[Service] +Type=notify +ExecStart=bundle exec my-daemon + +[Install] +WantedBy=multi-user.target +``` + +Ensure `Type=notify` is set, so that the service can notify systemd when it is ready. diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index eb2e87c..c94f8f1 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -189,6 +189,7 @@ def inspect "\#<#{self.class} name=#{@name.inspect} status=#{@status.inspect} pid=#{@pid.inspect}>" end + # @returns [String] A string representation of the process. alias to_s inspect # Invoke {#terminate!} and then {#wait} for the child process to exit. diff --git a/lib/async/container/notify/log.rb b/lib/async/container/notify/log.rb index d66375b..45cbf48 100644 --- a/lib/async/container/notify/log.rb +++ b/lib/async/container/notify/log.rb @@ -14,9 +14,16 @@ class Log < Client # The name of the environment variable which contains the path to the notification socket. NOTIFY_LOG = "NOTIFY_LOG" + # @returns [String] The path to the notification log file. + # @parameter environment [Hash] The environment variables, defaults to `ENV`. + def self.path(environment = ENV) + environment[NOTIFY_LOG] + end + # Open a notification client attached to the current {NOTIFY_LOG} if possible. + # @parameter environment [Hash] The environment variables, defaults to `ENV`. def self.open!(environment = ENV) - if path = environment.delete(NOTIFY_LOG) + if path = self.path(environment) self.new(path) end end diff --git a/lib/async/container/notify/pipe.rb b/lib/async/container/notify/pipe.rb index c9b6572..7c955bb 100644 --- a/lib/async/container/notify/pipe.rb +++ b/lib/async/container/notify/pipe.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2020-2024, by Samuel Williams. +# Copyright, 2020-2025, by Samuel Williams. # Copyright, 2020, by Juan Antonio Martín Lucas. require_relative "client" diff --git a/releases.md b/releases.md index ff8995e..7f9b35b 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Introduce `async:container:notify:log:ready?` task for detecting process readiness. + ## v0.24.0 - Add support for health check failure metrics. diff --git a/test/async/container/hybrid.rb b/test/async/container/hybrid.rb index 2412162..7e8ec29 100644 --- a/test/async/container/hybrid.rb +++ b/test/async/container/hybrid.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2024, by Samuel Williams. +# Copyright, 2019-2025, by Samuel Williams. require "async/container/hybrid" require "async/container/best" diff --git a/test/async/container/notify/log.rb b/test/async/container/notify/log.rb index 7bb7bad..79bcfec 100644 --- a/test/async/container/notify/log.rb +++ b/test/async/container/notify/log.rb @@ -7,10 +7,16 @@ require "async/container/controllers" require "tmpdir" +require "bake" -describe Async::Container::Notify::Pipe do +describe Async::Container::Notify::Log do let(:notify_script) {Async::Container::Controllers.path_for("notify")} let(:notify_log) {File.expand_path("notify-#{::Process.pid}-#{SecureRandom.hex(8)}.log", Dir.tmpdir)} + let(:notify) {Async::Container::Notify::Log.open!({"NOTIFY_LOG" => notify_log})} + + after do + File.unlink(notify_log) rescue nil + end it "receives notification of child status" do system({"NOTIFY_LOG" => notify_log}, "bundle", "exec", notify_script) @@ -22,4 +28,29 @@ "size" => be > 0, ) end + + with "async:container:notify:log:ready?" do + let(:context) {Bake::Context.load} + let(:recipe) {context.lookup("async:container:notify:log:ready?")} + + it "fails if the log file does not exist" do + expect do + recipe.call(path: "nonexistant.log") + end.to raise_exception(RuntimeError, message: be =~ /log file does not exist/i) + end + + it "succeeds if the log file exists and is ready" do + notify.ready! + + expect(recipe.call(path: notify_log)).to be == true + end + + it "fails if the log file exists but is not ready" do + notify.status!("Loading...") + + expect do + expect(recipe.call(path: notify_log)) + end.to raise_exception(RuntimeError, message: be =~ /service is not ready/i) + end + end end