Ruby script to sync users from LDAP to the leihs-API

Since leihs lacks the feature to dynamically create users based on an identity provider during login, the user must already exist in the DB before the user’s first login. Therefore it is up to you to sync the relevant user details from your source of choice to the leihs-API. This is my implementation for a Ruby user sync script from LDAP to leihs.

You probably never heard of leihs. Leihs is an open source inventory management, procurement and rental system, primarily for educational institutions. It’s free, it’s actively maintained, it works and has all the features we need from such a system. What it lacks, are integrations, in our case automatic user synchronisation.

Since I had a bit of extra time when setting up our leihs instance, I took the opportunity to develop the user synchronisation script in Ruby (which I was and still am learning), and deploy it on an OKD Kubernetes cluster to run as a Kubernetes CronJob every day. The script is based on the leihs developer’s reference leihs-sync implementation written in Clojure. It can run interactively, requiring user input for credentials and other parameters, or it can be run without user interaction using EVN VARs.

The script works as it is, it certainly could be improved, both in code quality, functionality and whatnot, but as with most internal projects, it’s enough that it does what it does reliably. Keep in mind, that I was scripting this as I was learning Ruby, so it might not the best and most idiomatic Ruby code in the world.

Requirements

  • Ruby 2.X or 3.X (you need to (un)comment one line depending on version due to function changes)
  • Somewhere to run it (PC, server, Kubernetes, Docker, etc.)
  • Bind-credentials for LDAP, api-token for the leihs-API
  • Set either to interactive mode, or specify all required ENV VARs

What the script does

  1. Creates class definitions and methods
  2. Retrieves the parameters either from the user or from ENV VARs
  3. Fetches and parses relevant users from LDAP
  4. Fetches and parses users from the leihs-API
  5. Posts new users to the leihs-API
  6. Updates existing users in the leihs-API, if the option is enabled
  7. Deletes the users from leihs-API who do not exist in LDAP anymore
  8. Disables the users in the leihs-API who could not be deleted due to dependencies (existing contracts, etc.)

Code

The repo (with everything needed) is available on GitLab, but the script itself and the BuildConfig is also included below:

The script

# LDAP --> Leihs (API) user sync script.
# Based on the leihs developer's reference leihs sync implementation written in clojure.
# https://github.com/functional-swiss/leihs-sync/blob/master/src/funswiss/leihs_sync/leihs/core.clj

#
# Imports
#

require 'rubygems'
require 'bundler/setup'
require 'net-ldap'
require 'httparty'
require 'json'
#require 'resolv-replace'

#
#  Object definitions
#

##
# Leihs user definition, attribute names correspond with the attribute names used by the leihs API
#
class User
	def initialize(login:, firstname:, lastname:, email:, badge_id:, id:, account_enabled: nil, password_sign_in_enabled: nil, is_admin: nil)
		@login = login
		@firstname = firstname
		@lastname = lastname
		@email = email
		@badge_id = badge_id
		@id = id
		@account_enabled = account_enabled
		@password_sign_in_enabled = password_sign_in_enabled
		@is_admin = is_admin
	end

	attr_accessor :login
	attr_accessor :firstname
	attr_accessor :lastname
	attr_accessor :email
	attr_accessor :badge_id

	# The leihs generated internal UUID
	attr_accessor :id
	attr_accessor :account_enabled
	attr_accessor :password_sign_in_enabled
	attr_accessor :is_admin

	def self.from_json_hash(entry)
		User.new(
			id: entry["id"],
			login: entry["login"],
			firstname: entry["firstname"],
			lastname: entry["lastname"],
			email: entry["email"],
			badge_id: entry["badge_id"],
			account_enabled: entry["account_enabled"],
			password_sign_in_enabled: entry["password_sign_in_enabled"],
			is_admin: entry["is_admin"])
	end

	def self.from_ldap_struct(entry)
		User.new(
			id: nil,
			login: entry.uid[0],
			firstname: entry.givenName[0],
			lastname: entry.sn[0],
			email: entry.mail[0],
			badge_id: entry.respond_to?(:employeeNumber) ? entry.employeeNumber[0] : nil,
			account_enabled: true,
			password_sign_in_enabled: true,
			is_admin: false)
	end

	def as_json(options={})
		{
			login: @login,
			firstname: @firstname,
			lastname: @lastname,
			email: @email,
			badge_id: @badge_id,
			id: @id,
			account_enabled: @account_enabled,
			password_sign_in_enabled: @password_sign_in_enabled,
			is_admin: @is_admin
		}
	end

	def to_json(*options)
		as_json(*options).to_json(*options)
	end
