chagall 0.0.1.beta5 → 0.0.1.beta6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e44d1b04e208c82e52cd34be55fb7ba94206e2bbe9687fbb44f791d621b61b8
4
- data.tar.gz: ea94a54224c1b67996838581db58f7dad6a3d37dc5c688d81d5de07618a093fa
3
+ metadata.gz: f3cbd29143d65c6c6713ea03e0e26a29d7bb520139638cec49467fa932a1915d
4
+ data.tar.gz: 3c758792a4a406ff7981de63eae1512602b2b41184ce1c68ea00c1aa369dfcd3
5
5
  SHA512:
6
- metadata.gz: 0c57f123057e3fc37bf4c5171d092d2185767b8bd3b1b3573608d93419b5cce755d0f8c556be22b7ff40102c44203a71859c559bbeb2ff1dad3366bba4fdaf68
7
- data.tar.gz: 51407745069e3836aee5cdaa4212cc8864f4b0e892ba4bdf9df85669afdea75cc221bc9bb1f993a8a1e67cc7c30ad68cdc5b63d83626225ea6c7909c05c32a01
6
+ metadata.gz: 53637eb5f31edfd125b9a14c844e888f316e8914a5bfd817eea97e60ae9e5cd5d5b3619f7f1cee2af1421b6ccf4c2e903845bb95cd70a7eaf9d7537312ef0447
7
+ data.tar.gz: 51b246cc1a5e21171552f56bae8e1b9b4f179468369613de76d7c0e45cf37e8a67b048f63d61248f0deaeb0cc9316ace2d46ad506ff2f516dfd5206ad777b227
data/lib/chagall/base.rb CHANGED
@@ -1,29 +1,33 @@
1
1
  require "logger"
2
+ require_relative "ssh"
2
3
 
3
4
  module Chagall
4
- class Base
5
+ class Base < Clamp::Command
5
6
  attr_reader :logger, :ssh
6
7
 
7
8
  LOG_LEVELS = {
8
9
  "info" => Logger::INFO,
9
10
  "debug" => Logger::DEBUG,
10
11
  "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"
12
+ "error" => Logger::ERROR,
13
+ }
14
+
15
+ def logger
16
+ @logger ||= Logger.new($stdout).tap do |l|
17
+ l.formatter = proc do |severity, _, _, msg|
18
+ if severity == "DEBUG"
19
+ "[#{severity}] #{msg}\n"
20
+ else
21
+ "#{msg}\n"
22
+ end
21
23
  end
22
- end
23
24
 
24
- @logger.level = LOG_LEVELS[ENV.fetch("LOG_LEVEL", "info")]
25
+ l.level = LOG_LEVELS[ENV.fetch("LOG_LEVEL", "info")]
26
+ end
27
+ end
25
28
 
26
- @ssh = SSH.new
29
+ def ssh
30
+ @ssh ||= SSH.new(logger: logger)
27
31
  end
28
32
  end
29
33
  end
data/lib/chagall/cli.rb CHANGED
@@ -2,13 +2,21 @@
2
2
 
3
3
  require "clamp"
4
4
  require_relative "settings"
5
- require_relative "deploy/main"
6
- require_relative "compose/main"
5
+ require_relative "deploy"
6
+ require_relative "compose"
7
+ require_relative "rollback"
7
8
 
8
9
  Clamp.allow_options_after_parameters = true
9
10
 
10
11
  module Chagall
11
12
  class Cli < Clamp::Command
13
+
14
+ def run(arguments)
15
+ parse(arguments)
16
+ Chagall::Settings.configure(collect_options_hash)
17
+ execute
18
+ end
19
+
12
20
  def self.options_from_config_file
13
21
  @options_from_config_file ||= begin
14
22
  config_path = File.join(Dir.pwd, "chagall.yml") || File.join(Dir.pwd, "chagall.yaml")
@@ -49,59 +57,10 @@ module Chagall
49
57
  exit(0)
50
58
  end
51
59
 
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
60
+ subcommand "deploy", "Deploy the application to the server", Chagall::Deploy
61
+ subcommand "setup", "Setup the server for deployment", Chagall::Setup
62
+ subcommand "compose", "Run Docker Compose commands with arguments passed through", Chagall::Compose
63
+ subcommand "rollback", "Rollback to previous deployment", Chagall::Rollback
105
64
 
