chagall 0.0.1.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/Readme.md +54 -0
- data/bin/chagall +12 -0
- data/lib/chagall/base.rb +29 -0
- data/lib/chagall/cli.rb +130 -0
- data/lib/chagall/compose/main.rb +45 -0
- data/lib/chagall/deploy/main.rb +261 -0
- data/lib/chagall/install/main.rb +321 -0
- data/lib/chagall/install/templates/template.Dockerfile +37 -0
- data/lib/chagall/install/templates/template.compose.yaml +121 -0
- data/lib/chagall/settings.rb +232 -0
- data/lib/chagall/setup/main.rb +121 -0
- data/lib/chagall/ssh.rb +55 -0
- data/lib/chagall/version.rb +3 -0
- data/lib/chagall.rb +21 -0
- metadata +129 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 547bc828747060b2453d9d7fd12211ee7d0f0ffae43af892738f14b9e755f142
|
4
|
+
data.tar.gz: a144cb486c2a31e5a42c8000cf8194eac6d61754b8be185ad2993c3d26a954a6
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 204ca192df6ffe1e3af30dba6d11af3bee58378183926c0926583ccc0bf3a8fc0f09b6c295507c4b96df9d9a9432d1104b26268b2b92953917e971e0cdf1243b
|
7
|
+
data.tar.gz: 4fb4a59f77a8178917df4fefd5d5392e69a9e272f2e8c955e70e554899b9154a914e3e1c7d64576a8c384d8f4f38dd53b3b6cbb28a931d04cd23886d08211766
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2025 Roman Klevtsov @r3cha
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Readme.md
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# Chagall Deploy
|
2
|
+
|
3
|
+
## Project under active development
|
4
|
+
|
5
|
+
**Chagall Deploy** is a deployment tool for applications for staging and production single-server setups.
|
6
|
+
|
7
|
+
- [ ] Generates Docker and docker compose configurations based on your application’s dependencies
|
8
|
+
- Only for rails apps
|
9
|
+
- Don't require Container Registry like Docker Hub or ECR
|
10
|
+
- Detect dependecies and include services like
|
11
|
+
- Redis
|
12
|
+
- Sidekiq
|
13
|
+
- Mariadb, MySQL
|
14
|
+
- PostgreSQL
|
15
|
+
- MongoDB
|
16
|
+
- Elasticsearch
|
17
|
+
- Generate development and production environments with a single `compose.yaml`
|
18
|
+
- For production TLS/SSL using [reproxy](https://github.com/umputun/reproxy)
|
19
|
+
|
20
|
+
## Features
|
21
|
+
|
22
|
+
- **Dynamic Service Configuration**: Detects and includes services based on your application’s `Gemfile`, `package.json`,`.ruby-version` or `.node-version`.
|
23
|
+
- **Multi-Stage Docker Builds**: Uses a single Dockerfile for both development and production environments.
|
24
|
+
- **Unified `compose.yaml`**: Configures development and production profiles in one Compose file.
|
25
|
+
- **Quick Installation**: Installs with a single `curl` command, generating the necessary `Dockerfile`, `compose.yaml` and `bin/chagall` file.
|
26
|
+
|
27
|
+
## Installation
|
28
|
+
|
29
|
+
To install Chagall Deploy, run this command in your project root:
|
30
|
+
|
31
|
+
```bash
|
32
|
+
curl -sSL https://github.com/frontandstart/chagall-deploy/install.sh | bash
|
33
|
+
```
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
Generate compose and compose.prod.yaml
|
38
|
+
```bash
|
39
|
+
bin/chagall install
|
40
|
+
```
|
41
|
+
|
42
|
+
Setup server for deploy:
|
43
|
+
- install docker
|
44
|
+
- install reproxy(optional can be part of compose.prod.yaml) for signe compose per server deployments
|
45
|
+
```bash
|
46
|
+
bin/chagall setup
|
47
|
+
```
|
48
|
+
|
49
|
+
Deploy application
|
50
|
+
- Build image
|
51
|
+
- Trigger compose project update
|
52
|
+
```bash
|
53
|
+
bin/chagall deploy
|
54
|
+
```
|
data/bin/chagall
ADDED
data/lib/chagall/base.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
3
|
+
module Chagall
|
4
|
+
class Base
|
5
|
+
attr_reader :logger, :ssh
|
6
|
+
|
7
|
+
LOG_LEVELS = {
|
8
|
+
"info" => Logger::INFO,
|
9
|
+
"debug" => Logger::DEBUG,
|
10
|
+
"warn" => Logger::WARN,
|
11
|
+
"error" => Logger::ERROR
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@logger = Logger.new($stdout)
|
16
|
+
@logger.formatter = proc do |severity, _, _, msg|
|
17
|
+
if severity == "DEBUG"
|
18
|
+
"[#{severity}] #{msg}\n"
|
19
|
+
else
|
20
|
+
"#{msg}\n"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
@logger.level = LOG_LEVELS[ENV.fetch("LOG_LEVEL", "info")]
|
25
|
+
|
26
|
+
@ssh = SSH.new
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/chagall/cli.rb
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "clamp"
|
4
|
+
require_relative "settings"
|
5
|
+
require_relative "deploy/main"
|
6
|
+
require_relative "compose/main"
|
7
|
+
|
8
|
+
Clamp.allow_options_after_parameters = true
|
9
|
+
|
10
|
+
module Chagall
|
11
|
+
class Cli < Clamp::Command
|
12
|
+
def self.options_from_config_file
|
13
|
+
@options_from_config_file ||= begin
|
14
|
+
config_path = File.join(Dir.pwd, "chagall.yml") || File.join(Dir.pwd, "chagall.yaml")
|
15
|
+
return {} unless File.exist?(config_path)
|
16
|
+
|
17
|
+
config = YAML.load_file(config_path)
|
18
|
+
config.transform_keys(&:to_sym)
|
19
|
+
rescue StandardError => e
|
20
|
+
puts "Warning: Error loading chagall.yml: #{e.message}"
|
21
|
+
{}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Settings::OPTIONS.each do |opt|
|
26
|
+
if opt[:type] == :boolean
|
27
|
+
option opt[:flags], :flag, opt[:description],
|
28
|
+
default: options_from_config_file[opt[:key]] || opt[:default],
|
29
|
+
environment_variable: opt[:environment_variable]
|
30
|
+
elsif opt[:proc].is_a?(Proc)
|
31
|
+
option opt[:flags],
|
32
|
+
opt[:environment_variable].gsub("CHAGALL_"),
|
33
|
+
opt[:description],
|
34
|
+
default: options_from_config_file[opt[:key]] || opt[:default],
|
35
|
+
environment_variable: opt[:environment_variable] do |value|
|
36
|
+
opt[:proc].call(value)
|
37
|
+
end
|
38
|
+
else
|
39
|
+
option opt[:flags],
|
40
|
+
opt[:environment_variable].gsub("CHAGALL_"),
|
41
|
+
opt[:description],
|
42
|
+
default: options_from_config_file[opt[:key]] || opt[:default],
|
43
|
+
environment_variable: opt[:environment_variable]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
option "--version", :flag, "Show version" do
|
48
|
+
puts Chagall::VERSION
|
49
|
+
exit(0)
|
50
|
+
end
|
51
|
+
|
52
|
+
subcommand "deploy", "Deploy the application to the server" do
|
53
|
+
def execute
|
54
|
+
Chagall::Settings.configure(collect_options_hash)
|
55
|
+
Chagall::Deploy::Main.new
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
subcommand "setup", "Setup the server for deployment" do
|
60
|
+
def execute
|
61
|
+
Chagall::Settings.configure(collect_options_hash)
|
62
|
+
Chagall::Setup::Main.new
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
subcommand "compose", "Run Docker Compose commands with arguments passed through" do
|
67
|
+
# Override parse method to handle all arguments after the subcommand
|
68
|
+
def parse(arguments)
|
69
|
+
if arguments.empty?
|
70
|
+
puts "ERROR: Missing required arguments"
|
71
|
+
puts "Usage: chagall compose COMMAND [OPTIONS]"
|
72
|
+
exit(1)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Extract the first argument as command
|
76
|
+
@command = arguments.shift
|
77
|
+
|
78
|
+
# Store the rest as raw args
|
79
|
+
@raw_args = arguments
|
80
|
+
|
81
|
+
# Validate required arguments
|
82
|
+
if @command.nil? || @command.empty?
|
83
|
+
puts "ERROR: Command is required"
|
84
|
+
puts "Usage: chagall compose COMMAND [OPTIONS]"
|
85
|
+
exit(1)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def execute
|
90
|
+
Chagall::Settings.configure(collect_options_hash)
|
91
|
+
Chagall::Compose::Main.new(@command, @raw_args)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
subcommand "rollback", "Rollback to previous deployment" do
|
96
|
+
option [ "--steps" ], "STEPS", "Number of steps to rollback", default: "1" do |s|
|
97
|
+
Integer(s)
|
98
|
+
end
|
99
|
+
|
100
|
+
def execute
|
101
|
+
Chagall::Settings.configure(collect_options_hash)
|
102
|
+
puts "Rollback functionality not implemented yet"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def collect_options_hash
|
109
|
+
result = {}
|
110
|
+
|
111
|
+
self.class.recognised_options.each do |option|
|
112
|
+
name = option.attribute_name.to_sym
|
113
|
+
|
114
|
+
next if !respond_to?(name) && !respond_to?("#{name}?")
|
115
|
+
|
116
|
+
binding.irb if option.attribute_name == "context"
|
117
|
+
|
118
|
+
value = if option.type == :flag
|
119
|
+
send("#{name}?")
|
120
|
+
else
|
121
|
+
send(name)
|
122
|
+
end
|
123
|
+
|
124
|
+
result[name] = value unless value.nil?
|
125
|
+
end
|
126
|
+
|
127
|
+
result
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require_relative "../settings"
|
2
|
+
require_relative "../base"
|
3
|
+
|
4
|
+
module Chagall
|
5
|
+
module Compose
|
6
|
+
# Build and execute command usign docker compose on server
|
7
|
+
class Main < Base
|
8
|
+
attr_reader :command, :arguments
|
9
|
+
|
10
|
+
def initialize(command, args)
|
11
|
+
super()
|
12
|
+
@command = command
|
13
|
+
@arguments = args.join(" ") if args.is_a?(Array)
|
14
|
+
@arguments ||= args.to_s
|
15
|
+
|
16
|
+
raise Chagall::Error, "Command is required" if @command.nil? || @command.empty?
|
17
|
+
|
18
|
+
run_command
|
19
|
+
end
|
20
|
+
|
21
|
+
def run_command
|
22
|
+
cmd = "cd #{Settings.instance.project_folder_path} && #{build_docker_compose_command} #{@command}"
|
23
|
+
cmd << " #{arguments}" unless arguments.empty?
|
24
|
+
|
25
|
+
logger.debug "Executing: #{cmd}"
|
26
|
+
ssh.execute(cmd, tty: true)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def build_docker_compose_command
|
32
|
+
compose_files = Settings[:compose_files]
|
33
|
+
compose_cmd = [ "docker compose" ]
|
34
|
+
|
35
|
+
if compose_files && !compose_files.empty?
|
36
|
+
compose_files.each do |file|
|
37
|
+
compose_cmd << "-f #{File.basename(file)}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
compose_cmd.join(" ")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,261 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../settings"
|
4
|
+
require_relative "../ssh"
|
5
|
+
require "digest"
|
6
|
+
require "benchmark"
|
7
|
+
require "yaml"
|
8
|
+
|
9
|
+
module Chagall
|
10
|
+
module Deploy
|
11
|
+
class Main < Base
|
12
|
+
attr_reader :total_time
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
super()
|
16
|
+
@interrupted = false
|
17
|
+
@total_time = 0.0
|
18
|
+
setup_signal_handlers
|
19
|
+
Time.now
|
20
|
+
|
21
|
+
t("Checking uncommitted changes") { check_uncommit_changes } unless Settings[:skip_uncommit]
|
22
|
+
t("Check image or build") { check_image_or_build }
|
23
|
+
t("tag as production") { tag_as_production }
|
24
|
+
t("update compose files") { update_compose_files }
|
25
|
+
t("deploy compose files") { deploy_compose_files }
|
26
|
+
t("rotate release") { rotate_releases }
|
27
|
+
|
28
|
+
print_total_time
|
29
|
+
rescue Interrupt
|
30
|
+
logger.info "\nDeployment interrupted by user"
|
31
|
+
print_total_time
|
32
|
+
cleanup_and_exit
|
33
|
+
rescue StandardError => e
|
34
|
+
logger.error "Deployment failed: #{e.message}"
|
35
|
+
logger.debug e.backtrace.join("\n") if ENV["DEBUG"]
|
36
|
+
print_total_time
|
37
|
+
exit 1
|
38
|
+
end
|
39
|
+
|
40
|
+
def check_image_or_build
|
41
|
+
image_exists = verify_image(check_only: true)
|
42
|
+
|
43
|
+
if image_exists
|
44
|
+
logger.info "Image #{Settings.instance.image_tag} exists and compose files are up to date"
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
t("Building image") { build }
|
49
|
+
t("Rotating cache") { rotate_cache }
|
50
|
+
t("Verifying image") { verify_image }
|
51
|
+
end
|
52
|
+
|
53
|
+
def setup_signal_handlers
|
54
|
+
# Handle CTRL+C (SIGINT)
|
55
|
+
Signal.trap("INT") do
|
56
|
+
@interrupted = true
|
57
|
+
puts "\nReceived interrupt signal. Cleaning up..."
|
58
|
+
cleanup_and_exit
|
59
|
+
end
|
60
|
+
|
61
|
+
# Handle SIGTERM
|
62
|
+
Signal.trap("TERM") do
|
63
|
+
@interrupted = true
|
64
|
+
puts "\nReceived termination signal. Cleaning up..."
|
65
|
+
cleanup_and_exit
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def cleanup_and_exit
|
70
|
+
puts "Cleaning up..."
|
71
|
+
# Add any cleanup tasks here
|
72
|
+
exit 1
|
73
|
+
end
|
74
|
+
|
75
|
+
def check_interrupted
|
76
|
+
return unless @interrupted
|
77
|
+
|
78
|
+
puts "Operation interrupted by user"
|
79
|
+
cleanup_and_exit
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def print_total_time
|
85
|
+
logger.info "Total execution time: #{format('%.2f', @total_time)}s"
|
86
|
+
end
|
87
|
+
|
88
|
+
def t(title)
|
89
|
+
logger.info "[#{title.upcase}]..."
|
90
|
+
start_time = Time.now
|
91
|
+
result = yield
|
92
|
+
duration = Time.now - start_time
|
93
|
+
@total_time += duration
|
94
|
+
logger.info " done #{'%.2f' % duration}s"
|
95
|
+
check_interrupted
|
96
|
+
result
|
97
|
+
rescue StandardError
|
98
|
+
duration = Time.now - start_time
|
99
|
+
@total_time += duration
|
100
|
+
logger.error " failed #{format('%.2f', duration)}s"
|
101
|
+
raise
|
102
|
+
end
|
103
|
+
|
104
|
+
def check_uncommit_changes
|
105
|
+
status = `git status --porcelain`.strip
|
106
|
+
raise "Uncommitted changes found. Commit first" unless status.empty?
|
107
|
+
end
|
108
|
+
|
109
|
+
def setup_server
|
110
|
+
SetupServer.new(ssh, logger).setup
|
111
|
+
end
|
112
|
+
|
113
|
+
def build
|
114
|
+
logger.debug "Building #{Settings.instance.image_tag} image and load to server"
|
115
|
+
system(build_cmd)
|
116
|
+
end
|
117
|
+
|
118
|
+
def rotate_cache
|
119
|
+
system("rm -rf #{Settings[:cache_from]}")
|
120
|
+
system("mv #{Settings[:cache_to]} #{Settings[:cache_from]}")
|
121
|
+
end
|
122
|
+
|
123
|
+
def verify_image(check_only: false)
|
124
|
+
logger.debug "Verifying image on server..."
|
125
|
+
|
126
|
+
check_cmd = "docker images --filter=reference=#{Settings.instance.image_tag} --format '{{.ID}}' | grep ."
|
127
|
+
|
128
|
+
# Use backticks to capture output instead of system
|
129
|
+
output = `#{ssh.command(check_cmd)} 2>/dev/null`.strip
|
130
|
+
exists = !output.empty?
|
131
|
+
|
132
|
+
if check_only
|
133
|
+
logger.debug "Image #{exists ? 'found' : 'not found'}: #{Settings.instance.image_tag}"
|
134
|
+
return exists
|
135
|
+
end
|
136
|
+
|
137
|
+
raise "Docker image #{Settings.instance.image_tag} not found on #{Settings[:server]}" unless exists
|
138
|
+
|
139
|
+
true
|
140
|
+
end
|
141
|
+
|
142
|
+
def verify_compose_files
|
143
|
+
puts "Verifying compose files on server..."
|
144
|
+
|
145
|
+
Settings[:compose_files].all? do |file|
|
146
|
+
remote_file = "#{Settings[:projects_folder]}/#{File.basename(file)}"
|
147
|
+
local_md5 = ::Digest::MD5.file(file).hexdigest
|
148
|
+
|
149
|
+
check_cmd = "md5sum #{remote_file} 2>/dev/null | cut -d' ' -f1"
|
150
|
+
remote_md5 = `#{ssh.command(check_cmd)}`.strip
|
151
|
+
local_md5 == remote_md5
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def tag_as_production
|
156
|
+
logger.debug "Tagging Docker #{Settings.instance.image_tag} image as production..."
|
157
|
+
|
158
|
+
command = "docker tag #{Settings.instance.image_tag} #{Settings[:name]}:production"
|
159
|
+
ssh.execute(command) or raise "Failed to tag Docker image"
|
160
|
+
end
|
161
|
+
|
162
|
+
def build_cmd
|
163
|
+
args = [
|
164
|
+
"--cache-from type=local,src=#{Settings[:cache_from]}",
|
165
|
+
"--cache-to type=local,dest=#{Settings[:cache_to]},mode=max",
|
166
|
+
"--platform #{Settings[:platform]}",
|
167
|
+
"--tag #{Settings.instance.image_tag}",
|
168
|
+
"--target #{Settings[:target]}",
|
169
|
+
"--file #{Settings[:dockerfile]}"
|
170
|
+
]
|
171
|
+
|
172
|
+
if Settings[:remote]
|
173
|
+
args.push("--load")
|
174
|
+
else
|
175
|
+
args.push("--output type=docker,dest=-")
|
176
|
+
end
|
177
|
+
|
178
|
+
args.push(Settings[:docker_context])
|
179
|
+
|
180
|
+
args = args.map { |arg| " #{arg}" }
|
181
|
+
.join("\\\n")
|
182
|
+
|
183
|
+
cmd = "docker build \\\n#{args}"
|
184
|
+
if Settings[:remote]
|
185
|
+
ssh.command(cmd)
|
186
|
+
else
|
187
|
+
"#{cmd} | #{ssh.command('docker load')}"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def update_compose_files
|
192
|
+
logger.debug "Updating compose configuration files on remote server..."
|
193
|
+
|
194
|
+
Settings[:compose_files].each do |file|
|
195
|
+
remote_destination = "#{Settings.instance.project_folder_path}/#{File.basename(file)}"
|
196
|
+
copy_file(file, remote_destination)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def deploy_compose_files
|
201
|
+
logger.debug "Updating compose services..."
|
202
|
+
deploy_command = [ "docker compose" ]
|
203
|
+
|
204
|
+
# Use the remote file paths for docker compose command
|
205
|
+
Settings[:compose_files].each do |file|
|
206
|
+
deploy_command << "-f #{File.basename(file)}"
|
207
|
+
end
|
208
|
+
deploy_command << "up -d"
|
209
|
+
|
210
|
+
ssh.execute(deploy_command.join(" "),
|
211
|
+
directory: Settings.instance.project_folder_path) or raise "Failed to update compose services"
|
212
|
+
end
|
213
|
+
|
214
|
+
def copy_file(local_file, remote_destination)
|
215
|
+
logger.debug "Copying #{local_file} to #{Settings[:server]}:#{remote_destination}..."
|
216
|
+
command = "scp #{local_file} #{Settings[:server]}:#{remote_destination}"
|
217
|
+
|
218
|
+
system(command) or raise "Failed to copy #{local_file} to server"
|
219
|
+
end
|
220
|
+
|
221
|
+
def rotate_releases
|
222
|
+
logger.debug "Rotating releases..."
|
223
|
+
release_folder = "#{Settings.instance.project_folder_path}/releases"
|
224
|
+
release_file = "#{release_folder}/#{Settings[:release]}"
|
225
|
+
|
226
|
+
# Create releases directory if it doesn't exist
|
227
|
+
ssh.execute("mkdir -p #{release_folder}")
|
228
|
+
|
229
|
+
# Save current release
|
230
|
+
ssh.execute("touch #{release_file}")
|
231
|
+
|
232
|
+
# Get list of releases sorted by modification time (newest first)
|
233
|
+
list_cmd = "ls -t #{release_folder}"
|
234
|
+
releases = `#{ssh.command(list_cmd)}`.strip.split("\n")
|
235
|
+
|
236
|
+
# Keep only the last N releases
|
237
|
+
logger.info "releases #{releases.length}"
|
238
|
+
return unless releases.length > Settings[:keep_releases]
|
239
|
+
|
240
|
+
releases_to_remove = releases[Settings[:keep_releases]..]
|
241
|
+
|
242
|
+
# Remove old release files
|
243
|
+
releases_to_remove.each do |release|
|
244
|
+
ssh.execute("rm #{release_folder}/#{release}")
|
245
|
+
|
246
|
+
# Remove corresponding Docker image
|
247
|
+
image = "#{Settings[:name]}:#{release}"
|
248
|
+
logger.info "Removing old Docker image: #{image}"
|
249
|
+
ssh.execute("docker rmi #{image} || true") # Use || true to prevent failure if image is already removed
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
def system(*args)
|
254
|
+
result = super
|
255
|
+
raise "Command failed with exit code #{$CHILD_STATUS.exitstatus}: #{args.join(' ')}" unless result
|
256
|
+
|
257
|
+
result
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|