From cd9d324e38993354c9e8019b0e6dcde58db4a6a7 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 10:21:36 -0600 Subject: [PATCH 1/6] introduce --include-dir option for overriding defaults. is also timer-aware. --- README.md | 20 +++++++++++++ lib/foreman/export/systemd_user.rb | 45 ++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa8f6c7..42948e3 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,29 @@ bundle exec foreman export systemd-user --app Note that this may break from foreman's protocol a bit, because it starts the processes after export. It does this by running the following: ``` +systemctl --user daemon-reload loginctl enable-linger systemctl --user enable .target systemctl --user restart .target ``` After forgetting to run these steps enough times, I just decided to bake it into the export. +## Including extra systemd files + +Use `--include-dir` to copy additional systemd files (drop-in overrides, extra units, timers) after generating the main units: + +``` +bundle exec foreman export systemd-user --app --include-dir Procfile.systemd +``` + +Example directory structure: +``` +Procfile.systemd/ + -web@.service.d/ + override.conf # Drop-in override for the web service + -restart.service # Extra standalone unit + -restart.timer # Timer (will be enabled and started automatically) +``` + +Any `.timer` files in the root of the include directory will be enabled with `--now`. + diff --git a/lib/foreman/export/systemd_user.rb b/lib/foreman/export/systemd_user.rb index 3600708..eb370c6 100644 --- a/lib/foreman/export/systemd_user.rb +++ b/lib/foreman/export/systemd_user.rb @@ -24,7 +24,15 @@ def location def export super + clean_old_units + write_units + install_include_dir + configure_systemd + end + + private + def clean_old_units Dir["#{location}/#{app}*.target"] .concat(Dir["#{location}/#{app}*.service"]) .concat(Dir["#{location}/#{app}*.target.wants/#{app}*.service"]) @@ -35,7 +43,9 @@ def export Dir["#{location}/#{app}*.target.wants"].each do |file| clean_dir file end + end + def write_units process_master_names = [] engine.each_process do |name, process| @@ -55,17 +65,42 @@ def export end write_template "systemd_user/master.target.erb", "#{app}.target", binding + end + + def run_command command + puts command + raise unless system(command) + end + + def include_dir + dir = options[:include_dir] + return unless dir + raise "include_dir '#{dir}' is not a directory" unless File.directory?(dir) + dir + end + + def install_include_dir + if include_dir + run_command "cp -r #{include_dir}/. #{location}/" + end + end + + def configure_systemd + run_command "systemctl --user daemon-reload" run_command "test -f /var/lib/systemd/linger/$USER || loginctl enable-linger" run_command "systemctl --user enable #{app}.target" run_command "systemctl --user restart #{app}.target" + enable_timers end - private - - def run_command command - puts command - raise unless system(command) + def enable_timers + if include_dir + Dir.glob("#{include_dir}/*.timer").each do |timer| + timer_name = File.basename(timer) + run_command "systemctl --user enable --now #{timer_name}" + end + end end end From 61e2957b0487d62c286f4d446ec3159de1806539 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 10:29:30 -0600 Subject: [PATCH 2/6] tweak description. --- README.md | 2 +- foreman-export-systemd_user.gemspec | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 42948e3..d9e3886 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Foreman export scripts for user-level systemd on Ubuntu 16.04+ +# Foreman export scripts for user-level systemd ```ruby # Gemfile diff --git a/foreman-export-systemd_user.gemspec b/foreman-export-systemd_user.gemspec index fb7d433..d41cee8 100644 --- a/foreman-export-systemd_user.gemspec +++ b/foreman-export-systemd_user.gemspec @@ -6,8 +6,8 @@ Gem::Specification.new do |s| s.authors = ["Micah Geisel"] s.email = ["micah@botandrose.com"] s.homepage = "http://github.com/botandrose/foreman-export-systemd_user" - s.summary = "Upstart user-level export scripts for systemd on Ubuntu 16.04+" - s.description = "Upstart user-level export scripts for systemd on Ubuntu 16.04+" + s.summary = "Foreman export scripts for user-level systemd" + s.description = "Foreman export scripts for user-level systemd" s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") From 71baf074ef3a3850bcb4b0d08beaf3149e1148d2 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 10:31:21 -0600 Subject: [PATCH 3/6] rely on newer foreman for easier extension. --- foreman-export-systemd_user.gemspec | 2 +- lib/foreman/export/systemd_user.rb | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/foreman-export-systemd_user.gemspec b/foreman-export-systemd_user.gemspec index d41cee8..6d7ff08 100644 --- a/foreman-export-systemd_user.gemspec +++ b/foreman-export-systemd_user.gemspec @@ -14,5 +14,5 @@ Gem::Specification.new do |s| s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ["lib"] - s.add_runtime_dependency "foreman" + s.add_runtime_dependency "foreman", ">= 0.90.0" end diff --git a/lib/foreman/export/systemd_user.rb b/lib/foreman/export/systemd_user.rb index eb370c6..97c3627 100644 --- a/lib/foreman/export/systemd_user.rb +++ b/lib/foreman/export/systemd_user.rb @@ -1,17 +1,12 @@ require "erb" require "foreman/export" -File.instance_eval { def exists?(p); exist?(p); end } # restore old method removed in Ruby 3.2, because foreman 0.87.2 uses it, and appears abandoned, and I don't want to maintain a fork. - class Foreman::Export::SystemdUser < Foreman::Export::Base - def initialize location, engine, options={} + TEMPLATE_DIR = File.expand_path("../../../../data/export/systemd_user", __FILE__) + + def initialize(location, engine, options = {}) + options[:template] ||= TEMPLATE_DIR super - # what a pain in the ass - # template is obviously not intended to be overriden - unless @options.has_key?(:template) - template = File.expand_path("../../../../data/export/systemd_user", __FILE__) - @options = { template: template }.merge(@options).freeze - end end def app From 3b0dbf964b676a6571cd540887737929b501cbe3 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 12:15:46 -0600 Subject: [PATCH 4/6] get something working with testing. --- Rakefile | 5 + foreman-export-systemd_user.gemspec | 2 + spec/Dockerfile.systemd | 38 ++++++ spec/foreman/export/systemd_user_spec.rb | 160 +++++++++++++++++++++++ spec/spec_helper.rb | 102 +++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 spec/Dockerfile.systemd create mode 100644 spec/foreman/export/systemd_user_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/Rakefile b/Rakefile index f57ae68..93b3e16 100644 --- a/Rakefile +++ b/Rakefile @@ -1,2 +1,7 @@ #!/usr/bin/env rake require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/foreman-export-systemd_user.gemspec b/foreman-export-systemd_user.gemspec index 6d7ff08..415f775 100644 --- a/foreman-export-systemd_user.gemspec +++ b/foreman-export-systemd_user.gemspec @@ -15,4 +15,6 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.add_runtime_dependency "foreman", ">= 0.90.0" + + s.add_development_dependency "rspec" end diff --git a/spec/Dockerfile.systemd b/spec/Dockerfile.systemd new file mode 100644 index 0000000..63104e4 --- /dev/null +++ b/spec/Dockerfile.systemd @@ -0,0 +1,38 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + systemd \ + systemd-sysv \ + dbus \ + dbus-user-session \ + ruby \ + ruby-bundler \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create test user +RUN useradd -m -s /bin/bash testuser \ + && mkdir -p /home/testuser/.config/systemd/user \ + && chown -R testuser:testuser /home/testuser + +# Enable linger for testuser so user services start at boot +RUN mkdir -p /var/lib/systemd/linger \ + && touch /var/lib/systemd/linger/testuser + +# Remove unnecessary systemd services that won't work in container +RUN rm -f /lib/systemd/system/multi-user.target.wants/* \ + /etc/systemd/system/*.wants/* \ + /lib/systemd/system/local-fs.target.wants/* \ + /lib/systemd/system/sockets.target.wants/*udev* \ + /lib/systemd/system/sockets.target.wants/*initctl* \ + /lib/systemd/system/sysinit.target.wants/systemd-tmpfiles-setup* \ + /lib/systemd/system/systemd-update-utmp* + +VOLUME ["/sys/fs/cgroup"] + +STOPSIGNAL SIGRTMIN+3 + +CMD ["/sbin/init"] diff --git a/spec/foreman/export/systemd_user_spec.rb b/spec/foreman/export/systemd_user_spec.rb new file mode 100644 index 0000000..f7f27f0 --- /dev/null +++ b/spec/foreman/export/systemd_user_spec.rb @@ -0,0 +1,160 @@ +require "spec_helper" + +RSpec.describe "foreman export systemd-user" do + let(:container) { SystemdContainer } + let(:app_name) { "testapp" } + let(:systemd_dir) { container.user_systemd_dir } + + before(:all) do + # Copy gem files to container and install + @gem_path = SystemdContainer.copy_gem_to_container + SystemdContainer.exec("bash", "-c", "cd #{@gem_path} && bundle install --quiet 2>&1") + + # Set up a minimal Procfile in the test user's app directory + SystemdContainer.exec_as_user("mkdir -p /home/testuser/app") + SystemdContainer.exec_as_user("echo 'web: ruby -run -e httpd . -p $PORT' > /home/testuser/app/Procfile") + + # Create a Gemfile in the app dir that references our gem + SystemdContainer.exec_as_user("cat > /home/testuser/app/Gemfile << EOF +source 'https://rubygems.org' +gem 'foreman-export-systemd_user', path: '#{@gem_path}' +EOF") + SystemdContainer.exec_as_user("cd /home/testuser/app && bundle config set --local path 'vendor/bundle' && bundle install --quiet 2>&1") + end + + describe "basic export" do + before(:all) do + stdout, stderr, status = SystemdContainer.exec_as_user( + "cd /home/testuser/app && bundle exec foreman export systemd-user --app testapp 2>&1" + ) + @export_output = stdout + @export_status = status + end + + it "succeeds" do + expect(@export_status).to be_success + end + + it "creates the master target file" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp.target") + expect(stdout).to include("[Unit]") + expect(stdout).to include("testapp-web.target") + end + + it "creates the process target file" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp-web.target") + expect(stdout).to include("[Unit]") + end + + it "creates the process service template" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp-web@.service") + expect(stdout).to include("[Service]") + expect(stdout).to include("ExecStart=") + end + + it "creates the target.wants directory with symlinks" do + stdout, _, _ = container.exec_as_user("ls -la #{systemd_dir}/testapp-web.target.wants/") + expect(stdout).to include("testapp-web@") + expect(stdout).to include("-> ../testapp-web@.service") + end + + it "runs daemon-reload" do + expect(@export_output).to include("systemctl --user daemon-reload") + end + + it "enables linger" do + expect(@export_output).to include("loginctl enable-linger") + end + + it "enables the target" do + expect(@export_output).to include("systemctl --user enable testapp.target") + end + + it "the target can be started" do + stdout, _, _ = container.exec_as_user("systemctl --user start testapp.target && systemctl --user is-active testapp.target") + expect(stdout.strip).to eq("active") + end + end + + describe "with --include-dir" do + before(:all) do + # Create include-dir with drop-in and timer + SystemdContainer.exec_as_user("mkdir -p /home/testuser/app/Procfile.systemd/testapp-web@.service.d") + SystemdContainer.exec_as_user("echo '[Service]\nEnvironment=EXTRA=value' > /home/testuser/app/Procfile.systemd/testapp-web@.service.d/override.conf") + + # Create a simple timer + SystemdContainer.exec_as_user("cat > /home/testuser/app/Procfile.systemd/testapp-cleanup.timer << 'EOF' +[Unit] +Description=Cleanup timer + +[Timer] +OnCalendar=daily + +[Install] +WantedBy=timers.target +EOF") + + SystemdContainer.exec_as_user("cat > /home/testuser/app/Procfile.systemd/testapp-cleanup.service << 'EOF' +[Unit] +Description=Cleanup service + +[Service] +Type=oneshot +ExecStart=/bin/true +EOF") + + # Stop any existing target first + SystemdContainer.exec_as_user("systemctl --user stop testapp.target 2>/dev/null || true") + + # Run export with --include-dir + stdout, stderr, status = SystemdContainer.exec_as_user( + "cd /home/testuser/app && bundle exec foreman export systemd-user --app testapp --include-dir Procfile.systemd 2>&1" + ) + @export_output = stdout + @export_status = status + end + + it "succeeds" do + expect(@export_status).to be_success + end + + it "copies the drop-in directory" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp-web@.service.d/override.conf") + expect(stdout).to include("EXTRA=value") + end + + it "copies the timer file" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp-cleanup.timer") + expect(stdout).to include("OnCalendar=daily") + end + + it "copies the service file" do + stdout, _, _ = container.exec_as_user("cat #{systemd_dir}/testapp-cleanup.service") + expect(stdout).to include("ExecStart=/bin/true") + end + + it "enables the timer with --now" do + expect(@export_output).to include("systemctl --user enable --now testapp-cleanup.timer") + end + + it "the timer is active" do + stdout, _, _ = container.exec_as_user("systemctl --user is-active testapp-cleanup.timer") + expect(stdout.strip).to eq("active") + end + + it "the drop-in is applied" do + stdout, _, _ = container.exec_as_user("systemctl --user show testapp-web@5000.service -p Environment") + expect(stdout).to include("EXTRA=value") + end + end + + describe "error handling" do + it "raises error when include-dir is not a directory" do + stdout, _, status = container.exec_as_user( + "cd /home/testuser/app && bundle exec foreman export systemd-user --app testapp --include-dir /nonexistent 2>&1" + ) + expect(status).not_to be_success + expect(stdout).to include("not a directory") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..725a178 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,102 @@ +require "open3" +require "fileutils" +require "tmpdir" + +module SystemdContainer + DOCKERFILE_PATH = File.expand_path("Dockerfile.systemd", __dir__) + IMAGE_NAME = "foreman-export-systemd-user-test" + CONTAINER_NAME = "foreman-export-systemd-user-test-container" + TEST_USER = "testuser" + + class << self + def start + build_image + stop + + cmd = [ + "podman", "run", "-d", + "--name", CONTAINER_NAME, + "--privileged", + "-v", "/sys/fs/cgroup:/sys/fs/cgroup:rw", + IMAGE_NAME + ] + run_command(cmd) + wait_for_systemd + end + + def stop + system("podman", "stop", CONTAINER_NAME, out: File::NULL, err: File::NULL) + system("podman", "rm", CONTAINER_NAME, out: File::NULL, err: File::NULL) + end + + def exec(*command) + cmd = ["podman", "exec", CONTAINER_NAME] + command.flatten + stdout, stderr, status = Open3.capture3(*cmd) + [stdout, stderr, status] + end + + def exec_as_user(command) + exec("su", "-", TEST_USER, "-c", command) + end + + def user_systemd_dir + "/home/#{TEST_USER}/.config/systemd/user" + end + + def copy_to_container(src, dest) + run_command(["podman", "cp", src, "#{CONTAINER_NAME}:#{dest}"]) + end + + def copy_gem_to_container(dest = "/tmp/gem") + gem_root = File.expand_path("../..", __FILE__) + + # Copy entire gem directory (including .git for gemspec) + copy_to_container(gem_root, dest) + + # Remove Gemfile.lock to avoid bundler version mismatch + exec("rm", "-f", "#{dest}/Gemfile.lock") + + # Fix permissions so test user can read the gem files + exec("chmod", "-R", "a+rX", dest) + + # Fix git safe.directory for root and test user + exec("git", "config", "--global", "--add", "safe.directory", dest) + exec_as_user("git config --global --add safe.directory #{dest}") + + dest + end + + private + + def run_command(cmd) + stdout, stderr, status = Open3.capture3(*cmd) + raise "Command failed: #{cmd.join(' ')}\n#{stderr}" unless status.success? + stdout + end + + def build_image + run_command(["podman", "build", "-t", IMAGE_NAME, "-f", DOCKERFILE_PATH, File.dirname(DOCKERFILE_PATH)]) + end + + def wait_for_systemd(timeout: 30) + deadline = Time.now + timeout + loop do + stdout, _, status = exec("systemctl", "is-system-running") + state = stdout.strip + break if %w[running degraded].include?(state) + raise "Timed out waiting for systemd" if Time.now > deadline + sleep 0.5 + end + end + end +end + +RSpec.configure do |config| + config.before(:suite) do + SystemdContainer.start + end + + config.after(:suite) do + SystemdContainer.stop + end +end From 4ad8f3d8af616c6453139f887d11042a81f36fae Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 12:15:50 -0600 Subject: [PATCH 5/6] we need to hook into the binary itself in order to extend the cli options, due to load order. --- bin/foreman | 14 ++++++++++++++ bin/setup | 10 ++++++++++ lib/foreman/export/systemd_user.rb | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100755 bin/foreman create mode 100755 bin/setup diff --git a/bin/foreman b/bin/foreman new file mode 100755 index 0000000..5f4263a --- /dev/null +++ b/bin/foreman @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "rubygems" +require "foreman/cli" +require "foreman-export-systemd_user" + +# Add --include-dir option to foreman export command +Foreman::CLI.class_eval do + option = Thor::Option.new(:include_dir, type: :string, desc: "Directory of additional systemd files to copy") + export_command = commands["export"] + export_command.options[:include_dir] = option +end + +Foreman::CLI.start diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..cf6b980 --- /dev/null +++ b/bin/setup @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Installing gem dependencies..." +bundle install + +echo "==> Building test container image..." +podman build -t foreman-export-systemd-user-test -f spec/Dockerfile.systemd spec/ + +echo "==> Setup complete! Run 'bundle exec rake spec' to run tests." diff --git a/lib/foreman/export/systemd_user.rb b/lib/foreman/export/systemd_user.rb index 97c3627..a481f9c 100644 --- a/lib/foreman/export/systemd_user.rb +++ b/lib/foreman/export/systemd_user.rb @@ -5,8 +5,9 @@ class Foreman::Export::SystemdUser < Foreman::Export::Base TEMPLATE_DIR = File.expand_path("../../../../data/export/systemd_user", __FILE__) def initialize(location, engine, options = {}) + options = options.dup options[:template] ||= TEMPLATE_DIR - super + super(location, engine, options) end def app From 3ac64dd885df47f5a8434388a85b361c7ed9675c Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Fri, 2 Jan 2026 12:20:58 -0600 Subject: [PATCH 6/6] set up GitHub Actions CI. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..455bc7b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.0" + bundler-cache: true + + - name: Install podman + run: | + sudo apt-get update + sudo apt-get install -y podman + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rspec