kamal 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1021 -0
  4. data/bin/kamal +18 -0
  5. data/lib/kamal/cli/accessory.rb +239 -0
  6. data/lib/kamal/cli/app.rb +296 -0
  7. data/lib/kamal/cli/base.rb +171 -0
  8. data/lib/kamal/cli/build.rb +106 -0
  9. data/lib/kamal/cli/healthcheck.rb +20 -0
  10. data/lib/kamal/cli/lock.rb +37 -0
  11. data/lib/kamal/cli/main.rb +249 -0
  12. data/lib/kamal/cli/prune.rb +30 -0
  13. data/lib/kamal/cli/registry.rb +18 -0
  14. data/lib/kamal/cli/server.rb +21 -0
  15. data/lib/kamal/cli/templates/deploy.yml +74 -0
  16. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  17. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  18. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  19. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
  20. data/lib/kamal/cli/templates/template.env +2 -0
  21. data/lib/kamal/cli/traefik.rb +111 -0
  22. data/lib/kamal/cli.rb +7 -0
  23. data/lib/kamal/commander.rb +154 -0
  24. data/lib/kamal/commands/accessory.rb +113 -0
  25. data/lib/kamal/commands/app.rb +175 -0
  26. data/lib/kamal/commands/auditor.rb +28 -0
  27. data/lib/kamal/commands/base.rb +65 -0
  28. data/lib/kamal/commands/builder/base.rb +60 -0
  29. data/lib/kamal/commands/builder/multiarch/remote.rb +51 -0
  30. data/lib/kamal/commands/builder/multiarch.rb +29 -0
  31. data/lib/kamal/commands/builder/native/cached.rb +16 -0
  32. data/lib/kamal/commands/builder/native/remote.rb +59 -0
  33. data/lib/kamal/commands/builder/native.rb +20 -0
  34. data/lib/kamal/commands/builder.rb +62 -0
  35. data/lib/kamal/commands/docker.rb +21 -0
  36. data/lib/kamal/commands/healthcheck.rb +57 -0
  37. data/lib/kamal/commands/hook.rb +14 -0
  38. data/lib/kamal/commands/lock.rb +63 -0
  39. data/lib/kamal/commands/prune.rb +38 -0
  40. data/lib/kamal/commands/registry.rb +20 -0
  41. data/lib/kamal/commands/traefik.rb +104 -0
  42. data/lib/kamal/commands.rb +2 -0
  43. data/lib/kamal/configuration/accessory.rb +169 -0
  44. data/lib/kamal/configuration/boot.rb +20 -0
  45. data/lib/kamal/configuration/builder.rb +114 -0
  46. data/lib/kamal/configuration/role.rb +155 -0
  47. data/lib/kamal/configuration/ssh.rb +38 -0
  48. data/lib/kamal/configuration/sshkit.rb +20 -0
  49. data/lib/kamal/configuration.rb +251 -0
  50. data/lib/kamal/sshkit_with_ext.rb +104 -0
  51. data/lib/kamal/tags.rb +39 -0
  52. data/lib/kamal/utils/healthcheck_poller.rb +39 -0
  53. data/lib/kamal/utils/sensitive.rb +19 -0
  54. data/lib/kamal/utils.rb +100 -0
  55. data/lib/kamal/version.rb +3 -0
  56. data/lib/kamal.rb +10 -0
  57. metadata +266 -0
