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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +8 -0
- data/README.md +43 -0
- data/Rakefile +12 -0
- data/lib/odysseus/builder/client.rb +292 -0
- data/lib/odysseus/caddy/client.rb +338 -0
- data/lib/odysseus/config/parser.rb +225 -0
- data/lib/odysseus/core/version.rb +7 -0
- data/lib/odysseus/core.rb +10 -0
- data/lib/odysseus/deployer/executor.rb +389 -0
- data/lib/odysseus/deployer/ssh.rb +143 -0
- data/lib/odysseus/docker/client.rb +333 -0
- data/lib/odysseus/errors.rb +27 -0
- data/lib/odysseus/host_providers/aws_asg.rb +91 -0
- data/lib/odysseus/host_providers/base.rb +27 -0
- data/lib/odysseus/host_providers/static.rb +24 -0
- data/lib/odysseus/host_providers.rb +49 -0
- data/lib/odysseus/orchestrator/accessory_deploy.rb +309 -0
- data/lib/odysseus/orchestrator/job_deploy.rb +176 -0
- data/lib/odysseus/orchestrator/web_deploy.rb +253 -0
- data/lib/odysseus/secrets/encrypted_file.rb +125 -0
- data/lib/odysseus/secrets/loader.rb +56 -0
- data/lib/odysseus/validators/config.rb +85 -0
- data/lib/odysseus/version.rb +5 -0
- data/lib/odysseus.rb +26 -0
- data/sig/odysseus/core.rbs +6 -0
- metadata +127 -0
|
@@ -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
|
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
|
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: []
|