odysseus-core 0.1.0

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: 917c4ba0f885774f77c2bb74ce396300be9f9ae077f5ddc8d54bd96e4766d6d8
4
+ data.tar.gz: ea50eb29cd561fa1ae3de2f6f81df06d33f4cb21bd4a36f3a0a60acc94cfd0f4
5
+ SHA512:
6
+ metadata.gz: 1eef3bc36f17dce94de204c1b4ddec3bdbc9f2d3882ec2dc2b7fd56cfe90f56a53856c8bcfb90df3ee10dce4e9236f5a748efc7b7b88d5babea919a02f636772
7
+ data.tar.gz: 2a3b7d72b6ddbc2e6210839e803a8c9173d41d952775695dea80a1172a919d22308f40e17b0f79ae37668d0b7f109b1e4591b3445ea0fab2a47cde8b9e570caa
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-19
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) Imfiny SAS / Wa Systems SAS
2
+
3
+ Odysseus is an Open Source project licensed under the terms of
4
+ the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
5
+ for license text.
6
+
7
+ Odysseus Pro and Odysseus Enterprise have a commercial-friendly license.
8
+
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Odysseus::Core
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/odysseus/core`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/odysseus-core. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/odysseus-core/blob/trunk/CODE_OF_CONDUCT.md).
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the Odysseus::Core project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/odysseus-core/blob/trunk/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,292 @@
1
+ # lib/odysseus/builder/client.rb
2
+
3
+ module Odysseus
4
+ module Builder
5
+ class Client
6
+ # @param config [Hash] builder config from deploy.yml
7
+ # @param ssh_config [Hash] SSH config (user, keys)
8
+ # @param logger [Object] logger
9
+ # @param verbose [Boolean] show commands being executed
10
+ def initialize(config:, ssh_config: {}, logger: nil, verbose: false)
11
+ @config = normalize_config(config)
12
+ @ssh_config = ssh_config
13
+ @logger = logger || default_logger
14
+ @verbose = verbose
15
+ end
16
+
17
+ # Build Docker image
18
+ # @param context_path [String] path to build context (directory with Dockerfile)
19
+ # @param image [String] full image name with tag (e.g., "registry/app:v1.0.0")
20
+ # @return [Hash] build result with :success, :image, :strategy
21
+ def build(context_path:, image:)
22
+ @logger.info("Building image: #{image}")
23
+
24
+ case strategy
25
+ when :local
26
+ build_local(context_path: context_path, image: image)
27
+ when :remote
28
+ build_remote(context_path: context_path, image: image)
29
+ else
30
+ raise BuildError, "Unknown build strategy: #{strategy}"
31
+ end
32
+ end
33
+
34
+ # Build and push to registry
35
+ # @param context_path [String] path to build context
36
+ # @param image [String] full image name with tag
37
+ # @param registry [Hash] registry config (server, username, password)
38
+ # @return [Hash] build and push result
39
+ def build_and_push(context_path:, image:, registry: nil)
40
+ result = build(context_path: context_path, image: image)
41
+ return result unless result[:success]
42
+
43
+ if @config[:push] || registry
44
+ push_result = push(image: image, registry: registry)
45
+ result.merge(pushed: push_result[:success], push_output: push_result[:output])
46
+ else
47
+ result.merge(pushed: false)
48
+ end
49
+ end
50
+
51
+ # Push image to registry
52
+ # @param image [String] full image name with tag
53
+ # @param registry [Hash] registry config (server, username, password)
54
+ # @return [Hash] push result
55
+ def push(image:, registry: nil)
56
+ @logger.info("Pushing image to registry: #{image}")
57
+
58
+ executor = build_executor
59
+
60
+ # Login if credentials provided
61
+ if registry && registry[:username]
62
+ login_cmd = build_login_command(registry)
63
+ executor.call(login_cmd)
64
+ end
65
+
66
+ output = executor.call("docker push #{image}")
67
+ { success: true, output: output }
68
+ rescue SSHCommandError, BuildError => e
69
+ @logger.error("Push failed: #{e.message}")
70
+ { success: false, error: e.message }
71
+ end
72
+
73
+ # Push image directly to remote host via SSH (using docker pussh/unregistry)
74
+ # @param image [String] full image name with tag
75
+ # @param host [String] target host to push to
76
+ # @param user [String] SSH user (default: from ssh_config)
77
+ # @return [Hash] pussh result
78
+ def pussh(image:, host:, user: nil)
79
+ ssh_user = user || @ssh_config[:user] || 'root'
80
+ target = "#{ssh_user}@#{host}"
81
+
82
+ @logger.info("Pushing image via SSH to #{target}: #{image}")
83
+
84
+ # docker pussh uses: docker pussh IMAGE [USER@]HOST
85
+ cmd = "docker pussh #{image} #{target}"
86
+ @logger.debug(cmd) if @logger.respond_to?(:debug)
87
+
88
+ output = execute_local_command(cmd)
89
+ { success: true, output: output, host: host }
90
+ rescue BuildError => e
91
+ @logger.error("Pussh failed: #{e.message}")
92
+ { success: false, error: e.message, host: host }
93
+ end
94
+
95
+ # Push image to multiple hosts via SSH
96
+ # @param image [String] full image name with tag
97
+ # @param hosts [Array<String>] list of target hosts
98
+ # @param user [String] SSH user (default: from ssh_config)
99
+ # @return [Hash] results for each host
100
+ def pussh_to_hosts(image:, hosts:, user: nil)
101
+ results = {}
102
+
103
+ hosts.each do |host|
104
+ @logger.info("Pushing to #{host}...")
105
+ results[host] = pussh(image: image, host: host, user: user)
106
+ end
107
+
108
+ {
109
+ success: results.values.all? { |r| r[:success] },
110
+ results: results
111
+ }
112
+ end
113
+
114
+ # Check if image exists (locally or on build host)
115
+ # @param image [String] full image name with tag
116
+ # @return [Boolean]
117
+ def image_exists?(image)
118
+ executor = build_executor
119
+ output = executor.call("docker images -q #{image} 2>/dev/null || echo ''")
120
+ !output.strip.empty?
121
+ rescue
122
+ false
123
+ end
124
+
125
+ # Get configured build strategy
126
+ # @return [Symbol] :local or :remote
127
+ def strategy
128
+ @config[:strategy]
129
+ end
130
+
131
+ # Get configured build host (for remote strategy)
132
+ # @return [String, nil]
133
+ def build_host
134
+ @config[:host]
135
+ end
136
+
137
+ private
138
+
139
+ def normalize_config(config)
140
+ {
141
+ strategy: (config[:strategy] || config['strategy'] || 'local').to_sym,
142
+ host: config[:host] || config['host'],
143
+ dockerfile: config[:dockerfile] || config['dockerfile'] || 'Dockerfile',
144
+ context: config[:context] || config['context'] || '.',
145
+ arch: config[:arch] || config['arch'],
146
+ platforms: config[:platforms] || config['platforms'] || [],
147
+ build_args: config[:build_args] || config['build_args'] || {},
148
+ cache: config.key?(:cache) ? config[:cache] : (config.key?('cache') ? config['cache'] : true),
149
+ push: config[:push] || config['push'] || false,
150
+ multiarch: config[:multiarch] || config['multiarch'] || false
151
+ }
152
+ end
153
+
154
+ def build_local(context_path:, image:)
155
+ @logger.info("Building locally...")
156
+
157
+ cmd = build_docker_command(context_path: context_path, image: image)
158
+ @logger.debug(cmd) if @logger.respond_to?(:debug)
159
+
160
+ # For local builds, execute directly
161
+ output = execute_local(cmd, context_path)
162
+
163
+ { success: true, image: image, strategy: :local, output: output }
164
+ rescue => e
165
+ @logger.error("Local build failed: #{e.message}")
166
+ { success: false, strategy: :local, error: e.message }
167
+ end
168
+
169
+ def build_remote(context_path:, image:)
170
+ @logger.info("Building on remote host: #{@config[:host]}")
171
+
172
+ unless @config[:host]
173
+ raise BuildError, "Remote build strategy requires 'host' to be configured"
174
+ end
175
+
176
+ ssh = connect_to_build_host
177
+
178
+ begin
179
+ # Create remote build directory
180
+ remote_dir = "/tmp/odysseus-build-#{Time.now.to_i}"
181
+ ssh.execute("mkdir -p #{remote_dir}")
182
+
183
+ # Upload build context
184
+ @logger.info("Uploading build context...")
185
+ ssh.upload(context_path, remote_dir)
186
+
187
+ # Determine the actual context directory on remote
188
+ context_name = File.basename(context_path)
189
+ remote_context = "#{remote_dir}/#{context_name}"
190
+
191
+ # Build on remote
192
+ cmd = build_docker_command(context_path: remote_context, image: image)
193
+ @logger.debug(cmd) if @logger.respond_to?(:debug)
194
+
195
+ output = ssh.execute(cmd)
196
+
197
+ # Cleanup remote directory
198
+ ssh.execute("rm -rf #{remote_dir}")
199
+
200
+ { success: true, image: image, strategy: :remote, output: output }
201
+ rescue SSHCommandError => e
202
+ @logger.error("Remote build failed: #{e.message}")
203
+ { success: false, strategy: :remote, error: e.message }
204
+ ensure
205
+ ssh.close
206
+ end
207
+ end
208
+
209
+ def build_docker_command(context_path:, image:)
210
+ parts = []
211
+
212
+ if @config[:multiarch] && @config[:platforms].any?
213
+ # Use buildx for multi-platform builds
214
+ parts << 'docker buildx build'
215
+ parts << "--platform #{@config[:platforms].join(',')}"
216
+ parts << '--push' if @config[:push]
217
+ else
218
+ parts << 'docker build'
219
+ # Single architecture build
220
+ parts << "--platform linux/#{@config[:arch]}" if @config[:arch]
221
+ end
222
+
223
+ parts << "-t #{image}"
224
+ parts << "-f #{context_path}/#{@config[:dockerfile]}"
225
+ parts << '--no-cache' unless @config[:cache]
226
+
227
+ # Add build args
228
+ @config[:build_args].each do |key, value|
229
+ parts << "--build-arg #{key}=#{value}"
230
+ end
231
+
232
+ parts << context_path
233
+
234
+ parts.join(' ')
235
+ end
236
+
237
+ def build_login_command(registry)
238
+ server = registry[:server] || ''
239
+ "echo '#{registry[:password]}' | docker login #{server} -u #{registry[:username]} --password-stdin"
240
+ end
241
+
242
+ def execute_local(cmd, working_dir)
243
+ # Execute command locally using system
244
+ Dir.chdir(working_dir) do
245
+ output = `#{cmd} 2>&1`
246
+ unless $?.success?
247
+ raise BuildError, "Build command failed: #{output}"
248
+ end
249
+ output
250
+ end
251
+ end
252
+
253
+ def build_executor
254
+ case strategy
255
+ when :local
256
+ # Return a proc that executes locally
257
+ ->(cmd) { execute_local_command(cmd) }
258
+ when :remote
259
+ # Return a proc that executes via SSH
260
+ ssh = connect_to_build_host
261
+ ->(cmd) { ssh.execute(cmd) }
262
+ end
263
+ end
264
+
265
+ def execute_local_command(cmd)
266
+ output = `#{cmd} 2>&1`
267
+ unless $?.success?
268
+ raise BuildError, "Command failed: #{output}"
269
+ end
270
+ output
271
+ end
272
+
273
+ def connect_to_build_host
274
+ Odysseus::Deployer::SSH.new(
275
+ host: @config[:host],
276
+ user: @ssh_config[:user] || 'root',
277
+ keys: @ssh_config[:keys] || [],
278
+ verbose: @verbose
279
+ )
280
+ end
281
+
282
+ def default_logger
283
+ Object.new.tap do |l|
284
+ l.define_singleton_method(:info) { |msg| puts msg }
285
+ l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
286
+ l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
287
+ l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if @verbose }
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end