end

##
# LDAP client object
#
class LeihsLdap < Net::LDAP
	#TODO: Get LDAP parameters from ENV vars.
	def initialize(bind_username:, bind_password:,
					filter: "(&(objectClass=posixAccount)(|(memberOf=cn=SomeCN,ou=SomeOu,dc=company,dc=tld)(memberOf=cn=SomeOtherCN,ou=SomeOu,dc=company,dc=tld)))",
					treebase: "ou=people,dc=company,dc=tld",
					attributes: ["uid", "givenName", "sn", "mail", "employeeNumber"])

		@treebase = treebase
		@attributes = attributes
		@filter = Net::LDAP::Filter.construct(filter)

		super(:host => 'ldapserver.company.tld',
						:port => 636,
						:encryption => :simple_tls,
						:auth => {
							:method => :simple,
							:username => bind_username,
							:password => bind_password,
							})
	end

	attr_reader :filter
	attr_reader :treebase
	attr_reader :attributes
end

##
# HTTP/Rest API client object
#
class LeihsRestApi
	include HTTParty

	base_uri "https://leihs.company.tld"
	headers "User-Agent" => "LeihsSyncer *Ruby/HTTParty*"
	headers "Content-Type" => "application/json"
	headers "Accept" => "application/json"
	headers "Host" => "leihs.company.tld:443"

	##
	# Disables the Rails-style array encoding that's not compatible with the leihs API.
	# https://www.rubydoc.info/github/jnunemaker/httparty/HTTParty%2FClassMethods:disable_rails_query_string_format
	disable_rails_query_string_format

	##
	# Enable this to dump HTTP logs to stdout
	#debug_output $stdout

	def initialize(auth_token:, base_path: "/admin", fields: ["account_disabled_at", "account_enabled", "admin_protected", "badge_id", "email", "firstname", "id", "is_admin", "is_system_admin", "lastname", "login", "password_sign_in_enabled", "system_admin_protected"] )
		@base_path = base_path
		@fields = fields
	  @headers = self.class.headers.merge("Authorization" => "Token #{auth_token}")
	end

	def get_users(page: 1, fields: @fields, per_page: 1000, type: "any", term: "", is_admin: "", retries: 5, retry_wait: 2)
		self.class.get("#{@base_path}/users/", headers: @headers, query: { page: page, "per-page" => per_page, type: type, term: term, is_admin: is_admin, fields: fields })

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.get_users(page: page, fields: fields, per_page: per_page, type: type, term: term, is_admin: is_admin, retries: retries - 1, retry_wait: retry_wait * 2)
	end

	def head_users(page: 1, fields: @fields, per_page: 1000, type: "any", term: "", is_admin: "", retries: 5, retry_wait: 2)
		self.class.head("#{@base_path}/users/", headers: @headers, query: { page: page, "per-page" => per_page, type: type, term: term, is_admin: is_admin, fields: fields })

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.head_users(page: page, fields: fields, per_page: per_page, type: type, term: term, is_admin: is_admin, retries: retries - 1, retry_wait: retry_wait * 2)
	end

	def get_user(id:, retries: 5, retry_wait: 2)
		self.class.get("#{@base_path}/users/#{id}", headers: @headers)

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.get_user(id: id, retries: retries - 1, retry_wait: retry_wait * 2)
	end

	def post_user(body:, retries: 5, retry_wait: 2)
		self.class.post("#{@base_path}/users/", headers: @headers, body: body.to_json)

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.post_user(body: body, retries: retries - 1, retry_wait: retry_wait * 2)
	end

	def patch_user(id:, body:, retries: 5, retry_wait: 2)
		self.class.patch("#{@base_path}/users/#{id}", headers: @headers, body: body.to_json)

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.patch_user(id: id, body: body, retries: retries - 1 , retry_wait: retry_wait * 2)
	end

	def delete_user(id:, retries: 5, retry_wait: 2)
		self.class.delete("#{@base_path}/users/#{id}", headers: @headers)

	rescue Net::OpenTimeout => e
		raise if retries <= 1

		puts "!RETRIES #{retries}: #{e}"
		puts "!SLEEP #{retry_wait}s ..."
		sleep retry_wait

		self.delete_user(id: id, retries: retries - 1, retry_wait: retry_wait * 2)
	end
