gjallarhorn 0.1.0.alpha → 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 +4 -4
- data/.yardopts +12 -0
- data/README.md +116 -9
- data/examples/zero-downtime-deployment.rb +104 -0
- data/lib/gjallarhorn/adapter/aws.rb +652 -0
- data/lib/gjallarhorn/adapter/base.rb +125 -0
- data/lib/gjallarhorn/cli.rb +90 -4
- data/lib/gjallarhorn/configuration.rb +64 -8
- data/lib/gjallarhorn/deployer.rb +179 -9
- data/lib/gjallarhorn/deployment/basic.rb +171 -0
- data/lib/gjallarhorn/deployment/legacy.rb +40 -0
- data/lib/gjallarhorn/deployment/strategy.rb +189 -0
- data/lib/gjallarhorn/deployment/zero_downtime.rb +276 -0
- data/lib/gjallarhorn/history.rb +164 -0
- data/lib/gjallarhorn/proxy/kamal_proxy_manager.rb +36 -0
- data/lib/gjallarhorn/proxy/manager.rb +186 -0
- data/lib/gjallarhorn/proxy/nginx_manager.rb +362 -0
- data/lib/gjallarhorn/proxy/traefik_manager.rb +36 -0
- data/lib/gjallarhorn/version.rb +1 -1
- data/lib/gjallarhorn.rb +16 -0
- metadata +101 -6
- data/lib/gjallarhorn/adapters/aws.rb +0 -96
- data/lib/gjallarhorn/adapters/base.rb +0 -56
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Gjallarhorn
|
|
8
|
+
# Deployment history tracking and management
|
|
9
|
+
#
|
|
10
|
+
# Handles tracking of deployment history in a local JSON file,
|
|
11
|
+
# providing functionality to record deployments and query history.
|
|
12
|
+
#
|
|
13
|
+
# @since 0.1.0
|
|
14
|
+
class History
|
|
15
|
+
# Default directory for storing history files
|
|
16
|
+
DEFAULT_HISTORY_DIR = File.expand_path("~/.gjallarhorn")
|
|
17
|
+
|
|
18
|
+
# Default filename for history storage
|
|
19
|
+
DEFAULT_HISTORY_FILE = "history.json"
|
|
20
|
+
|
|
21
|
+
# Initialize history manager
|
|
22
|
+
#
|
|
23
|
+
# @param history_dir [String] Directory to store history files
|
|
24
|
+
def initialize(history_dir: DEFAULT_HISTORY_DIR)
|
|
25
|
+
@history_dir = history_dir
|
|
26
|
+
@history_file = File.join(@history_dir, DEFAULT_HISTORY_FILE)
|
|
27
|
+
ensure_history_directory
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Record a deployment attempt
|
|
31
|
+
#
|
|
32
|
+
# @param environment [String] Environment name
|
|
33
|
+
# @param image [String] Container image deployed
|
|
34
|
+
# @param status [String] Deployment status ('success', 'failed', 'started')
|
|
35
|
+
# @param strategy [String] Deployment strategy used (optional)
|
|
36
|
+
# @param error [String] Error message if deployment failed (optional)
|
|
37
|
+
# @return [void]
|
|
38
|
+
def record_deployment(environment:, image:, status:, strategy: nil, error: nil)
|
|
39
|
+
deployment_record = {
|
|
40
|
+
timestamp: Time.now.utc.iso8601,
|
|
41
|
+
environment: environment,
|
|
42
|
+
image: image,
|
|
43
|
+
status: status,
|
|
44
|
+
strategy: strategy,
|
|
45
|
+
error: error
|
|
46
|
+
}.compact
|
|
47
|
+
|
|
48
|
+
history_data = load_history
|
|
49
|
+
history_data << deployment_record
|
|
50
|
+
|
|
51
|
+
# Keep only the last 100 deployments to prevent file from growing too large
|
|
52
|
+
history_data = history_data.last(100) if history_data.length > 100
|
|
53
|
+
|
|
54
|
+
save_history(history_data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get deployment history for a specific environment
|
|
58
|
+
#
|
|
59
|
+
# @param environment [String] Environment name (optional, returns all if nil)
|
|
60
|
+
# @param limit [Integer] Maximum number of records to return
|
|
61
|
+
# @return [Array<Hash>] Array of deployment records
|
|
62
|
+
def get_history(environment: nil, limit: 20)
|
|
63
|
+
history_data = load_history
|
|
64
|
+
|
|
65
|
+
# Filter by environment if specified
|
|
66
|
+
if environment
|
|
67
|
+
history_data = history_data.select { |record| record["environment"] == environment }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Sort by timestamp (most recent first) and limit results
|
|
71
|
+
history_data.sort_by { |record| record["timestamp"] }.reverse.first(limit)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get the last successful deployment for an environment
|
|
75
|
+
#
|
|
76
|
+
# @param environment [String] Environment name
|
|
77
|
+
# @return [Hash, nil] Last successful deployment record or nil if none found
|
|
78
|
+
def last_successful_deployment(environment)
|
|
79
|
+
history_data = load_history
|
|
80
|
+
|
|
81
|
+
history_data
|
|
82
|
+
.select { |record| record["environment"] == environment && record["status"] == "success" }
|
|
83
|
+
.sort_by { |record| record["timestamp"] }
|
|
84
|
+
.last
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get available versions for rollback
|
|
88
|
+
#
|
|
89
|
+
# @param environment [String] Environment name
|
|
90
|
+
# @param limit [Integer] Maximum number of versions to return
|
|
91
|
+
# @return [Array<String>] Array of available image tags
|
|
92
|
+
def available_versions(environment, limit: 10)
|
|
93
|
+
successful_deployments = get_history(environment: environment, limit: 50)
|
|
94
|
+
.select { |record| record["status"] == "success" }
|
|
95
|
+
.uniq { |record| record["image"] }
|
|
96
|
+
|
|
97
|
+
successful_deployments.first(limit).map { |record| record["image"] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Clear history for a specific environment or all environments
|
|
101
|
+
#
|
|
102
|
+
# @param environment [String] Environment name (optional, clears all if nil)
|
|
103
|
+
# @return [void]
|
|
104
|
+
def clear_history(environment: nil)
|
|
105
|
+
if environment
|
|
106
|
+
history_data = load_history.reject { |record| record["environment"] == environment }
|
|
107
|
+
save_history(history_data)
|
|
108
|
+
else
|
|
109
|
+
save_history([])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get deployment statistics
|
|
114
|
+
#
|
|
115
|
+
# @param environment [String] Environment name (optional)
|
|
116
|
+
# @return [Hash] Statistics about deployments
|
|
117
|
+
def statistics(environment: nil)
|
|
118
|
+
history_data = load_history
|
|
119
|
+
|
|
120
|
+
if environment
|
|
121
|
+
history_data = history_data.select { |record| record["environment"] == environment }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
total = history_data.length
|
|
125
|
+
successful = history_data.count { |record| record["status"] == "success" }
|
|
126
|
+
failed = history_data.count { |record| record["status"] == "failed" }
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
total_deployments: total,
|
|
130
|
+
successful_deployments: successful,
|
|
131
|
+
failed_deployments: failed,
|
|
132
|
+
success_rate: total > 0 ? (successful.to_f / total * 100).round(2) : 0
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Ensure the history directory exists
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
def ensure_history_directory
|
|
142
|
+
FileUtils.mkdir_p(@history_dir) unless Dir.exist?(@history_dir)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Load history data from file
|
|
146
|
+
#
|
|
147
|
+
# @return [Array<Hash>] Array of deployment records
|
|
148
|
+
def load_history
|
|
149
|
+
return [] unless File.exist?(@history_file)
|
|
150
|
+
|
|
151
|
+
JSON.parse(File.read(@history_file))
|
|
152
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
153
|
+
[]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Save history data to file
|
|
157
|
+
#
|
|
158
|
+
# @param history_data [Array<Hash>] Array of deployment records
|
|
159
|
+
# @return [void]
|
|
160
|
+
def save_history(history_data)
|
|
161
|
+
File.write(@history_file, JSON.pretty_generate(history_data))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "manager"
|
|
4
|
+
|
|
5
|
+
module Gjallarhorn
|
|
6
|
+
module Proxy
|
|
7
|
+
# Kamal-proxy manager for zero-downtime deployments
|
|
8
|
+
#
|
|
9
|
+
# This is a placeholder implementation for kamal-proxy support.
|
|
10
|
+
# Full implementation will be added in a future release.
|
|
11
|
+
#
|
|
12
|
+
# @since 0.1.0
|
|
13
|
+
class KamalProxyManager < Manager
|
|
14
|
+
# Switch traffic from old containers to new container
|
|
15
|
+
#
|
|
16
|
+
# @param service_name [String] Service name
|
|
17
|
+
# @param from_containers [Array<Hash>] Containers to switch traffic from
|
|
18
|
+
# @param to_container [Hash] Container to switch traffic to
|
|
19
|
+
# @return [void]
|
|
20
|
+
def switch_traffic(service_name:, from_containers:, to_container:)
|
|
21
|
+
raise NotImplementedError, "Kamal-proxy support not yet implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get kamal-proxy status
|
|
25
|
+
#
|
|
26
|
+
# @return [Hash] Kamal-proxy status information
|
|
27
|
+
def status
|
|
28
|
+
{
|
|
29
|
+
type: "kamal-proxy",
|
|
30
|
+
status: "not_implemented",
|
|
31
|
+
upstreams: []
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gjallarhorn
|
|
4
|
+
module Proxy
|
|
5
|
+
# Base proxy manager class
|
|
6
|
+
#
|
|
7
|
+
# Handles traffic routing and switching during deployments.
|
|
8
|
+
# Supports different proxy types (nginx, traefik, kamal-proxy)
|
|
9
|
+
# for zero-downtime deployments.
|
|
10
|
+
#
|
|
11
|
+
# @since 0.1.0
|
|
12
|
+
class Manager
|
|
13
|
+
attr_reader :config, :proxy_type, :logger
|
|
14
|
+
|
|
15
|
+
# Initialize proxy manager
|
|
16
|
+
#
|
|
17
|
+
# @param config [Hash] Proxy configuration
|
|
18
|
+
# @param logger [Logger] Logger instance
|
|
19
|
+
def initialize(config, logger = nil)
|
|
20
|
+
@config = config
|
|
21
|
+
@proxy_type = config[:type] || "nginx"
|
|
22
|
+
@logger = logger || Logger.new($stdout)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Switch traffic from old containers to new container
|
|
26
|
+
#
|
|
27
|
+
# @param service_name [String] Service name
|
|
28
|
+
# @param from_containers [Array<Hash>] Containers to switch traffic from
|
|
29
|
+
# @param to_container [Hash] Container to switch traffic to
|
|
30
|
+
# @abstract Subclasses should implement this method
|
|
31
|
+
# @return [void]
|
|
32
|
+
def switch_traffic(service_name:, from_containers:, to_container:)
|
|
33
|
+
raise NotImplementedError, "Subclasses must implement switch_traffic"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get proxy status
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash] Proxy status information
|
|
39
|
+
def status
|
|
40
|
+
{
|
|
41
|
+
type: @proxy_type,
|
|
42
|
+
status: "unknown",
|
|
43
|
+
upstreams: []
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Restart proxy service
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean] True if restart successful
|
|
50
|
+
def restart
|
|
51
|
+
@logger.info "Restarting #{@proxy_type} proxy..."
|
|
52
|
+
false # Default implementation
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if proxy is healthy
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] True if proxy is responding
|
|
58
|
+
def healthy?
|
|
59
|
+
false # Default implementation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create appropriate proxy manager based on configuration
|
|
63
|
+
#
|
|
64
|
+
# @param config [Hash] Proxy configuration
|
|
65
|
+
# @param logger [Logger] Logger instance
|
|
66
|
+
# @return [Manager] Proxy manager instance
|
|
67
|
+
def self.create(config, logger = nil)
|
|
68
|
+
case config[:type]
|
|
69
|
+
when "nginx"
|
|
70
|
+
NginxManager.new(config, logger)
|
|
71
|
+
when "traefik"
|
|
72
|
+
TraefikManager.new(config, logger)
|
|
73
|
+
when "kamal-proxy"
|
|
74
|
+
KamalProxyManager.new(config, logger)
|
|
75
|
+
else
|
|
76
|
+
raise ConfigurationError, "Unsupported proxy type: #{config[:type]}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
protected
|
|
81
|
+
|
|
82
|
+
# Verify that traffic switch was successful
|
|
83
|
+
#
|
|
84
|
+
# @param service_name [String] Service name
|
|
85
|
+
# @param container [Hash] Container that should be receiving traffic
|
|
86
|
+
# @param max_attempts [Integer] Maximum verification attempts
|
|
87
|
+
# @return [Boolean] True if verification successful
|
|
88
|
+
def verify_traffic_switch(service_name, container, max_attempts = 10)
|
|
89
|
+
return true unless @config[:domain] # Skip verification if no domain configured
|
|
90
|
+
|
|
91
|
+
attempts = 0
|
|
92
|
+
url = build_health_check_url(service_name)
|
|
93
|
+
|
|
94
|
+
loop do
|
|
95
|
+
if traffic_reaching_container?(url, container)
|
|
96
|
+
@logger.info "Traffic switch verification successful for #{service_name}"
|
|
97
|
+
return true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
attempts += 1
|
|
101
|
+
if attempts >= max_attempts
|
|
102
|
+
@logger.error "Traffic switch verification failed after #{max_attempts} attempts"
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@logger.debug "Traffic switch verification attempt #{attempts}/#{max_attempts}..."
|
|
107
|
+
sleep 2
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Build health check URL for traffic verification
|
|
112
|
+
#
|
|
113
|
+
# @param service_name [String] Service name
|
|
114
|
+
# @return [String] Health check URL
|
|
115
|
+
def build_health_check_url(_service_name)
|
|
116
|
+
protocol = @config[:ssl] ? "https" : "http"
|
|
117
|
+
domain = @config[:domain] || @config[:host]
|
|
118
|
+
path = @config[:health_check_path] || "/health"
|
|
119
|
+
|
|
120
|
+
"#{protocol}://#{domain}#{path}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if traffic is reaching the specified container
|
|
124
|
+
#
|
|
125
|
+
# @param url [String] URL to check
|
|
126
|
+
# @param container [Hash] Container information
|
|
127
|
+
# @return [Boolean] True if traffic is reaching container
|
|
128
|
+
def traffic_reaching_container?(url, container)
|
|
129
|
+
require "net/http"
|
|
130
|
+
require "uri"
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
uri = URI(url)
|
|
134
|
+
response = Net::HTTP.get_response(uri)
|
|
135
|
+
|
|
136
|
+
# Check if response headers indicate traffic is coming from new container
|
|
137
|
+
container_header = response["X-Container-ID"] || response["X-Container-Name"]
|
|
138
|
+
if container_header
|
|
139
|
+
container_header == container[:id] || container_header == container[:name]
|
|
140
|
+
else
|
|
141
|
+
# If no container headers, assume success if we get a successful response
|
|
142
|
+
response.code.to_i.between?(200, 299)
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
@logger.debug "Traffic verification request failed: #{e.message}"
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Generate upstream configuration block
|
|
151
|
+
#
|
|
152
|
+
# @param service_name [String] Service name
|
|
153
|
+
# @param containers [Array<Hash>] Container information
|
|
154
|
+
# @return [String] Upstream configuration
|
|
155
|
+
def generate_upstream_config(service_name, containers)
|
|
156
|
+
servers = containers.map do |container|
|
|
157
|
+
host = container[:ip] || container[:host] || "localhost"
|
|
158
|
+
port = extract_port_from_container(container)
|
|
159
|
+
" server #{host}:#{port};"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
<<~CONFIG
|
|
163
|
+
upstream #{service_name} {
|
|
164
|
+
#{servers.join("\n")}
|
|
165
|
+
}
|
|
166
|
+
CONFIG
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Extract port from container configuration
|
|
170
|
+
#
|
|
171
|
+
# @param container [Hash] Container information
|
|
172
|
+
# @return [Integer] Port number
|
|
173
|
+
def extract_port_from_container(container)
|
|
174
|
+
# Try to get port from container port mappings
|
|
175
|
+
if container[:ports]&.any?
|
|
176
|
+
# Format: ["80:3000"] -> extract the container port (3000)
|
|
177
|
+
port_mapping = container[:ports].first
|
|
178
|
+
port_mapping.split(":").last.to_i
|
|
179
|
+
else
|
|
180
|
+
# Default to app_port from config or 3000
|
|
181
|
+
@config[:app_port] || 3000
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|