385 lines
9.5 KiB
Ruby
385 lines
9.5 KiB
Ruby
require "active_support/ordered_options"
|
|
require "active_support/core_ext/string/inquiry"
|
|
require "active_support/core_ext/module/delegation"
|
|
require "active_support/core_ext/hash/keys"
|
|
require "erb"
|
|
require "net/ssh/proxy/jump"
|
|
|
|
class Kamal::Configuration
|
|
delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true
|
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
|
|
|
attr_reader :destination, :raw_config, :secrets
|
|
attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :servers, :ssh, :sshkit, :registry
|
|
|
|
include Validation
|
|
|
|
PROXY_MINIMUM_VERSION = "v0.3.0"
|
|
PROXY_HTTP_PORT = 80
|
|
PROXY_HTTPS_PORT = 443
|
|
|
|
class << self
|
|
def create_from(config_file:, destination: nil, version: nil)
|
|
raw_config = load_config_files(config_file, *destination_config_file(config_file, destination))
|
|
|
|
new raw_config, destination: destination, version: version
|
|
end
|
|
|
|
private
|
|
def load_config_files(*files)
|
|
files.inject({}) { |config, file| config.deep_merge! load_config_file(file) }
|
|
end
|
|
|
|
def load_config_file(file)
|
|
if file.exist?
|
|
# Newer Psych doesn't load aliases by default
|
|
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
|
|
YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys
|
|
else
|
|
raise "Configuration file not found in #{file}"
|
|
end
|
|
end
|
|
|
|
def destination_config_file(base_config_file, destination)
|
|
base_config_file.sub_ext(".#{destination}.yml") if destination
|
|
end
|
|
end
|
|
|
|
def initialize(raw_config, destination: nil, version: nil, validate: true)
|
|
@raw_config = ActiveSupport::InheritableOptions.new(raw_config)
|
|
@destination = destination
|
|
@declared_version = version
|
|
|
|
validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration
|
|
|
|
@secrets = Kamal::Secrets.new(destination: destination)
|
|
|
|
# Eager load config to validate it, these are first as they have dependencies later on
|
|
@servers = Servers.new(config: self)
|
|
@registry = Registry.new(config: self)
|
|
|
|
@accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
|
|
@aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
|
|
@boot = Boot.new(config: self)
|
|
@builder = Builder.new(config: self)
|
|
@env = Env.new(config: @raw_config.env || {}, secrets: secrets)
|
|
|
|
@logging = Logging.new(logging_config: @raw_config.logging)
|
|
@proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy || {})
|
|
@ssh = Ssh.new(config: self)
|
|
@sshkit = Sshkit.new(config: self)
|
|
|
|
ensure_destination_if_required
|
|
ensure_required_keys_present
|
|
ensure_valid_kamal_version
|
|
ensure_retain_containers_valid
|
|
ensure_valid_service_name
|
|
ensure_no_traefik_reboot_hooks
|
|
ensure_one_host_for_ssl_roles
|
|
ensure_unique_hosts_for_ssl_roles
|
|
end
|
|
|
|
|
|
def version=(version)
|
|
@declared_version = version
|
|
end
|
|
|
|
def version
|
|
@declared_version.presence || ENV["VERSION"] || git_version
|
|
end
|
|
|
|
def abbreviated_version
|
|
if version
|
|
# Don't abbreviate <sha>_uncommitted_<etc>
|
|
if version.include?("_")
|
|
version
|
|
else
|
|
version[0...7]
|
|
end
|
|
end
|
|
end
|
|
|
|
def minimum_version
|
|
raw_config.minimum_version
|
|
end
|
|
|
|
|
|
def roles
|
|
servers.roles
|
|
end
|
|
|
|
def role(name)
|
|
roles.detect { |r| r.name == name.to_s }
|
|
end
|
|
|
|
def accessory(name)
|
|
accessories.detect { |a| a.name == name.to_s }
|
|
end
|
|
|
|
|
|
def all_hosts
|
|
(roles + accessories).flat_map(&:hosts).uniq
|
|
end
|
|
|
|
def primary_host
|
|
primary_role&.primary_host
|
|
end
|
|
|
|
def primary_role_name
|
|
raw_config.primary_role || "web"
|
|
end
|
|
|
|
def primary_role
|
|
role(primary_role_name)
|
|
end
|
|
|
|
def allow_empty_roles?
|
|
raw_config.allow_empty_roles
|
|
end
|
|
|
|
def proxy_roles
|
|
roles.select(&:running_proxy?)
|
|
end
|
|
|
|
def proxy_role_names
|
|
proxy_roles.flat_map(&:name)
|
|
end
|
|
|
|
def proxy_hosts
|
|
proxy_roles.flat_map(&:hosts).uniq
|
|
end
|
|
|
|
def repository
|
|
[ registry.server, image ].compact.join("/")
|
|
end
|
|
|
|
def absolute_image
|
|
"#{repository}:#{version}"
|
|
end
|
|
|
|
def latest_image
|
|
"#{repository}:#{latest_tag}"
|
|
end
|
|
|
|
def latest_tag
|
|
[ "latest", *destination ].join("-")
|
|
end
|
|
|
|
def service_with_version
|
|
"#{service}-#{version}"
|
|
end
|
|
|
|
def require_destination?
|
|
raw_config.require_destination
|
|
end
|
|
|
|
def retain_containers
|
|
raw_config.retain_containers || 5
|
|
end
|
|
|
|
|
|
def volume_args
|
|
if raw_config.volumes.present?
|
|
argumentize "--volume", raw_config.volumes
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
def logging_args
|
|
logging.args
|
|
end
|
|
|
|
|
|
def readiness_delay
|
|
raw_config.readiness_delay || 7
|
|
end
|
|
|
|
def deploy_timeout
|
|
raw_config.deploy_timeout || 30
|
|
end
|
|
|
|
def drain_timeout
|
|
raw_config.drain_timeout || 30
|
|
end
|
|
|
|
|
|
def run_directory
|
|
".kamal"
|
|
end
|
|
|
|
def apps_directory
|
|
File.join run_directory, "apps"
|
|
end
|
|
|
|
def app_directory
|
|
File.join apps_directory, [ service, destination ].compact.join("-")
|
|
end
|
|
|
|
def proxy_directory
|
|
File.join run_directory, "proxy"
|
|
end
|
|
|
|
def env_directory
|
|
File.join app_directory, "env"
|
|
end
|
|
|
|
def assets_directory
|
|
File.join app_directory, "assets"
|
|
end
|
|
|
|
|
|
def hooks_path
|
|
raw_config.hooks_path || ".kamal/hooks"
|
|
end
|
|
|
|
def asset_path
|
|
raw_config.asset_path
|
|
end
|
|
|
|
|
|
def env_tags
|
|
@env_tags ||= if (tags = raw_config.env["tags"])
|
|
tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
def env_tag(name)
|
|
env_tags.detect { |t| t.name == name.to_s }
|
|
end
|
|
|
|
def proxy_publish_args
|
|
argumentize "--publish", [ "#{PROXY_HTTP_PORT}:#{PROXY_HTTP_PORT}", "#{PROXY_HTTPS_PORT}:#{PROXY_HTTPS_PORT}" ]
|
|
end
|
|
|
|
def proxy_image
|
|
"basecamp/kamal-proxy:#{PROXY_MINIMUM_VERSION}"
|
|
end
|
|
|
|
def proxy_container_name
|
|
"kamal-proxy"
|
|
end
|
|
|
|
def proxy_config_volume
|
|
Kamal::Configuration::Volume.new \
|
|
host_path: File.join(proxy_directory, "config"),
|
|
container_path: "/home/kamal-proxy/.config/kamal-proxy"
|
|
end
|
|
|
|
|
|
def to_h
|
|
{
|
|
roles: role_names,
|
|
hosts: all_hosts,
|
|
primary_host: primary_host,
|
|
version: version,
|
|
repository: repository,
|
|
absolute_image: absolute_image,
|
|
service_with_version: service_with_version,
|
|
volume_args: volume_args,
|
|
ssh_options: ssh.to_h,
|
|
sshkit: sshkit.to_h,
|
|
builder: builder.to_h,
|
|
accessories: raw_config.accessories,
|
|
logging: logging_args
|
|
}.compact
|
|
end
|
|
|
|
private
|
|
# Will raise ArgumentError if any required config keys are missing
|
|
def ensure_destination_if_required
|
|
if require_destination? && destination.nil?
|
|
raise ArgumentError, "You must specify a destination"
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_required_keys_present
|
|
%i[ service image registry servers ].each do |key|
|
|
raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present?
|
|
end
|
|
|
|
unless role(primary_role_name).present?
|
|
raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined"
|
|
end
|
|
|
|
if primary_role.hosts.empty?
|
|
raise Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role"
|
|
end
|
|
|
|
unless allow_empty_roles?
|
|
roles.each do |role|
|
|
if role.hosts.empty?
|
|
raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true"
|
|
end
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_valid_service_name
|
|
raise Kamal::ConfigurationError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_valid_kamal_version
|
|
if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION)
|
|
raise Kamal::ConfigurationError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}"
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_retain_containers_valid
|
|
raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_no_traefik_reboot_hooks
|
|
hooks = %w[ pre-traefik-reboot post-traefik-reboot ].select { |hook_file| File.exist?(File.join(hooks_path, hook_file)) }
|
|
|
|
if hooks.any?
|
|
raise Kamal::ConfigurationError, "Found #{hooks.join(", ")}, these should be renamed to (pre|post)-proxy-reboot"
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_one_host_for_ssl_roles
|
|
roles.each(&:ensure_one_host_for_ssl)
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_unique_hosts_for_ssl_roles
|
|
hosts = roles.select(&:ssl?).map { |role| role.proxy.host }
|
|
duplicates = hosts.tally.filter_map { |host, count| host if count > 1 }
|
|
|
|
raise Kamal::ConfigurationError, "Different roles can't share the same host for SSL: #{duplicates.join(", ")}" if duplicates.any?
|
|
|
|
true
|
|
end
|
|
|
|
def role_names
|
|
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
|
|
end
|
|
|
|
def git_version
|
|
@git_version ||=
|
|
if Kamal::Git.used?
|
|
if Kamal::Git.uncommitted_changes.present? && !builder.git_clone?
|
|
uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}"
|
|
end
|
|
[ Kamal::Git.revision, uncommitted_suffix ].compact.join
|
|
else
|
|
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
|
|
end
|
|
end
|
|
end
|