end


#
#  Methods
#

##
# Returns true if the user wants to exit the script.
def ask_exit
  puts "Continue? [Y/n]"
  exit_answ = STDIN.gets.chomp.strip.downcase

  !exit_answ.empty? && exit_answ != 'y'
end

##
# Asks the user to enter the value for parameter
def get_param_user param
  exit = false
  answer = nil

  until exit
    puts "Enter #{param}:"
    answer = STDIN.gets.chomp.strip

    puts "Did you enter the #{param} parameter correctly? [Y/n]"
    exit_answ = STDIN.gets.chomp.strip.downcase
	exit = exit_answ.empty? || exit_answ == "y"
  end

  return answer
end

##
# Returns a list of User objects from the specified LDAP, retries 5 times with increasing sleep times.
def get_users_ldap(leihs_ldap:, retries: 5, retry_wait: 2)
	# Technically you should use the method body callback to process and receive the entries but I'm simply to lazy to look that up.
	ldap_results = leihs_ldap.search(:base => leihs_ldap.treebase, :filter => leihs_ldap.filter, :attributes => leihs_ldap.attributes, :return_result => true) { |entry| }

	raise Net::LDAP::ResponseMissingOrInvalidError.new("Unknown query error, result code #{leihs_ldap.get_operation_result.code}: #{leihs_ldap.get_operation_result.message}") unless leihs_ldap.get_operation_result.code == 0

	puts "Query successfully returned #{ldap_results.size} entries."

	ldap_results.map{ |entry| [entry.uid[0], User.from_ldap_struct(entry)] }.to_h

rescue Errno::ECONNRESET, Net::LDAP::ConnectionError, Net::LDAP::SocketError, Net::LDAP::NoStartTLSResultError, Net::LDAP::NoBindResultError, Net::LDAP::ResponseMissingOrInvalidError, Net::LDAP::ResponseTypeInvalidError, Net::LDAP::SASLChallengeOverflowError => e
	raise if retries <= 1

	puts "!RETRIES #{retries}: #{e}"
	puts "!SLEEP #{retry_wait}s ..."
	sleep retry_wait

	get_users_ldap(leihs_ldap: leihs_ldap, retries: retries - 1, retry_wait: retry_wait * 2)
end

##
# Returns a list of User objects from the Leihs API.
#
# TODO: add retries and error handling like in get_users_ldap!
def get_users_api api
    page = 0
	users_json_arr = []
	exit = false

	until exit
	  page += 1
	  #begin
	    #TODO: exception handling!
	  response = api.get_users(page: page)
	  #end

	  if response.code == 200 && !response.parsed_response.nil?
	    users_body_arr = response.parsed_response["users"]

	    if !users_body_arr.empty?
	      users_json_arr = users_json_arr | users_body_arr
	    else
		  exit = true
	      puts "Retrieved #{page} pages, #{users_json_arr.size} JSON objects."
		end
	  else
	    puts "Invalid response code, aborting and throwing results away."
		p response
		users_json_arr = nil
		exit = true
	  end
	  #rescue
	  #  retry
	end

	results = users_json_arr.map{ |entry| [ entry["login"], User.from_json_hash(entry) ]}.to_h

	puts "Parsed #{results.size} users."

	return results
