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.
@@ -0,0 +1,253 @@
1
+ # lib/odysseus/orchestrator/web_deploy.rb
2
+
3
+ module Odysseus
4
+ module Orchestrator
5
+ class WebDeploy
6
+ # @param ssh [Odysseus::Deployer::SSH] SSH connection
7
+ # @param config [Hash] parsed deploy config
8
+ # @param logger [Object] logger (optional)
9
+ # @param secrets_loader [Odysseus::Secrets::Loader] secrets loader (optional)
10
+ def initialize(ssh:, config:, logger: nil, secrets_loader: nil)
11
+ @ssh = ssh
12
+ @config = config
13
+ @logger = logger || default_logger
14
+ @secrets_loader = secrets_loader
15
+ @docker = Odysseus::Docker::Client.new(ssh)
16
+ @caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: @docker)
17
+ end
18
+
19
+ # Execute full deploy for a web service
20
+ # @param image_tag [String] image tag to deploy
21
+ # @param role [Symbol] server role (default: :web)
22
+ # @return [Hash] deploy result
23
+ def deploy(image_tag:, role: :web)
24
+ service = @config[:service]
25
+ image = "#{@config[:image]}:#{image_tag}"
26
+
27
+ log "Starting deploy of #{service} with #{image}"
28
+
29
+ # Step 1: Ensure Caddy is running
30
+ log "Ensuring Caddy proxy is running..."
31
+ ensure_caddy!
32
+
33
+ # Step 2: Find existing containers
34
+ log "Checking for existing containers..."
35
+ old_containers = @docker.list(service: service)
36
+ log "Found #{old_containers.size} existing container(s)"
37
+
38
+ # Step 3: Start new container
39
+ log "Starting new container..."
40
+ new_container_id = start_new_container(image: image, role: role)
41
+ log "Started container: #{new_container_id[0..11]}"
42
+
43
+ # Step 4: Wait for healthy
44
+ log "Waiting for container to be healthy..."
45
+ unless wait_for_healthy(new_container_id)
46
+ handle_failed_deploy(new_container_id, old_containers)
47
+ raise Odysseus::DeployError, "Container failed health checks"
48
+ end
49
+ log "Container is healthy!"
50
+
51
+ # Step 5: Add new container to Caddy
52
+ log "Adding container to Caddy..."
53
+ add_to_caddy(new_container_id)
54
+
55
+ # Step 6: Remove old containers from Caddy and stop them
56
+ old_containers.each do |old|
57
+ log "Draining old container: #{old['ID'][0..11]}..."
58
+ drain_and_remove(old['ID'])
59
+ end
60
+
61
+ # Step 7: Cleanup old stopped containers
62
+ log "Cleaning up old containers..."
63
+ @docker.cleanup_old_containers(service: service, keep: 2)
64
+
65
+ # Step 8: Cleanup stale Caddy upstreams (in case any were missed)
66
+ log "Cleaning up stale Caddy routes..."
67
+ removed_upstreams = @caddy.cleanup_stale_upstreams(service: service)
68
+ log "Removed #{removed_upstreams.size} stale upstream(s)" if removed_upstreams.any?
69
+
70
+ {
71
+ success: true,
72
+ container_id: new_container_id,
73
+ service: service,
74
+ image: image
75
+ }
76
+ rescue StandardError => e
77
+ log "Deploy failed: #{e.message}", :error
78
+ raise
79
+ end
80
+
81
+ private
82
+
83
+ def ensure_caddy!
84
+ unless @caddy.ensure_running
85
+ raise Odysseus::DeployError, "Failed to start Caddy proxy"
86
+ end
87
+ end
88
+
89
+ def start_new_container(image:, role:)
90
+ service = @config[:service]
91
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
92
+ container_name = "#{service}-#{timestamp}"
93
+
94
+ server_config = @config[:servers][role] || {}
95
+ options = server_config[:options] || {}
96
+ proxy_config = @config[:proxy] || {}
97
+
98
+ @docker.run(
99
+ name: container_name,
100
+ image: image,
101
+ options: {
102
+ service: service,
103
+ version: timestamp,
104
+ ports: internal_port_mapping(proxy_config[:app_port]),
105
+ env: build_environment,
106
+ volumes: server_config[:volumes],
107
+ memory: options[:memory],
108
+ memory_reservation: options[:memory_reservation],
109
+ cpus: options[:cpus],
110
+ cpu_shares: options[:cpu_shares],
111
+ network: 'odysseus',
112
+ healthcheck: build_healthcheck(proxy_config[:healthcheck]),
113
+ cmd: server_config[:cmd]
114
+ }
115
+ )
116
+ end
117
+
118
+ def internal_port_mapping(app_port)
119
+ # Don't expose to host, only internal network
120
+ # Caddy will route traffic to container
121
+ return nil unless app_port
122
+
123
+ # For internal network, we don't need host port mapping
124
+ # Container exposes the port on the Docker network
125
+ nil
126
+ end
127
+
128
+ def build_environment
129
+ env = {}
130
+
131
+ # Clear env vars (hardcoded values)
132
+ @config[:env][:clear]&.each do |key, value|
133
+ env[key.to_s] = value.to_s
134
+ end
135
+
136
+ # Secret env vars - first try encrypted file, then server environment
137
+ @config[:env][:secret]&.each do |key|
138
+ # Try encrypted secrets file first
139
+ if @secrets_loader&.configured?
140
+ value = @secrets_loader.get(key)
141
+ if value
142
+ env[key.to_s] = value.to_s
143
+ next
144
+ end
145
+ end
146
+
147
+ # Fall back to server's environment
148
+ value = @ssh.execute("echo $#{key}").strip
149
+ env[key.to_s] = value unless value.empty?
150
+ end
151
+
152
+ env
153
+ end
154
+
155
+ def build_healthcheck(hc_config)
156
+ return nil unless hc_config && hc_config[:path]
157
+
158
+ port = @config[:proxy][:app_port]
159
+ path = hc_config[:path]
160
+ expect_status = hc_config[:expect_status]
161
+
162
+ # Build curl command based on expected status
163
+ cmd = if expect_status
164
+ # Check for specific status code or range (e.g., 301, "2xx", "3xx")
165
+ status_str = expect_status.to_s
166
+ if status_str.end_with?('xx')
167
+ # Range like "2xx" or "3xx" - check first digit
168
+ first_digit = status_str[0]
169
+ "curl -s -o /dev/null -w '%{http_code}' http://localhost:#{port}#{path} | grep -q '^#{first_digit}' || exit 1"
170
+ else
171
+ # Specific status code like 200 or 301
172
+ "curl -s -o /dev/null -w '%{http_code}' http://localhost:#{port}#{path} | grep -q '^#{expect_status}$' || exit 1"
173
+ end
174
+ else
175
+ # Default: accept 2xx (use -f flag which fails on 4xx/5xx)
176
+ "curl -sf http://localhost:#{port}#{path} || exit 1"
177
+ end
178
+
179
+ {
180
+ cmd: cmd,
181
+ interval: hc_config[:interval] || 10,
182
+ timeout: hc_config[:timeout] || 5,
183
+ retries: 3
184
+ }
185
+ end
186
+
187
+ def wait_for_healthy(container_id, timeout: 60)
188
+ @docker.wait_healthy(container_id, timeout: timeout)
189
+ end
190
+
191
+ def add_to_caddy(container_id)
192
+ # Get container name for DNS resolution in Docker network
193
+ container_info = @ssh.execute("docker inspect --format '{{.Name}}' #{container_id}").strip
194
+ container_name = container_info.delete_prefix('/')
195
+
196
+ port = @config[:proxy][:app_port]
197
+ upstream = "#{container_name}:#{port}"
198
+ proxy_config = @config[:proxy]
199
+
200
+ @caddy.add_upstream(
201
+ service: @config[:service],
202
+ hosts: proxy_config[:hosts],
203
+ upstream: upstream,
204
+ healthcheck: proxy_config[:healthcheck],
205
+ ssl: proxy_config[:ssl],
206
+ ssl_email: proxy_config[:ssl_email]
207
+ )
208
+ end
209
+
210
+ def drain_and_remove(container_id)
211
+ container_info = @ssh.execute("docker inspect --format '{{.Name}}' #{container_id}").strip
212
+ container_name = container_info.delete_prefix('/')
213
+ port = @config[:proxy][:app_port]
214
+
215
+ # Remove from Caddy (drains connections)
216
+ @caddy.drain_upstream(
217
+ service: @config[:service],
218
+ upstream: "#{container_name}:#{port}"
219
+ )
220
+
221
+ # Give some time for connections to drain
222
+ sleep 5
223
+
224
+ # Stop and remove the old container
225
+ @docker.stop(container_id)
226
+ @docker.remove(container_id)
227
+ end
228
+
229
+ def handle_failed_deploy(new_container_id, old_containers)
230
+ log "Rolling back failed deploy...", :warn
231
+
232
+ # Remove the failed new container
233
+ @docker.stop(new_container_id)
234
+ @docker.remove(new_container_id, force: true)
235
+
236
+ # Old containers should still be running and in Caddy
237
+ log "Rollback complete - old containers still serving traffic"
238
+ end
239
+
240
+ def default_logger
241
+ @default_logger ||= Object.new.tap do |l|
242
+ def l.info(msg); puts msg; end
243
+ def l.warn(msg); puts "[WARN] #{msg}"; end
244
+ def l.error(msg); puts "[ERROR] #{msg}"; end
245
+ end
246
+ end
247
+
248
+ def log(message, level = :info)
249
+ @logger.send(level, message)
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,125 @@
1
+ # lib/odysseus/secrets/encrypted_file.rb
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'yaml'
6
+ require 'securerandom'
7
+
8
+ module Odysseus
9
+ module Secrets
10
+ class EncryptedFile
11
+ CIPHER = 'aes-256-gcm'
12
+ KEY_ENV_VAR = 'ODYSSEUS_MASTER_KEY'
13
+
14
+ class DecryptionError < Odysseus::Error; end
15
+ class MissingKeyError < Odysseus::Error; end
16
+
17
+ # @param path [String] path to encrypted secrets file
18
+ def initialize(path)
19
+ @path = path
20
+ end
21
+
22
+ # Check if the secrets file exists
23
+ # @return [Boolean]
24
+ def exists?
25
+ File.exist?(@path)
26
+ end
27
+
28
+ # Read and decrypt secrets from file
29
+ # @param key [String] master key (optional, defaults to ENV)
30
+ # @return [Hash] decrypted secrets
31
+ def read(key: nil)
32
+ key ||= master_key
33
+ raise MissingKeyError, "#{KEY_ENV_VAR} not set and no key provided" unless key
34
+
35
+ encrypted_content = File.read(@path)
36
+ decrypt(encrypted_content, key)
37
+ end
38
+
39
+ # Encrypt and write secrets to file
40
+ # @param secrets [Hash] secrets to encrypt
41
+ # @param key [String] master key (optional, defaults to ENV)
42
+ def write(secrets, key: nil)
43
+ key ||= master_key
44
+ raise MissingKeyError, "#{KEY_ENV_VAR} not set and no key provided" unless key
45
+
46
+ encrypted_content = encrypt(secrets, key)
47
+ File.write(@path, encrypted_content)
48
+ end
49
+
50
+ # Generate a new random master key
51
+ # @return [String] hex-encoded 32-byte key
52
+ def self.generate_key
53
+ SecureRandom.hex(32)
54
+ end
55
+
56
+ private
57
+
58
+ def master_key
59
+ ENV[KEY_ENV_VAR]
60
+ end
61
+
62
+ def encrypt(data, key)
63
+ cipher = OpenSSL::Cipher.new(CIPHER)
64
+ cipher.encrypt
65
+
66
+ # Generate random IV
67
+ iv = cipher.random_iv
68
+
69
+ # Derive key from master key
70
+ derived_key = derive_key(key, iv)
71
+ cipher.key = derived_key
72
+
73
+ # Encrypt
74
+ yaml_data = data.to_yaml
75
+ encrypted = cipher.update(yaml_data) + cipher.final
76
+ auth_tag = cipher.auth_tag
77
+
78
+ # Combine: iv + auth_tag + encrypted_data, all base64 encoded
79
+ combined = Base64.strict_encode64(iv) + "\n" +
80
+ Base64.strict_encode64(auth_tag) + "\n" +
81
+ Base64.strict_encode64(encrypted)
82
+
83
+ "# Odysseus encrypted secrets\n# Do not edit this file directly\n\n#{combined}"
84
+ end
85
+
86
+ def decrypt(content, key)
87
+ # Remove comments and empty lines
88
+ lines = content.lines.reject { |l| l.start_with?('#') || l.strip.empty? }
89
+
90
+ raise DecryptionError, "Invalid encrypted file format" if lines.size < 3
91
+
92
+ iv = Base64.strict_decode64(lines[0].strip)
93
+ auth_tag = Base64.strict_decode64(lines[1].strip)
94
+ encrypted = Base64.strict_decode64(lines[2].strip)
95
+
96
+ cipher = OpenSSL::Cipher.new(CIPHER)
97
+ cipher.decrypt
98
+ cipher.iv = iv
99
+
100
+ # Derive key from master key
101
+ derived_key = derive_key(key, iv)
102
+ cipher.key = derived_key
103
+ cipher.auth_tag = auth_tag
104
+
105
+ yaml_data = cipher.update(encrypted) + cipher.final
106
+ YAML.safe_load(yaml_data, permitted_classes: [Symbol], symbolize_names: true) || {}
107
+ rescue OpenSSL::Cipher::CipherError => e
108
+ raise DecryptionError, "Failed to decrypt secrets: #{e.message}. Check your ODYSSEUS_MASTER_KEY."
109
+ rescue ArgumentError => e
110
+ raise DecryptionError, "Invalid encrypted file format: #{e.message}"
111
+ end
112
+
113
+ def derive_key(master_key, salt)
114
+ # Use PBKDF2 to derive a proper key from the master key
115
+ OpenSSL::PKCS5.pbkdf2_hmac(
116
+ master_key,
117
+ salt,
118
+ 10_000, # iterations
119
+ 32, # key length (256 bits)
120
+ 'sha256'
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,56 @@
1
+ # lib/odysseus/secrets/loader.rb
2
+
3
+ module Odysseus
4
+ module Secrets
5
+ class Loader
6
+ # @param config [Hash] parsed deploy config
7
+ # @param config_dir [String] directory containing deploy.yml (for relative paths)
8
+ def initialize(config, config_dir: '.')
9
+ @config = config
10
+ @config_dir = config_dir
11
+ @cached_secrets = nil
12
+ end
13
+
14
+ # Load secrets from encrypted file if configured
15
+ # @return [Hash] secrets hash (key => value)
16
+ def load
17
+ return @cached_secrets if @cached_secrets
18
+
19
+ secrets_file = @config[:secrets_file]
20
+ return {} unless secrets_file
21
+
22
+ path = resolve_path(secrets_file)
23
+ encrypted = EncryptedFile.new(path)
24
+
25
+ unless encrypted.exists?
26
+ raise Odysseus::ConfigError, "Secrets file not found: #{path}"
27
+ end
28
+
29
+ @cached_secrets = encrypted.read
30
+ end
31
+
32
+ # Get a specific secret value
33
+ # @param key [String, Symbol] secret key
34
+ # @return [String, nil] secret value or nil
35
+ def get(key)
36
+ load[key.to_sym] || load[key.to_s]
37
+ end
38
+
39
+ # Check if secrets file is configured
40
+ # @return [Boolean]
41
+ def configured?
42
+ !@config[:secrets_file].nil?
43
+ end
44
+
45
+ private
46
+
47
+ def resolve_path(secrets_file)
48
+ if secrets_file.start_with?('/')
49
+ secrets_file
50
+ else
51
+ File.join(@config_dir, secrets_file)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,85 @@
1
+ # lib/odysseus/validators/config.rb
2
+
3
+ module Odysseus
4
+ module Validators
5
+ class Config
6
+ REQUIRED_KEYS = ['service', 'image', 'servers'].freeze
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ # Validate config structure
13
+ # @raise [Odysseus::ConfigValidationError] if invalid
14
+ def validate!
15
+ validate_required_keys!
16
+ validate_servers!
17
+ validate_proxy! if @config['proxy']
18
+ validate_env! if @config['env']
19
+ validate_ssh! if @config['ssh']
20
+ end
21
+
22
+ private
23
+
24
+ def validate_required_keys!
25
+ missing = REQUIRED_KEYS.select { |key| @config[key].nil? }
26
+ return if missing.empty?
27
+
28
+ raise Odysseus::ConfigValidationError,
29
+ "Missing required keys: #{missing.join(', ')}"
30
+ end
31
+
32
+ def validate_servers!
33
+ servers = @config['servers']
34
+ raise Odysseus::ConfigValidationError,
35
+ "servers must be a hash" unless servers.is_a?(Hash)
36
+
37
+ servers.each do |role, config|
38
+ raise Odysseus::ConfigValidationError,
39
+ "server role '#{role}' must have 'hosts' array" \
40
+ unless config.is_a?(Hash) && config['hosts'].is_a?(Array)
41
+ end
42
+ end
43
+
44
+ def validate_proxy!
45
+ proxy = @config['proxy']
46
+ return if proxy.nil?
47
+
48
+ raise Odysseus::ConfigValidationError,
49
+ "proxy must have 'app_port'" unless proxy['app_port']
50
+ end
51
+
52
+ def validate_env!
53
+ env = @config['env']
54
+ return if env.nil?
55
+
56
+ unless env.is_a?(Hash)
57
+ raise Odysseus::ConfigValidationError, "env must be a hash"
58
+ end
59
+
60
+ clear = env['clear']
61
+ unless clear.nil? || clear.is_a?(Hash)
62
+ raise Odysseus::ConfigValidationError, "env.clear must be a hash"
63
+ end
64
+
65
+ secret = env['secret']
66
+ unless secret.nil? || secret.is_a?(Array)
67
+ raise Odysseus::ConfigValidationError, "env.secret must be an array"
68
+ end
69
+ end
70
+
71
+ def validate_ssh!
72
+ ssh = @config['ssh']
73
+ return if ssh.nil?
74
+
75
+ raise Odysseus::ConfigValidationError,
76
+ "ssh must be a hash" unless ssh.is_a?(Hash)
77
+
78
+ keys = ssh['keys']
79
+ raise Odysseus::ConfigValidationError,
80
+ "ssh.keys must be an array" \
81
+ unless keys.nil? || keys.is_a?(Array)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ # lib/odysseus/version.rb
2
+
3
+ module Odysseus
4
+ VERSION = "0.1.0"
5
+ end
data/lib/odysseus.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ module Odysseus
6
+ class << self
7
+ def loader
8
+ @loader ||= begin
9
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
10
+ loader.inflector.inflect(
11
+ 'ssh' => 'SSH',
12
+ 'aws_asg' => 'AwsAsg'
13
+ )
14
+ # errors.rb doesn't follow Zeitwerk conventions (plural, defines multiple classes)
15
+ loader.ignore("#{__dir__}/odysseus/errors.rb")
16
+ loader.setup
17
+ loader
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ # Load errors manually before Zeitwerk (needed by other classes)
24
+ require_relative 'odysseus/errors'
25
+
26
+ Odysseus.loader
@@ -0,0 +1,6 @@
1
+ module Odysseus
2
+ module Core
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: odysseus-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: net-ssh
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: net-scp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '4.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.6'
68
+ description: Core library providing configuration parsing, deployers, and orchestrators
69
+ for Odysseus
70
+ email:
71
+ - thomas@imfiny.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - LICENSE.txt
78
+ - README.md
79
+ - Rakefile
80
+ - lib/odysseus.rb
81
+ - lib/odysseus/builder/client.rb
82
+ - lib/odysseus/caddy/client.rb
83
+ - lib/odysseus/config/parser.rb
84
+ - lib/odysseus/core.rb
85
+ - lib/odysseus/core/version.rb
86
+ - lib/odysseus/deployer/executor.rb
87
+ - lib/odysseus/deployer/ssh.rb
88
+ - lib/odysseus/docker/client.rb
89
+ - lib/odysseus/errors.rb
90
+ - lib/odysseus/host_providers.rb
91
+ - lib/odysseus/host_providers/aws_asg.rb
92
+ - lib/odysseus/host_providers/base.rb
93
+ - lib/odysseus/host_providers/static.rb
94
+ - lib/odysseus/orchestrator/accessory_deploy.rb
95
+ - lib/odysseus/orchestrator/job_deploy.rb
96
+ - lib/odysseus/orchestrator/web_deploy.rb
97
+ - lib/odysseus/secrets/encrypted_file.rb
98
+ - lib/odysseus/secrets/loader.rb
99
+ - lib/odysseus/validators/config.rb
100
+ - lib/odysseus/version.rb
101
+ - sig/odysseus/core.rbs
102
+ homepage: https://github.com/WA-Systems-EU/odysseus
103
+ licenses:
104
+ - LGPL-3.0-only
105
+ metadata:
106
+ allowed_push_host: https://rubygems.org
107
+ homepage_uri: https://github.com/WA-Systems-EU/odysseus
108
+ source_code_uri: https://github.com/WA-Systems-EU/odysseus
109
+ changelog_uri: https://github.com/WA-Systems-EU/odysseus/blob/trunk/CHANGELOG.md
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 3.2.0
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.6.9
125
+ specification_version: 4
126
+ summary: Core library for Odysseus deployment tool
127
+ test_files: []