brasa 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10c05491bcc0f0acb8baea99ebdf17b598c2d2306cddd27f37b20036649f7877
4
- data.tar.gz: 4b5ef6aacd6da982766cafc26e0fbbcfaa06748fe843a25049101c6611bb7bcb
3
+ metadata.gz: 3618063ca02e720d10a6a270976219d508a078ff13917d3de2fc9f81d9ab8e73
4
+ data.tar.gz: 781b40094dfc2baef08fc091d266cb79acd984dfabed6d3f54b9f68e2616c6c7
5
5
  SHA512:
6
- metadata.gz: a3f40bb61b742e672444fad37d5ed851c3bef5b8b1acdffe8a06ec7aff3b21f9ba9499cda34d882416b5d0d1e8ea6455785e9c185d50b20a53315fbc2a334112
7
- data.tar.gz: 9feffff14f3712c98cdb2070791183472425b378d055b733765802762421e95349d503e68bd2f91316b05f48db320a642ba38c61ed81b6e6715a1853f91456e1
6
+ metadata.gz: edbd4b233d795fd78006f054791743985f6a1513c90d37951313739d92a3e1c945cdf5254d7981941400575a23d26e891cb0c991554216419710001912214b22
7
+ data.tar.gz: c7db4110649d9378c0eb9d5351a15da213a716ca0037612c56632d00ea3cca2bb10e5188d43f1a709bb8dcec35cd2ff874d89f1b5bc0129e2371a08069a0cef0
@@ -5,7 +5,8 @@ module Brasa
5
5
  module Commands
6
6
  class Deploy < Base
7
7
  POLL_INTERVAL = 5
8
- MAX_POLLS = 120
8
+ DEPLOY_TIMEOUT = 1800
9
+ DEPLOY_MAX_POLLS = 360
9
10
 
10
11
  def execute(options = {})
11
12
  require_auth!
@@ -13,18 +14,16 @@ module Brasa
13
14
 
14
15
  info("Empacotando código...")
15
16
  archive_path = SourcePacker.pack
16
-
17
17
  size_kb = File.size(archive_path) / 1024
18
18
  info("Enviando #{size_kb}KB para o servidor...")
19
19
 
20
- deploy = api.upload(
21
- "/api/v1/apps/#{slug}/deploys",
20
+ deploy = api.upload("/api/v1/apps/#{slug}/deploys",
22
21
  file_path: archive_path,
23
- params: { branch: options["branch"] || project_config[:branch] || "main" }
24
- )
25
- info("Deploy ##{deploy["id"]} criado. Aguardando build e deploy...")
22
+ params: { branch: options["branch"] || project_config[:branch] || "main" })
23
+ info("Deploy ##{deploy["id"]} criado.")
24
+ info(" Na fila...")
26
25
 
27
- wait_for_deploy(slug, deploy["id"])
26
+ wait_for_deploy_complete(slug, deploy["id"])
28
27
  rescue Api::Client::ApiError => e
29
28
  error("Erro: #{e.message}")
30
29
  ensure
@@ -33,23 +32,71 @@ module Brasa
33
32
 
34
33
  private
35
34
 
36
- def wait_for_deploy(slug, deploy_id)
37
- MAX_POLLS.times do
38
- deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
35
+ def wait_for_deploy_complete(slug, deploy_id)
36
+ ws = connect_websocket
37
+ started = Time.now
38
+
39
+ ws.on_log { |msg| print msg["line"] + "\n" if msg["line"] }
40
+ ws.subscribe("DeployLogChannel", deploy_id: deploy_id)
41
+
42
+ status = ws.wait_for("DeployStatusChannel",
43
+ params: { deploy_id: deploy_id },
44
+ until_status: %w[live failed],
45
+ timeout: DEPLOY_TIMEOUT)
46
+
47
+ elapsed = (Time.now - started).to_i
48
+ if status == "live"
49
+ success("\nDeploy concluído com sucesso! (#{elapsed}s)")
50
+ else
51
+ error("\nDeploy falhou.")
52
+ end
53
+ rescue Websocket::CableClient::ConnectionError => e
54
+ $stderr.puts "\n [WebSocket indisponível: #{e.message}. Usando polling...]"
55
+ wait_for_deploy_polling(slug, deploy_id)
56
+ ensure
57
+ ws&.disconnect
58
+ end
59
+
60
+ def connect_websocket
61
+ require "brasa/websocket/cable_client"
62
+ Websocket::CableClient.new.tap(&:connect!)
63
+ end
39
64
 
65
+ def wait_for_deploy_polling(slug, deploy_id)
66
+ started = Time.now
67
+ last_status = nil
68
+ DEPLOY_MAX_POLLS.times do |i|
69
+ deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
40
70
  case deploy["status"]
41
71
  when "live"