end


#
#  Script
#

# Base global parameters
header = [	' _           _  _           ____                                         ',
		'| |     ___ (_)| |__   ___ / ___|  _   _  _ __    ___  ___  _ __  __/\__ ',
		'| |    / _ \| || `_ \ / __|\___ \ | | | || ,_ \  / __|/ _ \| `__| \    / ',
		'| |___|  __/| || | | |\__ \ ___) || |_| || | | || (__|  __/| |    /_  _\ ',
		'|_____|\___||_||_| |_||___/|____/  \__, ||_| |_| \___|\___||_|      \/   ',
	        '                                   |___/                                 ',
                ' ____          _                __ _   _  _____  _____  ____               _                 ',
                '|  _ \  _   _ | |__   _   _    / /| | | ||_   _||_   _||  _ \  __ _  _ __ | |_  _   _ __/\__ ',
                '| |_) || | | || `_ \ | | | |  / / | |_| |  | |    | |  | |_) |/ _` || `__|| __|| | | |\    / ',
                '|  _ < | |_| || |_) || |_| | / /  |  _  |  | |    | |  |  __/| (_| || |   | |_ | |_| |/_  _\ ',
                '|_| \_\ \__,_||_.__/  \__, |/_/   |_| |_|  |_|    |_|  |_|    \__,_||_|    \__| \__, |  \/   ',
                '                      |___/                                                     |___/        ']
##
# You must specify these ENV VARS if you want to run without manual user input! Set SYNCER_AUTOPILOT=true and specify all ENV VARS.
#

# ENV VARs for the non-interactive mode (autopilot = true).
env_vars = {"SYNCER_AUTOPILOT" => nil, "SYNCER_BINDDN" => nil, "SYNCER_PASSWORD" => nil, "SYNCER_API_TOKEN" => nil, "SYNCER_SKIP_PATCH" => nil}.map{ |k,v| [k, ENV[k]]}.to_h
# If true, script runs non-interactively using the ENV VARs. If false it takes user inputs.
autopilot = env_vars["SYNCER_AUTOPILOT"].nil? ? false : env_vars["SYNCER_AUTOPILOT"].chomp.strip.downcase == "true"
# Bind credentials for the LDAP query.
bind_credentials = { :username => "", :password => ""}
# Token for the leihs API.
token = nil
# Users who should be admins.
admin_users = ["admin1", "sync-job-user", "admin2"]
# Users who should remain active in leihs, despite not existing in LDAP anymore.
keep_users = ["patrik.ploechl"]
log = {get_ldap: nil, get_api: nil, post_api: nil, patch_api: nil, delete_api: nil, patch_disable_api: nil}
# If true, the script does not update all users with their up-to-date information from LDAP (name changes, etc.). If false, users get updated but the scipt takes longer.
skip_patch = false
exit = false

# This is where the fun starts :)

# Write cool leihs header to console
header.each { |line| puts line }
puts ""

# Retrieve (from the user from the console or over ENV VARS) LDAP credentials, Leihs API token and whether to skip the patching of existing users.
# for ruby 3 the syntax and some functions changed, uncomment this if, comment the if for ruby 2!
#if autopilot && env_vars.except("SYNCER_AUTOPILOT").any? { |_, v| v.nil? }
# for ruby 2
if autopilot && env_vars.reject{ |key, _| key == "SYNCER_AUTOPILOT" }.any?{ |_, v| v.nil? }
	# Aborting since some ENV VARS are missing.
	puts "If SYNCER_AUTOPILOT is set all other env vars must be set #{env_vars.keys.to_s}"
	Kernel.exit(1)
elsif autopilot
	# Running on ENV VARS
	puts "Running on autopilot..."
	bind_credentials[:username] = env_vars["SYNCER_BINDDN"]
	bind_credentials[:password] = env_vars["SYNCER_PASSWORD"]
	token = env_vars["SYNCER_API_TOKEN"]
	skip_patch = env_vars["SYNCER_SKIP_PATCH"].chomp.strip.downcase == "true"
