chagall 0.0.1.beta5 → 0.0.1.beta7

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: 4f8df566c5608a68224f0cda8ee7ec4dc2ad6b9c3657476052271c033104ccc5
4
+ data.tar.gz: fc9fdbd3a3c2035459d7239a9f245a030df45994bd7b1346dbed62a735014493
5
5
  SHA512:
6
- metadata.gz: 0c57f123057e3fc37bf4c5171d092d2185767b8bd3b1b3573608d93419b5cce755d0f8c556be22b7ff40102c44203a71859c559bbeb2ff1dad3366bba4fdaf68
7
- data.tar.gz: 51407745069e3836aee5cdaa4212cc8864f4b0e892ba4bdf9df85669afdea75cc221bc9bb1f993a8a1e67cc7c30ad68cdc5b63d83626225ea6c7909c05c32a01
6
+ metadata.gz: 1cb86c45fe1af47f7c8791ed73025903ae5338ecfaee7b6b287c96b4959fa21670f5677cbc228715e4949603329601d0a8fe4fe2584d15090206830b526908ca
7
+ data.tar.gz: 5b9cd2bb23766a47c6f1727dcd4a04d4c068fdbdeec942115adfa11aac01ac739b131c1314156f201db2a5868d24ae0ae396b1f9b41fa720f1e52f0691c6ddb4
data/lib/chagall/base.rb CHANGED
@@ -1,7 +1,8 @@
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 = {
@@ -9,21 +10,24 @@ module Chagall
9
10
  "debug" => Logger::DEBUG,
10
11
  "warn" => Logger::WARN,
11
12
  "error" => Logger::ERROR
12
- }.freeze
13
+ }
13
14
 
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"
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,20 @@
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
+ def run(arguments)
14
+ parse(arguments)
15
+ Chagall::Settings.configure(collect_options_hash)
16
+ execute
17
+ end
18
+
12
19
  def self.options_from_config_file
13
20
  @options_from_config_file ||= begin
14
21
  config_path = File.join(Dir.pwd, "chagall.yml") || File.join(Dir.pwd, "chagall.yaml")
@@ -49,59 +56,10 @@ module Chagall
49
56
  exit(0)
50
57
  end
51
58
 
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
59
+ subcommand "deploy", "Deploy the application to the server", Chagall::Deploy
60
+ subcommand "setup", "Setup the server for deployment", Chagall::Setup
61
+ subcommand "compose", "Run Docker Compose commands with arguments passed through", Chagall::Compose
62
+ subcommand "rollback", "Rollback to previous deployment", Chagall::Rollback
105
63
 
106
64
  private
107
65
 