106
65
  private
107
66
 
@@ -1,32 +1,39 @@
1
- require_relative "../settings"
2
1
  require_relative "../base"
3
2
 
4
3
  module Chagall
5
4
  module Compose
6
- # Build and execute command usign docker compose on server
7
5
  class Main < Base
8
- attr_reader :command, :arguments
6
+ attr_reader :command, :service, :args
9
7
 
10
- def initialize(command, args)
11
- super()
8
+ def initialize(command, *args)
12
9
  @command = command
13
- @arguments = args.join(" ") if args.is_a?(Array)
14
- @arguments ||= args.to_s
10
+ @service = args.first if args.first && !args.first.start_with?('-')
11
+ @args = @service ? args[1..-1] : args
15
12
 
16
13
  raise Chagall::Error, "Command is required" if @command.nil? || @command.empty?
17
14
 
18
15
  run_command
19
16
  end
20
17
 
21
- def run_command
22
- cmd = "cd #{Settings.instance.project_folder_path} && #{build_docker_compose_command} #{@command}"
23
- cmd << " #{arguments}" unless arguments.empty?
18
+ private
24
19
 
20
+ def run_command
21
+ cmd = build_command
25
22
  logger.debug "Executing: #{cmd}"
26
- ssh.execute(cmd, tty: true)
23
+
24
+ result = ssh.execute(cmd, tty: true)
25
+ raise Chagall::Error, "Command failed: #{cmd}" unless result
27
26
  end
28
27
 
29
- private
28
+ def build_command
29
+ cmd = [ "cd #{Settings.instance.project_folder_path}" ]
30
+ cmd << build_docker_compose_command
31
+ cmd << @command
32
+ cmd << @service if @service
33
+ cmd << @args.join(" ") if @args && @args.any?
34
+
35
+ cmd.join(" && ")
36
+ end
30
37
 
31
38
  def build_docker_compose_command
32
39
  compose_files = Settings[:compose_files]
@@ -42,4 +49,4 @@ module Chagall
42
49
  end
43
50
  end
44
51
  end