42
- success("Deploy concluído com sucesso!")
72
+ success("\nDeploy concluído com sucesso! (#{(Time.now - started).to_i}s)")
43
73
  return
44
74
  when "failed"
45
- error("Deploy falhou.")
75
+ error("\nDeploy falhou.")
46
76
  return
47
77
  end
48
-
78
+ if deploy["status"] != last_status
79
+ print "\n #{status_label(deploy["status"])}"
80
+ last_status = deploy["status"]
81
+ end
49
82
  print "."
83
+ show_elapsed(started) if (i % 12).zero? && i > 0
84
+ sleep(POLL_INTERVAL)
85
+ rescue Faraday::Error, Errno::ECONNREFUSED, IO::TimeoutError
86
+ print "!"
50
87
  sleep(POLL_INTERVAL)
51
88
  end
52
- error("Timeout aguardando deploy.")
89
+ error("\nTimeout (#{(Time.now - started).to_i}s). Verifique com: brasa status")
90
+ end
91
+
92
+ def status_label(status)
93
+ { "queued" => "Na fila...", "building" => "Construindo imagem...",
94
+ "deploying" => "Deployando..." }[status] || status
95
+ end
96
+
97
+ def show_elapsed(started)
98
+ elapsed = (Time.now - started).to_i
99
+ print " (#{elapsed / 60}m#{elapsed % 60}s)"
53
100
  end
54
101
  end
55
102
  end
@@ -5,7 +5,10 @@ module Brasa
5
5
  module Commands
6
6
  class Up < Base
7
7
  POLL_INTERVAL = 5
8
- MAX_POLLS = 120
8
+ PROVISION_TIMEOUT = 1200
9
+ DEPLOY_TIMEOUT = 1800
10
+ PROVISION_MAX_POLLS = 240
11
+ DEPLOY_MAX_POLLS = 360
9
12
 
10
13
  def execute(options = {})
11
14
  require_auth!
@@ -18,7 +21,8 @@ module Brasa
18
21
  info("Iniciando deploy...")
19
22
  else
20
23
  info("Provisionando infraestrutura...")
21
- unless wait_for_provisioning(slug)
24
+ trigger_provision(slug) if %w[pending error].include?(@app["status"])
25
+ unless wait_for_provisioning(@app["id"], slug)
22
26
  return
23
27
  end
24
28
  success("Infraestrutura provisionada!")
@@ -26,8 +30,9 @@ module Brasa
26
30
  end
27
31
 
28
32
  deploy = trigger_deploy(slug)
33
+ info(" Na fila...")
29
34
 
30
- if wait_for_deploy(slug, deploy["id"])
35
+ if wait_for_deploy_complete(slug, deploy["id"])
31
36
  subdomain = @app["subdomain"] || slug
32
37
  success("Deploy concluído! App disponível em https://#{subdomain}.usebrasa.com.br")
33
38
  end
@@ -49,23 +54,22 @@ module Brasa
49
54
  create_app(config)
50
55
  end
51
56
 
57
+ def trigger_provision(slug)
58
+ api.post("/api/v1/apps/#{slug}/provision")
59
+ rescue Api::Client::ApiError
60
+ end
61
+
52
62
  def ensure_repo_url(slug)
53
63
  repo = detect_git_remote
54
64
  return unless repo
55
-
56
65
  api.patch("/api/v1/apps/#{slug}", body: { app: { repo_url: repo } })
57
66
  rescue Api::Client::ApiError
58
- # Não bloquear o fluxo se falhar ao atualizar repo
59
67
  end
60
68
 
61
69
  def create_app(config)
62
70
  app_params = {
63
- name: config[:app],
64
- stack: config[:stack],
65
- preset: config[:preset],
66
- region: config[:region],
67
- repo_url: detect_git_remote,
68
- repo_branch: config[:branch]
71
+ name: config[:app], stack: config[:stack], preset: config[:preset],
72
+ region: config[:region], repo_url: detect_git_remote, repo_branch: config[:branch]
69
73
  }
70
74
  app_params[:database_engine] = config[:database_engine] if config[:database_engine]
71
75
  api.post("/api/v1/apps", body: { app: app_params })
@@ -76,13 +80,8 @@ module Brasa
76
80
  archive_path = SourcePacker.pack
77
81
  size_kb = File.size(archive_path) / 1024
78
82
  info("Enviando #{size_kb}KB para o servidor...")
79
-
80
- deploy = api.upload(
81
- "/api/v1/apps/#{slug}/deploys",
82
- file_path: archive_path,
83
- params: { branch: project_config[:branch] || "main" }
84
- )
85
- deploy
83
+ api.upload("/api/v1/apps/#{slug}/deploys", file_path: archive_path,
84
+ params: { branch: project_config[:branch] || "main" })
86
85
  ensure