@@ -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,257 @@
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
+ attr_reader :total_time
11
+
12
+ def execute
13
+ @interrupted = false
14
+ @total_time = 0.0
15
+ setup_signal_handlers
16
+ Time.now
17
+
18
+ t("Checking uncommitted changes") { check_uncommit_changes } unless Settings[:skip_uncommit]
19
+ t("Check image or build") { check_image_or_build }
20
+ t("tag as production") { tag_as_production }
21
+ t("update compose files") { update_compose_files }
22
+ t("deploy compose files") { deploy_compose_files }
23
+ t("rotate release") { rotate_releases }
24
+
25
+ print_total_time
26
+ rescue Interrupt
27
+ logger.info "\nDeployment interrupted by user"
28
+ print_total_time
29
+ cleanup_and_exit
30
+ rescue StandardError => e
31
+ logger.error "Deployment failed: #{e.message}"
32
+ logger.debug e.backtrace.join("\n") if ENV["DEBUG"]
33
+ print_total_time
34
+ exit 1
35
+ end
36
+
37
+ def check_image_or_build
38
+ image_exists = verify_image(check_only: true)
39
+
40
+ if image_exists
41
+ logger.info "Image #{@image_tag} exists and compose files are up to date"
42
+ return
43
+ end
44
+
45
+ t("Building image") { build }
46
+ t("Rotating cache") { rotate_cache }
47
+ t("Verifying image") { verify_image }
48
+ end
49
+
50
+ def setup_signal_handlers
51
+ # Handle CTRL+C (SIGINT)
52
+ Signal.trap("INT") do
53
+ @interrupted = true
54
+ puts "\nReceived interrupt signal. Cleaning up..."
55
+ cleanup_and_exit
56
+ end
57
+
58
+ # Handle SIGTERM
59
+ Signal.trap("TERM") do
60
+ @interrupted = true
61
+ puts "\nReceived termination signal. Cleaning up..."
62
+ cleanup_and_exit
63
+ end
64
+ end
65
+
66
+ def cleanup_and_exit
67
+ puts "Cleaning up..."
68
+ # Add any cleanup tasks here
69
+ exit 1
70
+ end
71
+
72
+ def check_interrupted
73
+ return unless @interrupted
74
+
75
+ puts "Operation interrupted by user"
76
+ cleanup_and_exit
77
+ end
78
+
79
+ private
80
+
81
+ def print_total_time
82
+ logger.info "Total execution time: #{format('%.2f', @total_time)}s"
83
+ end
84
+
85
+ def t(title)
86
+ logger.info "[#{title.upcase}]..."
87
+ start_time = Time.now
88
+ result = yield
89
+ duration = Time.now - start_time
90
+ @total_time += duration
91
+ logger.info " done #{'%.2f' % duration}s"
92
+ check_interrupted
93
+ result
94
+ rescue StandardError
95
+ duration = Time.now - start_time
96
+ @total_time += duration
97
+ logger.error " failed #{format('%.2f', duration)}s"
98
+ raise
99
+ end
100
+
101
+ def check_uncommit_changes
102
+ status = `git status --porcelain`.strip
103
+ raise "Uncommitted changes found. Commit first" unless status.empty?
104
+ end
105
+
106
+ def build
107
+ logger.debug "Building #{Settings.instance.image_tag} image and load to server"
108
+ system(build_cmd)
109
+ end
110
+
111
+ def rotate_cache
112
+ system("rm -rf #{Settings[:cache_from]}")
113
+ system("mv #{Settings[:cache_to]} #{Settings[:cache_from]}")
114
+ end
115
+
116
+ def verify_image(check_only: false)
117
+ logger.debug "Verifying image on server..."
118
+
119
+ check_cmd = "docker images --filter=reference=#{Settings.instance.image_tag} --format '{{.ID}}' | grep ."
120
+
121
+ # Use backticks to capture output instead of system
122
+ output = `#{ssh.command(check_cmd)} 2>/dev/null`.strip
123
+ exists = !output.empty?
124
+
125
+ if check_only
126
+ logger.debug "Image #{exists ? 'found' : 'not found'}: #{Settings.instance.image_tag}"
127
+ return exists
128
+ end
129
+
130
+ raise "Docker image #{Settings.instance.image_tag} not found on #{Settings[:server]}" unless exists
131
+
132
+ true
133
+ end
134
+
135
+ def verify_compose_files
136
+ puts "Verifying compose files on server..."
137
+
138
+ Settings[:compose_files].all? do |file|
139
+ remote_file = "#{Settings[:projects_folder]}/#{File.basename(file)}"
140
+ local_md5 = ::Digest::MD5.file(file).hexdigest
141
+
142
+ check_cmd = "md5sum #{remote_file} 2>/dev/null | cut -d' ' -f1"
143
+ remote_md5 = `#{ssh.command(check_cmd)}`.strip
144
+ local_md5 == remote_md5
145
+ end
146
+ end
147
+
148
+ def tag_as_production
149
+ logger.debug "Tagging Docker #{Settings.instance.image_tag} image as production..."
150
+
151
+ command = "docker tag #{Settings.instance.image_tag} #{Settings[:name]}:production"
152
+ ssh.execute(command) or raise "Failed to tag Docker image"
153
+ end
154
+
155
+ def build_cmd
156
+ args = [
157
+ "--cache-from type=local,src=#{Settings[:cache_from]}",
158
+ "--cache-to type=local,dest=#{Settings[:cache_to]},mode=max",
159
+ "--platform #{Settings[:platform]}",
160
+ "--tag #{Settings.instance.image_tag}",
161
+ "--target #{Settings[:target]}",
162
+ "--file #{Settings[:dockerfile]}"
163
+ ]
164
+
165
+ if Settings[:remote]
166
+ args.push("--load")
167
+ else
168
+ args.push("--output type=docker,dest=-")
169
+ end
170
+
171
+ args.push(Settings[:docker_context])
172
+
173
+ args = args.map { |arg| " #{arg}" }
174
+ .join(" \\\n")
175
+
176
+ cmd = "docker build \\\n#{args}"
177
+ if Settings[:remote]
178
+ ssh.command(cmd)
179
+ else
180
+ "#{cmd} | #{ssh.command('docker load')}"
181
+ end
182
+ end
183
+
184
+ def update_compose_files
185
+ logger.debug "Updating compose configuration files on remote server..."
186
+
187
+ Settings[:compose_files].each do |file|
188
+ remote_destination = "#{Settings.instance.project_folder_path}/#{File.basename(file)}"
189
+ copy_file(file, remote_destination)
190
+ end
191
+ end
192
+
193
+ def deploy_compose_files
194
+ logger.debug "Updating compose services..."
195
+ deploy_command = [ "docker compose" ]
196
+
197
+ # Use the remote file paths for docker compose command
198
+ Settings[:compose_files].each do |file|
199
+ deploy_command << "-f #{File.basename(file)}"
200
+ end
201
+ deploy_command << "up -d"
202
+
203
+ ssh.execute(deploy_command.join(" "),
204
+ directory: Settings.instance.project_folder_path) or raise "Failed to update compose services"
205
+ end
206
+
207
+ def copy_file(local_file, remote_destination)
208
+ logger.debug "Copying #{local_file} to #{Settings[:server]}:#{remote_destination}..."
209
+ command = "scp #{local_file} #{Settings[:server]}:#{remote_destination}"
210
+
211
+ system(command) or raise "Failed to copy #{local_file} to server"
212
+ end
213
+
214
+ def rotate_releases
215
+ logger.debug "Rotating releases..."
216
+ release_folder = "#{Settings.instance.project_folder_path}/releases"
217
+ release_file = "#{release_folder}/#{Settings[:release]}"
218
+
219
+ # Create releases directory if it doesn't exist
220
+ ssh.execute("mkdir -p #{release_folder}")
221
+
222
+ # Save current release
223
+ ssh.execute("touch #{release_file}")
224
+
225
+ # Get list of releases sorted by modification time (newest first)
226
+ list_cmd = "ls -t #{release_folder}"
227
+ releases = `#{ssh.command(list_cmd)}`.strip.split("\n")
228
+
229
+ # Keep only the last N releases
230
+ logger.info "releases #{releases.length}"
231
+ return unless releases.length > Settings[:keep_releases]
232
+
233
+ releases_to_remove = releases[Settings[:keep_releases]..]
234
+
235
+ # Remove old release files
236
+ releases_to_remove.each do |release|
237
+ ssh.execute("rm #{release_folder}/#{release}")
238
+
239
+ # Remove corresponding Docker image
240
+ image = "#{Settings[:name]}:#{release}"
241
+ logger.info "Removing old Docker image: #{image}"
242
+ ssh.execute("docker rmi #{image} || true") # Use || true to prevent failure if image is already removed
243
+ end
244
+ end
245
+
246
+ def system(*args)
247
+ result = super
248
+ raise "Command failed with exit code #{$CHILD_STATUS.exitstatus}: #{args.join(' ')}" unless result
249
+
250
+ result
251
+ end
252
+
253
+ def ssh
254
+ @ssh ||= SSH.new
255
+ end
256
+ end
257
+ 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.beta7"
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.beta7
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,14 @@ files:
93
93
  - lib/chagall.rb
94
94
  - lib/chagall/base.rb
95
95
  - lib/chagall/cli.rb
96
- - lib/chagall/compose/main.rb
97
- - lib/chagall/deploy/main.rb
96
+ - lib/chagall/compose.rb
97
+ - lib/chagall/deploy.rb
98
98
  - lib/chagall/install/main.rb
99
99
  - lib/chagall/install/templates/template.Dockerfile
100
100
  - lib/chagall/install/templates/template.compose.yaml
101
+ - lib/chagall/rollback.rb
101
102
  - lib/chagall/settings.rb
102
- - lib/chagall/setup/main.rb
103
+ - lib/chagall/setup.rb
103
104
  - lib/chagall/ssh.rb
104
105
  - lib/chagall/version.rb
105
106
  homepage: https://github.com/frontandstart/chagall
@@ -1,45 +0,0 @@
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
@@ -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