45
- end
52
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "base"
2
+
3
+ module Chagall
4
+ # Build and execute command usign docker compose on server
5
+ class Compose < Base
6
+ attr_reader :command, :arguments
7
+
8
+
9
+ # Override parse method to handle all arguments after the subcommand
10
+ def parse(arguments)
11
+ if arguments.empty?
12
+ puts "ERROR: Missing required arguments"
13
+ puts "Usage: chagall compose COMMAND [OPTIONS]"
14
+ exit(1)
15
+ end
16
+
17
+ # Extract the first argument as command
18
+ @command = arguments.shift
19
+
20
+ # Store the rest as raw args
21
+ @raw_args = arguments
22
+
23
+ # Validate required arguments
24
+ if @command.nil? || @command.empty?
25
+ puts "ERROR: Command is required"
26
+ puts "Usage: chagall compose COMMAND [OPTIONS]"
27
+ exit(1)
28
+ end
29
+ end
30
+
31
+ def execute
32
+ cmd = "cd #{Settings.instance.project_folder_path} && #{build_docker_compose_command} #{@command}"
33
+ cmd << " #{@raw_args.join(" ")}" unless @raw_args.empty?
34
+
35
+ logger.debug "Executing: #{cmd}"
36
+ ssh.execute(cmd, tty: true)
37
+ end
38
+
39
+ private
40
+
41
+ def build_docker_compose_command
42
+ compose_files = Settings[:compose_files]
43
+ compose_cmd = [ "docker compose" ]
44
+
45
+ if compose_files && !compose_files.empty?
46
+ compose_files.each do |file|
47
+ compose_cmd << "-f #{File.basename(file)}"
48
+ end
49
+ end
50
+
51
+ compose_cmd.join(" ")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "digest"
5
+ require "benchmark"
6
+ require "yaml"
7
+
8
+ module Chagall
9
+ class Deploy < Base
10
+
11
+ attr_reader :total_time
12
+
13
+ def execute
14
+ @interrupted = false
15
+ @total_time = 0.0
16
+ setup_signal_handlers
17
+ Time.now
18
+
19
+ t("Checking uncommitted changes") { check_uncommit_changes } unless Settings[:skip_uncommit]
20
+ t("Check image or build") { check_image_or_build }
21
+ t("tag as production") { tag_as_production }
22
+ t("update compose files") { update_compose_files }
23
+ t("deploy compose files") { deploy_compose_files }
24
+ t("rotate release") { rotate_releases }
25
+
26
+ print_total_time
27
+ rescue Interrupt
28
+ logger.info "\nDeployment interrupted by user"
29
+ print_total_time
30
+ cleanup_and_exit
31
+ rescue StandardError => e
32
+ logger.error "Deployment failed: #{e.message}"
33
+ logger.debug e.backtrace.join("\n") if ENV["DEBUG"]
34
+ print_total_time
35
+ exit 1
36
+ end
37
+
38
+ def check_image_or_build
39
+ image_exists = verify_image(check_only: true)
40
+
41
+ if image_exists
42
+ logger.info "Image #{@image_tag} exists and compose files are up to date"
43
+ return
44
+ end
45
+
46
+ t("Building image") { build }
47
+ t("Rotating cache") { rotate_cache }
48
+ t("Verifying image") { verify_image }
49
+ end
50
+
51
+ def setup_signal_handlers
52
+ # Handle CTRL+C (SIGINT)
53
+ Signal.trap("INT") do
54
+ @interrupted = true
55
+ puts "\nReceived interrupt signal. Cleaning up..."
56
+ cleanup_and_exit
57
+ end
58
+
59
+ # Handle SIGTERM
60
+ Signal.trap("TERM") do
61
+ @interrupted = true
62
+ puts "\nReceived termination signal. Cleaning up..."
63
+ cleanup_and_exit
64
+ end
65
+ end
66
+
67
+ def cleanup_and_exit
68
+ puts "Cleaning up..."
69
+ # Add any cleanup tasks here
70
+ exit 1
71
+ end
72
+
73
+ def check_interrupted
74
+ return unless @interrupted
75
+
76
+ puts "Operation interrupted by user"
77
+ cleanup_and_exit
78
+ end
79
+
80
+ private
81
+
82
+ def print_total_time
83
+ logger.info "Total execution time: #{format('%.2f', @total_time)}s"
84
+ end
85
+
86
+ def t(title)
87
+ logger.info "[#{title.upcase}]..."
88
+ start_time = Time.now
89
+ result = yield
90
+ duration = Time.now - start_time
91
+ @total_time += duration
92
+ logger.info " done #{'%.2f' % duration}s"
93
+ check_interrupted
94
+ result
95
+ rescue StandardError
96
+ duration = Time.now - start_time
97
+ @total_time += duration
98
+ logger.error " failed #{format('%.2f', duration)}s"
99
+ raise
100
+ end
101
+
102
+ def check_uncommit_changes
103
+ status = `git status --porcelain`.strip
104
+ raise "Uncommitted changes found. Commit first" unless status.empty?
105
+ end
106
+
107
+ def build
108
+ logger.debug "Building #{Settings.instance.image_tag} image and load to server"
109
+ system(build_cmd)
110
+ end
111
+
112
+ def rotate_cache
113
+ system("rm -rf #{Settings[:cache_from]}")
114
+ system("mv #{Settings[:cache_to]} #{Settings[:cache_from]}")
115
+ end
116
+
117
+ def verify_image(check_only: false)
118
+ logger.debug "Verifying image on server..."
119
+
120
+ check_cmd = "docker images --filter=reference=#{Settings.instance.image_tag} --format '{{.ID}}' | grep ."
121
+
122
+ # Use backticks to capture output instead of system
123
+ output = `#{ssh.command(check_cmd)} 2>/dev/null`.strip
124
+ exists = !output.empty?
125
+
126
+ if check_only
127
+ logger.debug "Image #{exists ? 'found' : 'not found'}: #{Settings.instance.image_tag}"
128
+ return exists
129
+ end
130
+
131
+ raise "Docker image #{Settings.instance.image_tag} not found on #{Settings[:server]}" unless exists
132
+
133
+ true
134
+ end
135
+
136
+ def verify_compose_files
137
+ puts "Verifying compose files on server..."
138
+
139
+ Settings[:compose_files].all? do |file|
140
+ remote_file = "#{Settings[:projects_folder]}/#{File.basename(file)}"
141
+ local_md5 = ::Digest::MD5.file(file).hexdigest
142
+
143
+ check_cmd = "md5sum #{remote_file} 2>/dev/null | cut -d' ' -f1"
144
+ remote_md5 = `#{ssh.command(check_cmd)}`.strip
145
+ local_md5 == remote_md5
146
+ end
147
+ end
148
+
149
+ def tag_as_production
150
+ logger.debug "Tagging Docker #{Settings.instance.image_tag} image as production..."
151
+
152
+ command = "docker tag #{Settings.instance.image_tag} #{Settings[:name]}:production"
153
+ ssh.execute(command) or raise "Failed to tag Docker image"
154
+ end
155
+
156
+ def build_cmd
157
+ args = [
158
+ "--cache-from type=local,src=#{Settings[:cache_from]}",
159
+ "--cache-to type=local,dest=#{Settings[:cache_to]},mode=max",
160
+ "--platform #{Settings[:platform]}",
161
+ "--tag #{Settings.instance.image_tag}",
162
+ "--target #{Settings[:target]}",
163
+ "--file #{Settings[:dockerfile]}"
164
+ ]
165
+
166
+ if Settings[:remote]
167
+ args.push("--load")
168
+ else
169
+ args.push("--output type=docker,dest=-")
170
+ end
171
+
172
+ args.push(Settings[:docker_context])
173
+
174
+ args = args.map { |arg| " #{arg}" }
175
+ .join(" \\\n")
176
+
177
+ cmd = "docker build \\\n#{args}"
178
+ if Settings[:remote]
179
+ ssh.command(cmd)
180
+ else
181
+ "#{cmd} | #{ssh.command('docker load')}"
182
+ end
183
+ end
184
+
185
+ def update_compose_files
186
+ logger.debug "Updating compose configuration files on remote server..."
187
+
188
+ Settings[:compose_files].each do |file|
189
+ remote_destination = "#{Settings.instance.project_folder_path}/#{File.basename(file)}"
190
+ copy_file(file, remote_destination)
191
+ end
192
+ end
193
+
194
+ def deploy_compose_files
195
+ logger.debug "Updating compose services..."
196
+ deploy_command = [ "docker compose" ]
197
+
198
+ # Use the remote file paths for docker compose command
199
+ Settings[:compose_files].each do |file|
200
+ deploy_command << "-f #{File.basename(file)}"
201
+ end
202
+ deploy_command << "up -d"
203
+
204
+ ssh.execute(deploy_command.join(" "),
205
+ directory: Settings.instance.project_folder_path) or raise "Failed to update compose services"
206
+ end
207
+
208
+ def copy_file(local_file, remote_destination)
209
+ logger.debug "Copying #{local_file} to #{Settings[:server]}:#{remote_destination}..."
210
+ command = "scp #{local_file} #{Settings[:server]}:#{remote_destination}"
211
+
212
+ system(command) or raise "Failed to copy #{local_file} to server"
213
+ end
214
+
215
+ def rotate_releases
216
+ logger.debug "Rotating releases..."
217
+ release_folder = "#{Settings.instance.project_folder_path}/releases"
218
+ release_file = "#{release_folder}/#{Settings[:release]}"
219
+
220
+ # Create releases directory if it doesn't exist
221
+ ssh.execute("mkdir -p #{release_folder}")
222
+
223
+ # Save current release
224
+ ssh.execute("touch #{release_file}")
225
+
226
+ # Get list of releases sorted by modification time (newest first)
227
+ list_cmd = "ls -t #{release_folder}"
228
+ releases = `#{ssh.command(list_cmd)}`.strip.split("\n")
229
+
230
+ # Keep only the last N releases
231
+ logger.info "releases #{releases.length}"
232
+ return unless releases.length > Settings[:keep_releases]
233
+
234
+ releases_to_remove = releases[Settings[:keep_releases]..]
235
+
236
+ # Remove old release files
237
+ releases_to_remove.each do |release|
238
+ ssh.execute("rm #{release_folder}/#{release}")
239
+
240
+ # Remove corresponding Docker image
241
+ image = "#{Settings[:name]}:#{release}"
242
+ logger.info "Removing old Docker image: #{image}"
243
+ ssh.execute("docker rmi #{image} || true") # Use || true to prevent failure if image is already removed
244
+ end
245
+ end
246
+
247
+ def system(*args)
248
+ result = super
249
+ raise "Command failed with exit code #{$CHILD_STATUS.exitstatus}: #{args.join(' ')}" unless result
250
+
251
+ result
252
+ end
253
+
254
+ def ssh
255
+ @ssh ||= SSH.new
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,11 @@
1
+ module Chagall
2
+ class Rollback < Base
3
+ option [ "--steps" ], "STEPS", "Number of steps to rollback", default: "1" do |s|
4
+ Integer(s)
5
+ end
6
+
7
+ def execute
8
+ puts "Rollback functionality not implemented yet"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "io/console"
5
+ require_relative "base"
6
+
7
+ module Chagall
8
+ # Handles server provisioning and Docker environment setup for deployment
9
+ class Setup < Base
10
+ class DockerSetupError < StandardError; end
11
+
12
+ def execute
13
+ install_docker unless docker_installed?
14
+ setup_non_root_docker_deamon_access if unable_to_access_docker_deamon?
15
+ create_project_folder unless project_folder_exists?
16
+ create_env_files
17
+
18
+ logger.info "Docker environment setup complete"
19
+ end
20
+
21
+ private
22
+
23
+ def create_env_files
24
+ logger.debug "Create env files described at services..."
25
+
26
+ Settings[:compose_files].each do |file|
27
+ yaml = YAML.load_file(file, aliases: true)
28
+ yaml["services"].each do |service|
29
+ service[1]["env_file"]&.each do |env_file|
30
+ ssh.execute("touch #{env_file}", directory: Settings.instance.project_folder_path)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def unable_to_access_docker_deamon?
37
+ return true
38
+ docker_result = ssh.command("docker ps")
39
+ docker_output = `#{docker_result} 2>&1`.strip
40
+ logger.debug "Docker output: #{docker_output}"
41
+ docker_output.downcase.include?("permission denied") ||
42
+ docker_output.downcase.include?("connect: permission denied")
43
+ end
44
+
45
+ def setup_non_root_docker_deamon_access
46
+ logger.info "Add user to docker group..."
47
+
48
+ username = `#{ssh.command("whoami")} 2>&1`.strip
49
+ return true if username == "root"
50
+
51
+ groups = `#{ssh.command("groups")} 2>&1`.strip
52
+ return if groups.include?("docker")
53
+
54
+ logger.debug "Adding #{username} user to docker group"
55
+ ssh.execute("sudo usermod -aG docker #{username}", tty: true)
56
+ ssh.execute("groups #{username} | grep -q docker")
57
+
58
+ logger.debug "Successfully added user to docker group"
59
+ true
60
+ rescue StandardError => e
61
+ logger.error "Error setting up Docker daemon access: #{e.message}"
62
+ logger.debug e.backtrace.join("\n")
63
+ false
64
+ end
65
+
66
+ def docker_installed?
67
+ logger.debug "Checking Docker installation..."
68
+
69
+ docker_output = `#{ssh.command("docker --version")} 2>&1`.strip
70
+ logger.debug "Docker version output: '#{docker_output}'"
71
+
72
+ compose_output = `#{ssh.command("docker compose version")} 2>&1`.strip
73
+ logger.debug "Docker Compose output: '#{compose_output}'"
74
+
75
+ return true if docker_output.include?("Docker version") &&
76
+ compose_output.include?("Docker Compose version")
77
+
78
+ logger.warn "Docker check failed:"
79
+ logger.warn "Docker output: #{docker_output}"
80
+ logger.warn "Docker Compose output: #{compose_output}"
81
+ false
82
+ rescue StandardError => e
83
+ logger.error "Error checking Docker installation: #{e.message}"
84
+ logger.debug e.backtrace.join("\n")
85
+ false
86
+ end
87
+
88
+ def project_folder_exists?
89
+ ssh.execute("test -d #{Settings.instance.project_folder_path}")
90
+ rescue StandardError => e
91
+ logger.error "Error checking project folder: #{e.message}"
92
+ false
93
+ end
94
+
95
+ def create_project_folder
96
+ logger.debug "Creating project folder #{Settings.instance.project_folder_path}"
97
+ ssh.execute("mkdir -p #{Settings.instance.project_folder_path}")
98
+ end
99
+
100
+ def install_docker
101
+ logger.debug "Installing Docker..."
102
+
103
+ command = '(curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo "exit 1") | sh'
104
+ # Clear any existing sudo session
105
+ ssh.execute("sudo -k")
106
+
107
+ logger.debug "Executing: #{command}"
108
+ result = ssh.execute(command, tty: true)
109
+ raise DockerSetupError, "Failed to execute: #{command}" unless result
110
+ rescue StandardError => e
111
+ logger.error "Docker installation failed: #{e.message}"
112
+ raise DockerSetupError, "Failed to install Docker: #{e.message}"
113
+ end
114
+ end
115
+ end
data/lib/chagall/ssh.rb CHANGED
@@ -1,14 +1,15 @@
1
-
2
1
  require "English"
