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.
@@ -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