87
86
  FileUtils.rm_f(archive_path) if archive_path
88
87
  end
@@ -92,35 +91,108 @@ module Brasa
92
91
  remote.empty? ? nil : remote
93
92
  end
94
93
 
95
- def wait_for_provisioning(slug)
96
- MAX_POLLS.times do
94
+ # ── WebSocket methods ──
95
+
96
+ def wait_for_provisioning(app_id, slug)
97
+ ws = connect_websocket
98
+ status = ws.wait_for("ProvisionStatusChannel",
99
+ params: { app_id: app_id },
100
+ until_status: %w[active error],
101
+ timeout: PROVISION_TIMEOUT)
102
+
103
+ status == "active" ? true : (error("\nErro no provisionamento."); false)
104
+ rescue Websocket::CableClient::ConnectionError => e
105
+ warn_fallback(e)
106
+ wait_for_provisioning_polling(slug)
107
+ ensure
108
+ ws&.disconnect
109
+ end
110
+
111
+ def wait_for_deploy_complete(slug, deploy_id)
112
+ ws = connect_websocket
113
+ started = Time.now
114
+
115
+ ws.on_log { |msg| print msg["line"] + "\n" if msg["line"] }
116
+ ws.subscribe("DeployLogChannel", deploy_id: deploy_id)
117
+
118
+ status = ws.wait_for("DeployStatusChannel",
119
+ params: { deploy_id: deploy_id },
120
+ until_status: %w[live failed],
121
+ timeout: DEPLOY_TIMEOUT)
122
+
123
+ status == "live" ? true : (error("\nDeploy falhou."); false)
124
+ rescue Websocket::CableClient::ConnectionError => e
125
+ warn_fallback(e)
126
+ wait_for_deploy_polling(slug, deploy_id)
127
+ ensure
128
+ ws&.disconnect
129
+ end
130
+
131
+ def connect_websocket
132
+ require "brasa/websocket/cable_client"
133
+ Websocket::CableClient.new.tap(&:connect!)
134
+ end
135
+
136
+ def warn_fallback(err)
137
+ $stderr.puts "\n [WebSocket indisponível: #{err.message}. Usando polling...]"
138
+ end
139
+
140
+ # ── Fallback polling ──
141
+
142
+ def wait_for_provisioning_polling(slug)
143
+ started = Time.now
144
+ last_status = nil
145
+ PROVISION_MAX_POLLS.times do |i|
97
146
  app = api.get("/api/v1/apps/#{slug}")
98
147
  return true if app["status"] == "active"
99
- if app["status"] == "error"
100
- error("Erro no provisionamento.")
101
- return false
148
+ return (error("\nErro no provisionamento."); false) if app["status"] == "error"
149
+ if app["status"] != last_status
150
+ print "\n #{status_label(app["status"])}"
151
+ last_status = app["status"]
102
152
  end
103
153
  print "."
154
+ show_elapsed(started) if (i % 12).zero? && i > 0
155
+ sleep(POLL_INTERVAL)
156
+ rescue Faraday::Error, Errno::ECONNREFUSED, IO::TimeoutError
157
+ print "!"
104
158
  sleep(POLL_INTERVAL)
105
159
  end
106
- error("Timeout aguardando provisionamento.")
160
+ error("\nTimeout (#{(Time.now - started).to_i}s). Verifique com: brasa status")
107
161
  false
108
162
  end
109
163
 
110
- def wait_for_deploy(slug, deploy_id)
111
- MAX_POLLS.times do
164
+ def wait_for_deploy_polling(slug, deploy_id)
165
+ started = Time.now
166
+ last_status = nil
167
+ DEPLOY_MAX_POLLS.times do |i|
112
168
  deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
113
169
  return true if deploy["status"] == "live"
114
- if deploy["status"] == "failed"
115
- error("Deploy falhou.")
116
- return false
170
+ return (error("\nDeploy falhou."); false) if deploy["status"] == "failed"
171
+ if deploy["status"] != last_status
172
+ print "\n #{status_label(deploy["status"])}"
173
+ last_status = deploy["status"]
117
174
  end
118
175
  print "."
176
+ show_elapsed(started) if (i % 12).zero? && i > 0
177
+ sleep(POLL_INTERVAL)
178
+ rescue Faraday::Error, Errno::ECONNREFUSED, IO::TimeoutError
179
+ print "!"
119
180
  sleep(POLL_INTERVAL)
120
181
  end
121
- error("Timeout aguardando deploy.")
182
+ error("\nTimeout (#{(Time.now - started).to_i}s). Verifique com: brasa status")
122
183
  false
123
184
  end