2
+
3
3
  module Chagall
4
4
  class SSH
5
- attr_reader :server, :ssh_args
5
+ attr_reader :server, :ssh_args, :logger
6
6
 
7
7
  DEFAULT_SSH_ARGS = "-o StrictHostKeyChecking=no -o ServerAliveInterval=60".freeze
8
8
 
9
- def initialize(server: Settings.instance.options[:server], ssh_args: DEFAULT_SSH_ARGS)
9
+ def initialize(server: Settings.instance.options[:server], ssh_args: DEFAULT_SSH_ARGS, logger: Logger.new($stdout))
10
10
  @server = server
11
11
  @ssh_args = ssh_args
12
+ @logger = logger
12
13
  end
13
14
 
14
15
  def execute(command, directory: nil, tty: false)
@@ -38,18 +39,5 @@ module Chagall
38
39
 
39
40
  "#{ssh_cmd.join(' ')} '#{cmd}'"
40
41
  end
41
-
42
- def logger
43
- @logger ||= Logger.new($stdout).tap do |l|
44
- l.formatter = proc do |severity, _, _, msg|
45
- if severity == "DEBUG"
46
- "[#{severity}] #{msg}\n"
47
- else
48
- "#{msg}\n"
49
- end
50
- end
51
- l.level = ENV.fetch("LOG_LEVEL") { "debug" }.downcase == "debug" ? Logger::DEBUG : Logger::INFO
52
- end
53
- end
54
42
  end
