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 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
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'chagall'
5
+
6
+ begin
7
+ Chagall::Cli.run
8
+ rescue StandardError => e
9
+ puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
10
+ puts e.backtrace if ENV['VERBOSE'] || ENV['DEBUG']
11
+ exit 1
12
+ end
@@ -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
@@ -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