185
+
186
+ def status_label(status)
187
+ { "pending" => "Aguardando...", "provisioning" => "Provisionando VM...",
188
+ "queued" => "Na fila...", "building" => "Construindo imagem...",
189
+ "deploying" => "Deployando..." }[status] || status
190
+ end
191
+
192
+ def show_elapsed(started)
193
+ elapsed = (Time.now - started).to_i
194
+ print " (#{elapsed / 60}m#{elapsed % 60}s)"
195
+ end
124
196
  end
125
197
  end
126
198
  end
data/lib/brasa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Brasa
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -0,0 +1,123 @@
1
+ require "websocket-client-simple"
2
+ require "json"
3
+
4
+ module Brasa
5
+ module Websocket
6
+ class CableClient
7
+ class ConnectionError < StandardError; end
8
+
9
+ RECONNECT_DELAYS = [1, 3, 10].freeze
10
+
11
+ def initialize(api_url: nil, token: nil)
12
+ @api_url = api_url || Brasa::Config.api_url
13
+ @token = token || Brasa::Config.token
14
+ @subscriptions = {}
15
+ @connected = false
16
+ @log_callback = nil
17
+ end
18
+
19
+ def connect!
20
+ raise ConnectionError, "No token. Run `brasa login`." unless @token
21
+
22
+ ws_url = @api_url.sub(%r{^https?://}, "wss://").chomp("/") + "/cable"
23
+ @ws = WebSocket::Client::Simple.connect(ws_url, headers: {
24
+ "Authorization" => "Bearer #{@token}",
25
+ "Origin" => @api_url
26
+ })
27
+
28
+ setup_handlers
29
+ wait_for_welcome
30
+ self
31
+ end
32
+
33
+ def subscribe(channel, params = {}, &callback)
34
+ identifier = { channel: channel }.merge(params).to_json
35
+ @subscriptions[identifier] = callback
36
+ send_command("subscribe", identifier)
37
+ identifier
38
+ end
39
+
40
+ def wait_for(channel, params: {}, until_status: [], timeout: 1800)
41
+ result = nil
42
+ identifier = subscribe(channel, params) do |msg|
43
+ status = msg["status"]
44
+ result = status if until_status.include?(status)
45
+ end
46
+
47
+ deadline = Time.now + timeout
48
+ loop do
49
+ return result if result
50
+ raise ConnectionError, "Timeout (#{timeout}s)" if Time.now > deadline
51
+ raise ConnectionError, "Connection lost" unless @connected
52
+ sleep 0.2
53
+ end
54
+ ensure
55
+ send_command("unsubscribe", identifier) if identifier && @connected
56
+ end
57
+
58
+ def on_log(&block)
59
+ @log_callback = block
60
+ end
61
+
62
+ def disconnect
63
+ @ws&.close
64
+ @connected = false
65
+ end
66
+
67
+ private
68
+
69
+ def setup_handlers
70
+ client = self
71
+
72
+ @ws.on :message do |msg|
73
+ data = JSON.parse(msg.data) rescue next
74
+ client.send(:handle_message, data)
75
+ end
76
+
77
+ @ws.on :close do |_|
78
+ client.instance_variable_set(:@connected, false)
79
+ end
80
+
81
+ @ws.on :error do |_|
82
+ client.instance_variable_set(:@connected, false)
83
+ end
84
+ end
85
+
86
+ def handle_message(data)
87
+ case data["type"]
88
+ when "welcome"
89
+ @connected = true
90
+ when "ping"
91
+ # keepalive — noop
92
+ when "confirm_subscription"
93
+ # confirmed
94
+ when "reject_subscription"
95
+ raise ConnectionError, "Subscription rejected"
96
+ else
97
+ identifier = data["identifier"]
98
+ message = data["message"]
99
+ return unless message
100
+
101
+ callback = @subscriptions[identifier]
102
+ callback&.call(message)
103
+
104
+ @log_callback&.call(message) if message["line"]
105
+ end
106
+ end
107
+
108
+ def wait_for_welcome
109
+ deadline = Time.now + 10
110
+ loop do
111
+ return if @connected
112
+ raise ConnectionError, "WebSocket connection timeout" if Time.now > deadline
113
+ sleep 0.1
114
+ end
115
+ end
116
+
117
+ def send_command(command, identifier)
118
+ return unless @ws&.open?
119
+ @ws.send({ command: command, identifier: identifier }.to_json)
120
+ end
121
+ end
122
+ end
123
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brasa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brasa
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0.8'
110
+ - !ruby/object:Gem::Dependency
111
+ name: websocket-client-simple
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.8'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.8'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: rspec
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -168,6 +182,7 @@ files:
168
182
  - lib/brasa/config.rb
169
183
  - lib/brasa/source_packer.rb
170
184
  - lib/brasa/version.rb
185
+ - lib/brasa/websocket/cable_client.rb
171
186
  homepage: https://usebrasa.com.br
172
187
  licenses:
173
188
  - MIT