@@ -0,0 +1,175 @@
1
+ class Kamal::Commands::App < Kamal::Commands::Base
2
+ ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
3
+
4
+ attr_reader :role
5
+
6
+ def initialize(config, role: nil)
7
+ super(config)
8
+ @role = role
9
+ end
10
+
11
+ def start_or_run(hostname: nil)
12
+ combine start, run(hostname: hostname), by: "||"
13
+ end
14
+
15
+ def run(hostname: nil)
16
+ role = config.role(self.role)
17
+
18
+ docker :run,
19
+ "--detach",
20
+ "--restart unless-stopped",
21
+ "--name", container_name,
22
+ *(["--hostname", hostname] if hostname),
23
+ "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
24
+ *role.env_args,
25
+ *role.health_check_args,
26
+ *config.logging_args,
27
+ *config.volume_args,
28
+ *role.label_args,
29
+ *role.option_args,
30
+ config.absolute_image,
31
+ role.cmd
32
+ end
33
+
34
+ def start
35
+ docker :start, container_name
36
+ end
37
+
38
+ def status(version:)
39
+ pipe container_id_for_version(version), xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
40
+ end
41
+
42
+ def stop(version: nil)
43
+ pipe \
44
+ version ? container_id_for_version(version) : current_running_container_id,
45
+ xargs(config.stop_wait_time ? docker(:stop, "-t", config.stop_wait_time) : docker(:stop))
46
+ end
47
+
48
+ def info
49
+ docker :ps, *filter_args
50
+ end
51
+
52
+
53
+ def logs(since: nil, lines: nil, grep: nil)
54
+ pipe \
55
+ current_running_container_id,
56
+ "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
57
+ ("grep '#{grep}'" if grep)
58
+ end
59
+
60
+ def follow_logs(host:, grep: nil)
61
+ run_over_ssh \
62
+ pipe(
63
+ current_running_container_id,
64
+ "xargs docker logs --timestamps --tail 10 --follow 2>&1",
65
+ (%(grep "#{grep}") if grep)
66
+ ),
67
+ host: host
68
+ end
69
+
70
+
71
+ def execute_in_existing_container(*command, interactive: false)
72
+ docker :exec,
73
+ ("-it" if interactive),
74
+ container_name,
75
+ *command
76
+ end
77
+
78
+ def execute_in_new_container(*command, interactive: false)
79
+ role = config.role(self.role)
80
+
81
+ docker :run,
82
+ ("-it" if interactive),
83
+ "--rm",
84
+ *config.env_args,
85
+ *config.volume_args,
86
+ *role&.option_args,
87
+ config.absolute_image,
88
+ *command
89
+ end
90
+
91
+ def execute_in_existing_container_over_ssh(*command, host:)
92
+ run_over_ssh execute_in_existing_container(*command, interactive: true), host: host
93
+ end
94
+
95
+ def execute_in_new_container_over_ssh(*command, host:)
96
+ run_over_ssh execute_in_new_container(*command, interactive: true), host: host
97
+ end
98
+
99
+
100
+ def current_running_container_id
101
+ docker :ps, "--quiet", *filter_args(statuses: ACTIVE_DOCKER_STATUSES), "--latest"
102
+ end
103
+
104
+ def container_id_for_version(version, only_running: false)
105
+ container_id_for(container_name: container_name(version), only_running: only_running)
106
+ end
107
+
108
+ def current_running_version
109
+ list_versions("--latest", statuses: ACTIVE_DOCKER_STATUSES)
110
+ end
111
+
112
+ def list_versions(*docker_args, statuses: nil)
113
+ pipe \
114
+ docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
115
+ %(while read line; do echo ${line##{service_role_dest}-}; done) # Extract SHA from "service-role-dest-SHA"
116
+ end
117
+
118
+ def list_containers
119
+ docker :container, :ls, "--all", *filter_args
120
+ end
121
+
122
+ def list_container_names
123
+ [ *list_containers, "--format", "'{{ .Names }}'" ]
124
+ end
125
+
126
+ def remove_container(version:)
127
+ pipe \
128
+ container_id_for(container_name: container_name(version)),
129
+ xargs(docker(:container, :rm))
130
+ end
131
+
132
+ def rename_container(version:, new_version:)
133
+ docker :rename, container_name(version), container_name(new_version)
134
+ end
135
+
136
+ def remove_containers
137
+ docker :container, :prune, "--force", *filter_args
138
+ end
139
+
140
+ def list_images
141
+ docker :image, :ls, config.repository
142
+ end
143
+
144
+ def remove_images
145
+ docker :image, :prune, "--all", "--force", *filter_args
146
+ end
147
+
148
+ def tag_current_as_latest
149
+ docker :tag, config.absolute_image, config.latest_image
150
+ end
151
+
152
+
153
+ private
154
+ def container_name(version = nil)
155
+ [ config.service, role, config.destination, version || config.version ].compact.join("-")
156
+ end
157
+
158
+ def filter_args(statuses: nil)
159
+ argumentize "--filter", filters(statuses: statuses)
160
+ end
161
+
162
+ def service_role_dest
163
+ [config.service, role, config.destination].compact.join("-")
164
+ end
165
+
166
+ def filters(statuses: nil)
167
+ [ "label=service=#{config.service}" ].tap do |filters|
168
+ filters << "label=destination=#{config.destination}" if config.destination
169
+ filters << "label=role=#{role}" if role
170
+ statuses&.each do |status|
171
+ filters << "status=#{status}"
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,28 @@
1
+ class Kamal::Commands::Auditor < Kamal::Commands::Base
2
+ attr_reader :details
3
+
4
+ def initialize(config, **details)
5
+ super(config)
6
+ @details = details
7
+ end
8
+
9
+ # Runs remotely
10
+ def record(line, **details)
11
+ append \
12
+ [ :echo, audit_tags(**details).except(:version, :service_version).to_s, line ],
13
+ audit_log_file
14
+ end
15
+
16
+ def reveal
17
+ [ :tail, "-n", 50, audit_log_file ]
18
+ end
19
+
20
+ private
21
+ def audit_log_file
22
+ [ "kamal", config.service, config.destination, "audit.log" ].compact.join("-")
23
+ end
24
+
25
+ def audit_tags(**details)
26
+ tags(**self.details, **details)
27
+ end
28
+ end
@@ -0,0 +1,65 @@
1
+ module Kamal::Commands
2
+ class Base
3
+ delegate :sensitive, :argumentize, to: Kamal::Utils
4
+
5
+ DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'"
6
+ DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
7
+
8
+ attr_accessor :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def run_over_ssh(*command, host:)
15
+ "ssh".tap do |cmd|
16
+ if config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Jump)
17
+ cmd << " -J #{config.ssh.proxy.jump_proxies}"
18
+ elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command)
19
+ cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
20
+ end
21
+ cmd << " -t #{config.ssh.user}@#{host} '#{command.join(" ")}'"
22
+ end
23
+ end
24
+
25
+ def container_id_for(container_name:, only_running: false)
26
+ docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
27
+ end
28
+
29
+ private
30
+ def combine(*commands, by: "&&")
31
+ commands
32
+ .compact
33
+ .collect { |command| Array(command) + [ by ] }.flatten # Join commands
34
+ .tap { |commands| commands.pop } # Remove trailing combiner
35
+ end
36
+
37
+ def chain(*commands)
38
+ combine *commands, by: ";"
39
+ end
40
+
41
+ def pipe(*commands)
42
+ combine *commands, by: "|"
43
+ end
44
+
45
+ def append(*commands)
46
+ combine *commands, by: ">>"
47
+ end
48
+
49
+ def write(*commands)
50
+ combine *commands, by: ">"
51
+ end
52
+
53
+ def xargs(command)
54
+ [ :xargs, command ].flatten
55
+ end
56
+
57
+ def docker(*args)
58
+ args.compact.unshift :docker
59
+ end
60
+
61
+ def tags(**details)
62
+ Kamal::Tags.from_config(config, **details)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,60 @@
1
+
2
+ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
3
+ class BuilderError < StandardError; end
4
+
5
+ delegate :argumentize, to: Kamal::Utils
6
+ delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, to: :builder_config
7
+
8
+ def clean
9
+ docker :image, :rm, "--force", config.absolute_image
10
+ end
11
+
12
+ def pull
13
+ docker :pull, config.absolute_image
14
+ end
15
+
16
+ def build_options
17
+ [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile ]
18
+ end
19
+
20
+ def build_context
21
+ config.builder.context
22
+ end
23
+
24
+
25
+ private
26
+ def build_tags
27
+ [ "-t", config.absolute_image, "-t", config.latest_image ]
28
+ end
29
+
30
+ def build_cache
31
+ if cache_to && cache_from
32
+ ["--cache-to", cache_to,
33
+ "--cache-from", cache_from]
34
+ end
35
+ end
36
+
37
+ def build_labels
38
+ argumentize "--label", { service: config.service }
39
+ end
40
+
41
+ def build_args
42
+ argumentize "--build-arg", args, sensitive: true
43
+ end
44
+
45
+ def build_secrets
46
+ argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
47
+ end
48
+
49
+ def build_dockerfile
50
+ if Pathname.new(File.expand_path(dockerfile)).exist?
51
+ argumentize "--file", dockerfile
52
+ else
53
+ raise BuilderError, "Missing #{dockerfile}"
54
+ end
55
+ end
56
+
57
+ def builder_config
58
+ config.builder
59
+ end
60
+ end
@@ -0,0 +1,51 @@
1
+ class Kamal::Commands::Builder::Multiarch::Remote < Kamal::Commands::Builder::Multiarch
2
+ def create
3
+ combine \
4
+ create_contexts,
5
+ create_local_buildx,
6
+ append_remote_buildx
7
+ end
8
+
9
+ def remove
10
+ combine \
11
+ remove_contexts,
12
+ super
13
+ end
14
+
15
+ private
16
+ def builder_name
17
+ super + "-remote"
18
+ end
19
+
20
+ def builder_name_with_arch(arch)
21
+ "#{builder_name}-#{arch}"
22
+ end
23
+
24
+ def create_local_buildx
25
+ docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local_arch), "--platform", "linux/#{local_arch}"
26
+ end
27
+
28
+ def append_remote_buildx
29
+ docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote_arch), "--platform", "linux/#{remote_arch}"
30
+ end
31
+
32
+ def create_contexts
33
+ combine \
34
+ create_context(local_arch, local_host),
35
+ create_context(remote_arch, remote_host)
36
+ end
37
+
38
+ def create_context(arch, host)
39
+ docker :context, :create, builder_name_with_arch(arch), "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'"
40
+ end
41
+
42
+ def remove_contexts
43
+ combine \
44
+ remove_context(local_arch),
45
+ remove_context(remote_arch)
46
+ end
47
+
48
+ def remove_context(arch)
49
+ docker :context, :rm, builder_name_with_arch(arch)
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ class Kamal::Commands::Builder::Multiarch < Kamal::Commands::Builder::Base
2
+ def create
3
+ docker :buildx, :create, "--use", "--name", builder_name
4
+ end
5
+
6
+ def remove
7
+ docker :buildx, :rm, builder_name
8
+ end
9
+
10
+ def push
11
+ docker :buildx, :build,
12
+ "--push",
13
+ "--platform", "linux/amd64,linux/arm64",
14
+ "--builder", builder_name,
15
+ *build_options,
16
+ build_context
17
+ end
18
+
19
+ def info
20
+ combine \
21
+ docker(:context, :ls),
22
+ docker(:buildx, :ls)
23
+ end
24
+
25
+ private
26
+ def builder_name
27
+ "kamal-#{config.service}-multiarch"
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ class Kamal::Commands::Builder::Native::Cached < Kamal::Commands::Builder::Native
2
+ def create
3
+ docker :buildx, :create, "--use", "--driver=docker-container"
4
+ end
5
+
6
+ def remove
7
+ docker :buildx, :rm, builder_name
8
+ end
9
+
10
+ def push
11
+ docker :buildx, :build,
12
+ "--push",
13
+ *build_options,
14
+ build_context
15
+ end
16
+ end
@@ -0,0 +1,59 @@
1
+ class Kamal::Commands::Builder::Native::Remote < Kamal::Commands::Builder::Native
2
+ def create
3
+ chain \
4
+ create_context,
5
+ create_buildx
6
+ end
7
+
8
+ def remove
9
+ chain \
10
+ remove_context,
11
+ remove_buildx
12
+ end
13
+
14
+ def push
15
+ docker :buildx, :build,
16
+ "--push",
17
+ "--platform", platform,
18
+ "--builder", builder_name,
19
+ *build_options,
20
+ build_context
21
+ end
22
+
23
+ def info
24
+ chain \
25
+ docker(:context, :ls),
26
+ docker(:buildx, :ls)
27
+ end
28
+
29
+
30
+ private
31
+ def builder_name
32
+ "kamal-#{config.service}-native-remote"
33
+ end
34
+
35
+ def builder_name_with_arch
36
+ "#{builder_name}-#{remote_arch}"
37
+ end
38
+
39
+ def platform
40
+ "linux/#{remote_arch}"
41
+ end
42
+
43
+ def create_context
44
+ docker :context, :create,
45
+ builder_name_with_arch, "--description", "'#{builder_name} #{remote_arch} native host'", "--docker", "'host=#{remote_host}'"
46
+ end
47
+
48
+ def remove_context
49
+ docker :context, :rm, builder_name_with_arch
50
+ end
51
+
52
+ def create_buildx
53
+ docker :buildx, :create, "--name", builder_name, builder_name_with_arch, "--platform", platform
54
+ end
55
+
56
+ def remove_buildx
57
+ docker :buildx, :rm, builder_name
58
+ end
59
+ end
@@ -0,0 +1,20 @@
1
+ class Kamal::Commands::Builder::Native < Kamal::Commands::Builder::Base
2
+ def create
3
+ # No-op on native without cache
4
+ end
5
+
6
+ def remove
7
+ # No-op on native without cache
8
+ end
9
+
10
+ def push
11
+ combine \
12
+ docker(:build, *build_options, build_context),
13
+ docker(:push, config.absolute_image),
14
+ docker(:push, config.latest_image)
15
+ end
16
+
17
+ def info
18
+ # No-op on native
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ class Kamal::Commands::Builder < Kamal::Commands::Base
2
+ delegate :create, :remove, :push, :clean, :pull, :info, to: :target
3
+
4
+ def name
5
+ target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry
6
+ end
7
+
8
+ def target
9
+ case
10
+ when !config.builder.multiarch? && !config.builder.cached?
11
+ native
12
+ when !config.builder.multiarch? && config.builder.cached?
13
+ native_cached
14
+ when config.builder.local? && config.builder.remote?
15
+ multiarch_remote
16
+ when config.builder.remote?
17
+ native_remote
18
+ else
19
+ multiarch
20
+ end
21
+ end
22
+
23
+ def native
24
+ @native ||= Kamal::Commands::Builder::Native.new(config)
25
+ end
26
+
27
+ def native_cached
28
+ @native ||= Kamal::Commands::Builder::Native::Cached.new(config)
29
+ end
30
+
31
+ def native_remote
32
+ @native ||= Kamal::Commands::Builder::Native::Remote.new(config)
33
+ end
34
+
35
+ def multiarch
36
+ @multiarch ||= Kamal::Commands::Builder::Multiarch.new(config)
37
+ end
38
+
39
+ def multiarch_remote
40
+ @multiarch_remote ||= Kamal::Commands::Builder::Multiarch::Remote.new(config)
41
+ end
42
+
43
+
44
+ def ensure_local_dependencies_installed
45
+ if name.native?
46
+ ensure_local_docker_installed
47
+ else
48
+ combine \
49
+ ensure_local_docker_installed,
50
+ ensure_local_buildx_installed
51
+ end
52
+ end
53
+
54
+ private
55
+ def ensure_local_docker_installed
56
+ docker "--version"
57
+ end
58
+
59
+ def ensure_local_buildx_installed
60
+ docker :buildx, "version"
61
+ end
62
+ end
@@ -0,0 +1,21 @@
1
+ class Kamal::Commands::Docker < Kamal::Commands::Base
2
+ # Install Docker using the https://github.com/docker/docker-install convenience script.
3
+ def install
4
+ pipe [ :curl, "-fsSL", "https://get.docker.com" ], :sh
5
+ end
6
+
7
+ # Checks the Docker client version. Fails if Docker is not installed.
8
+ def installed?
9
+ docker "-v"
10
+ end
11
+
12
+ # Checks the Docker server version. Fails if Docker is not running.
13
+ def running?
14
+ docker :version
15
+ end
16
+
17
+ # Do we have superuser access to install Docker and start system services?
18
+ def superuser?
19
+ [ '[ "${EUID:-$(id -u)}" -eq 0 ]' ]
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ class Kamal::Commands::Healthcheck < Kamal::Commands::Base
2
+ EXPOSED_PORT = 3999
3
+
4
+ def run
5
+ web = config.role(:web)
6
+
7
+ docker :run,
8
+ "--detach",
9
+ "--name", container_name_with_version,
10
+ "--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}",
11
+ "--label", "service=#{container_name}",
12
+ "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
13
+ *web.env_args,
14
+ *web.health_check_args,
15
+ *config.volume_args,
16
+ *web.option_args,
17
+ config.absolute_image,
18
+ web.cmd
19
+ end
20
+
21
+ def status
22
+ pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
23
+ end
24
+
25
+ def container_health_log
26
+ pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
27
+ end
28
+
29
+ def logs
30
+ pipe container_id, xargs(docker(:logs, "--tail", 50, "2>&1"))
31
+ end
32
+
33
+ def stop
34
+ pipe container_id, xargs(docker(:stop))
35
+ end
36
+
37
+ def remove
38
+ pipe container_id, xargs(docker(:container, :rm))
39
+ end
40
+
41
+ private
42
+ def container_name
43
+ [ "healthcheck", config.service, config.destination ].compact.join("-")
44
+ end
45
+
46
+ def container_name_with_version
47
+ "#{container_name}-#{config.version}"
48
+ end
49
+
50
+ def container_id
51
+ container_id_for(container_name: container_name_with_version)
52
+ end
53
+
54
+ def health_url
55
+ "http://localhost:#{EXPOSED_PORT}#{config.healthcheck["path"]}"
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ class Kamal::Commands::Hook < Kamal::Commands::Base
2
+ def run(hook, **details)
3
+ [ hook_file(hook), env: tags(**details).env ]
4
+ end
5
+
6
+ def hook_exists?(hook)
7
+ Pathname.new(hook_file(hook)).exist?
8
+ end
9
+
10
+ private
11
+ def hook_file(hook)
12
+ "#{config.hooks_path}/#{hook}"
13
+ end
14
+ end