else
	# Running on user input
	puts "Running in interactive mode..."
	exit = false

	until exit
		bind_credentials.each { |key, value| value.replace get_param_user key.to_s }
		exit = !ask_exit
	end

	exit = false

	until exit
		token = get_param_user "leihs token"
		exit = !ask_exit
	end

	exit = false

	until exit
		skip_answ = get_param_user("?skip_patch[true|false]?").downcase
		skip_patch = skip_answ == "true"
		exit = !ask_exit && (skip_answ == "true" || skip_answ == "false")
	end
end

# List users who won't be disabled or deleted, even if they are not in ldap. Admins get admin rights assigned.
puts "Admins: #{admin_users.to_s}"
puts "Users to keep: #{keep_users.to_s}"

Kernel.exit(0) if !autopilot && ask_exit

#
# 1. Fetch users from LDAP
#
puts ""
puts "Fetch users from LDAP:"

leihs_ldap = LeihsLdap.new(bind_username: bind_credentials[:username], bind_password: bind_credentials[:password])

puts "Attributes: #{leihs_ldap.attributes}"
puts "Filter: #{leihs_ldap.filter}"

users_ldap = get_users_ldap(leihs_ldap: leihs_ldap)
log[:get_ldap] = users_ldap.keys

puts "Results:"
puts log[:get_ldap].to_s

# Set all admins as admins
(users_ldap.keys & admin_users).each { |key| users_ldap[key].is_admin = true }

Kernel.exit(0) if !autopilot && ask_exit

#
# 2.1. Fetch users from API
#
puts ""
puts "Fetch users from API:"

api = LeihsRestApi.new(auth_token: token)
users_api = get_users_api(api)
log[:get_api] = users_api.keys

puts "Results:"
puts log[:get_api].to_s
Kernel.exit(0) if !autopilot && ask_exit

#
# 2.2. Post new users to API
#
puts ""
puts "Post new users to API:"

new_user_keys = users_ldap.keys - users_api.keys

log[:post_api] = {}

new_user_keys.each do |key|
	res = api.post_user(body: users_ldap[key])
	log[:post_api][key] = res.code
end

puts "Results:"
puts log[:post_api].to_s
Kernel.exit(0) if !autopilot && ask_exit

#
# 2.3. Patch existing users in API
#
puts ""
puts "Patch existing users in API:"

if skip_patch
	puts "Skipped as per ENV VAR or user request!"
else
	patch_user_keys = users_ldap.keys & users_api.keys

	patch_user_keys.each do |key|
		users_ldap[key].id = users_api[key].id
		users_ldap[key].is_admin = users_api[key].is_admin
		users_ldap[key].account_enabled = users_api[key].account_enabled
		users_ldap[key].password_sign_in_enabled = users_api[key].password_sign_in_enabled
	end

	log[:patch_api] = {}

	patch_user_keys.each do |key|
		res = api.patch_user(id: users_ldap[key].id, body: users_ldap[key])
		log[:patch_api][key] = res.code
	end

	puts "Results:"
	puts log[:patch_api].to_s
	Kernel.exit(0) if !autopilot && ask_exit
end

#
# 2.4. Delete users who are in Leihs (API) but are not in LDAP anymore:
#
puts ""
puts "Delete users who aren't in LDAP anymore:"
puts "Keeping #{admin_users} and #{keep_users}"

delete_user_keys = (users_api.keys - users_ldap.keys) - (admin_users | keep_users)
log[:delete_api] = {}

delete_user_keys.each do |key|
  res = api.delete_user(id: users_api[key].id)
  log[:delete_api][key] = res.code
end

puts "Results:"
puts log[:delete_api].to_s
Kernel.exit(0) if !autopilot && ask_exit

#
# 2.5. Disable users who aren't in LDAP and cannot be deleted due to dependencies:
#
puts ""
puts "Disable users who aren't in LDAP and cannot be deleted due to dependencies:"

disable_user_keys = log[:delete_api].select{ |key, response_code| response_code == 409 }.keys
log[:patch_disable_api] = {}