55
43
  end
@@ -1,3 +1,3 @@
1
1
  module Chagall
2
- VERSION = "0.0.1.beta5"
2
+ VERSION = "0.0.1.beta6"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chagall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.beta5
4
+ version: 0.0.1.beta6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Klevtsov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-09 00:00:00.000000000 Z
11
+ date: 2025-04-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: clamp
@@ -93,13 +93,15 @@ files:
93
93
  - lib/chagall.rb
94
94
  - lib/chagall/base.rb
95
95
  - lib/chagall/cli.rb
96
+ - lib/chagall/compose.rb
96
97
  - lib/chagall/compose/main.rb
97
- - lib/chagall/deploy/main.rb
98
+ - lib/chagall/deploy.rb
98
99
  - lib/chagall/install/main.rb
99
100
  - lib/chagall/install/templates/template.Dockerfile
100
101
  - lib/chagall/install/templates/template.compose.yaml
102
+ - lib/chagall/rollback.rb
101
103
  - lib/chagall/settings.rb
102
- - lib/chagall/setup/main.rb
104
+ - lib/chagall/setup.rb
103
105
  - lib/chagall/ssh.rb
104
106
  - lib/chagall/version.rb
105
107
  homepage: https://github.com/frontandstart/chagall
@@ -1,261 +0,0 @@
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
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "io/console"
5
-
6
- module Chagall
7
- module Setup
8
- # Handles server provisioning and Docker environment setup for deployment
9
- class Main < Base
10
- class DockerSetupError < StandardError; end
11
-
12
- def initialize
13
- super()
14
- setup
15
- end
16
-
17
- def setup
18
- install_docker unless docker_installed?
19
- setup_non_root_docker_deamon_access if unable_to_access_docker_deamon?
20
- create_project_folder unless project_folder_exists?
21
- create_env_files
22
-
23
- logger.info "Docker environment setup complete"
24
- end
25
-
26
- private
27
-
28
- def create_env_files
29
- logger.debug "Create env files described at services..."
30
-
31
- Settings[:compose_files].each do |file|
32
- yaml = YAML.load_file(file, aliases: true)
33
- yaml["services"].each do |service|
34
- service[1]["env_file"]&.each do |env_file|
35
- ssh.execute("touch #{env_file}", directory: Settings.instance.project_folder_path)
36
- end
37
- end
38
- end
39
- end
40
-
41
- def unable_to_access_docker_deamon?
42
- return true
43
- docker_result = ssh.command("docker ps")
44
- docker_output = `#{docker_result} 2>&1`.strip
45
- logger.debug "Docker output: #{docker_output}"
46
- docker_output.downcase.include?("permission denied") ||
47
- docker_output.downcase.include?("connect: permission denied")
48
- end
49
-
50
- def setup_non_root_docker_deamon_access
51
- logger.info "Add user to docker group..."
52
-
53
- username = `#{ssh.command("whoami")} 2>&1`.strip
54
- return true if username == "root"
55
-
56
- groups = `#{ssh.command("groups")} 2>&1`.strip
57
- return if groups.include?("docker")
58
-
59
- logger.debug "Adding #{username} user to docker group"
60
- ssh.execute("sudo usermod -aG docker #{username}", tty: true)
61
- ssh.execute("groups #{username} | grep -q docker")
62
-
63
- logger.debug "Successfully added user to docker group"
64
- true
65
- rescue StandardError => e
66
- logger.error "Error setting up Docker daemon access: #{e.message}"
67
- logger.debug e.backtrace.join("\n")
68
- false
69
- end
70
-
71
- def docker_installed?
72
- logger.debug "Checking Docker installation..."
73
-
74
- docker_output = `#{ssh.command("docker --version")} 2>&1`.strip
75
- logger.debug "Docker version output: '#{docker_output}'"
76
-
77
- compose_output = `#{ssh.command("docker compose version")} 2>&1`.strip
78
- logger.debug "Docker Compose output: '#{compose_output}'"
79
-
80
- return true if docker_output.include?("Docker version") &&
81
- compose_output.include?("Docker Compose version")
82
-
83
- logger.warn "Docker check failed:"
84
- logger.warn "Docker output: #{docker_output}"
85
- logger.warn "Docker Compose output: #{compose_output}"
86
- false
87
- rescue StandardError => e
88
- logger.error "Error checking Docker installation: #{e.message}"
89
- logger.debug e.backtrace.join("\n")
90
- false
91
- end
92
-
93
- def project_folder_exists?
94
- ssh.execute("test -d #{Settings.instance.project_folder_path}")
95
- rescue StandardError => e
96
- logger.error "Error checking project folder: #{e.message}"
97
- false
98
- end
99
-
100
- def create_project_folder
101
- logger.debug "Creating project folder #{Settings.instance.project_folder_path}"
102
- ssh.execute("mkdir -p #{Settings.instance.project_folder_path}")
103
- end
104
-
105
- def install_docker
106
- logger.debug "Installing Docker..."
107
-
108
- command = '(curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo "exit 1") | sh'
109
- # Clear any existing sudo session
110
- ssh.execute("sudo -k")
111
-
112
- logger.debug "Executing: #{command}"
113
- result = ssh.execute(command, tty: true)
114
- raise DockerSetupError, "Failed to execute: #{command}" unless result
115
- rescue StandardError => e
116
- logger.error "Docker installation failed: #{e.message}"
117
- raise DockerSetupError, "Failed to install Docker: #{e.message}"
118
- end
119
- end
120
- end
121
- end