disable_user_keys.each do |key|
  res = api.patch_user(id: users_api[key].id, body: { account_enabled: false, password_sign_in_enabled: false })
  log[:patch_disable_api][key] = res.code
end

puts "Results:"
puts log[:patch_disable_api].to_s
Kernel.exit(0) if !autopilot && ask_exit

puts "Goodbye :)" unless autopilot

The YAML object definitions

#
# YAML definitions to run the script every day using a K8s CronJob to update the leihs API.

#
# manual test possible: oc create job --from=cronjob/ruby-leihssyncer-s2i syncer-manual-test -n leihssyncer-job

kind: Secret
apiVersion: v1
metadata:
  name: "ruby-leihssyncer-ldap"
  namespace: "leihssyncer-job"
  annotations:
    app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
  labels:
    app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
    app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
type: Opaque
stringData:
  SYNCER_API_TOKEN: ""
  SYNCER_BINDDN: ""
  SYNCER_PASSWORD: ""
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: "ruby-leihssyncer-conf"
  namespace: "leihssyncer-job"
  annotations:
    app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
  labels:
    app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
    app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
data:
  SYNCER_AUTOPILOT: "true"
  SYNCER_SKIP_PATCH: "false"
---
kind: CronJob
apiVersion: batch/v1
metadata:
  name: "ruby-leihssyncer-s2i"
  namespace: "leihssyncer-job"
  annotations:
    app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
  labels:
    app.kubernetes.io/part-of: "ruby-leihssyncer-s2i"
    app.openshift.io/runtime: ruby
    app.openshift.io/runtime-version: "2.7-ubi8"
    app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
spec:
  schedule: "15 5 * * 1"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 10
  failedJobsHistoryLimit: 10
  jobTemplate:
    metadata:
      annotations:
        app.openshift.io/vcs-ref: ""
        app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
      labels:
        app.openshift.io/runtime: ruby
        app.openshift.io/runtime-version: 2.7-ubi8
        app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
    spec:
      backoffLimit: 0
      template:
        metadata:
          annotations:
            app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
          labels:
            app.openshift.io/runtime: ruby
            app.openshift.io/runtime-version: 2.7-ubi8
            app.kubernetes.io/instance: "ruby-leihssyncer-s2i"
        spec:
          containers:
            - name: "ruby-leihssyncer-s2i"
              image: "image-registry.openshift-image-registry.svc:5000/project-of-imagestream/ruby-leihssyncer-s2i:stable"
              imagePullPolicy: Always
              command: ["bundle", "exec", "ruby", "leihs_syncer.rb"]
              envFrom:
                - secretRef:
                    name: ruby-leihssyncer-ldap
                - configMapRef:
                    name: ruby-leihssyncer-conf
          restartPolicy: "Never"
          terminationGracePeriodSeconds: 30
          activeDeadlineSeconds: 500
          dnsPolicy: "ClusterFirst"

The BuildConfig

kind: BuildConfig
apiVersion: build.openshift.io/v1
metadata:
  name: ruby-leihssyncer-s2i
  namespace: project-of-imagestream
  annotations:
    app.openshift.io/vcs-ref: ""
    app.openshift.io/vcs-uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
  labels:
    app.openshift.io/runtime: ruby
spec:
  failedBuildsHistoryLimit: 5
  output:
    to:
      kind: ImageStreamTag
      name: "ruby-leihssyncer-s2i:latest"
  runPolicy: Serial
  source:
    contextDir: /docker/
    git:
      uri: "https://git.company.tld/group/ruby-leihssyncer-s2i-repo"
    sourceSecret:
      name: "k8s-secret-containing-your-git-access-token"
    type: Git
  strategy:
    sourceStrategy:
      from:
        kind: ImageStreamTag
        name: ruby:2.7-ubi8
        namespace: namespace-of-ruby-imagestream
    type: Source
  successfulBuildsHistoryLimit: 5
  triggers:
  - type: ImageChange
  - type: ConfigChange

References