jatai 1.0.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/LICENSE.txt +21 -0
- data/README.md +65 -0
- data/exe/jatai +4 -0
- data/lib/jatai/api/client.rb +108 -0
- data/lib/jatai/cli.rb +106 -0
- data/lib/jatai/commands/base.rb +47 -0
- data/lib/jatai/commands/database.rb +75 -0
- data/lib/jatai/commands/deploy.rb +106 -0
- data/lib/jatai/commands/destroy.rb +32 -0
- data/lib/jatai/commands/domains.rb +98 -0
- data/lib/jatai/commands/env.rb +66 -0
- data/lib/jatai/commands/init.rb +83 -0
- data/lib/jatai/commands/login.rb +25 -0
- data/lib/jatai/commands/logs.rb +40 -0
- data/lib/jatai/commands/logs_export.rb +33 -0
- data/lib/jatai/commands/logs_search.rb +47 -0
- data/lib/jatai/commands/redis.rb +79 -0
- data/lib/jatai/commands/scale.rb +39 -0
- data/lib/jatai/commands/status.rb +53 -0
- data/lib/jatai/commands/up.rb +220 -0
- data/lib/jatai/config.rb +63 -0
- data/lib/jatai/source_packer.rb +52 -0
- data/lib/jatai/version.rb +3 -0
- data/lib/jatai/websocket/cable_client.rb +146 -0
- data/lib/jatai.rb +7 -0
- metadata +207 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "jatai/commands/base"
|
|
3
|
+
|
|
4
|
+
module Jatai
|
|
5
|
+
module Commands
|
|
6
|
+
class Env < Thor
|
|
7
|
+
namespace :env
|
|
8
|
+
|
|
9
|
+
desc "list", "Listar variáveis de ambiente"
|
|
10
|
+
def list
|
|
11
|
+
helper = EnvHelper.new
|
|
12
|
+
helper.require_auth!
|
|
13
|
+
slug = helper.app_slug
|
|
14
|
+
|
|
15
|
+
vars = helper.api.get("/api/v1/apps/#{slug}/env")
|
|
16
|
+
|
|
17
|
+
if vars.nil? || vars.empty?
|
|
18
|
+
helper.info("Nenhuma variável de ambiente configurada.")
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
vars.each do |key, value|
|
|
23
|
+
puts "#{helper.pastel.bold(key)}=#{value}"
|
|
24
|
+
end
|
|
25
|
+
rescue Api::Client::ApiError => e
|
|
26
|
+
EnvHelper.new.error("Erro: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc "set KEY=VALUE [KEY=VALUE...]", "Definir variáveis de ambiente"
|
|
30
|
+
def set(*pairs)
|
|
31
|
+
helper = EnvHelper.new
|
|
32
|
+
helper.require_auth!
|
|
33
|
+
slug = helper.app_slug
|
|
34
|
+
|
|
35
|
+
env_vars = {}
|
|
36
|
+
pairs.each do |pair|
|
|
37
|
+
key, value = pair.split("=", 2)
|
|
38
|
+
if key.nil? || key.empty? || value.nil?
|
|
39
|
+
helper.error("Formato inválido: #{pair}. Use KEY=VALUE.")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
env_vars[key] = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
helper.api.put("/api/v1/apps/#{slug}/env", body: { env: env_vars })
|
|
46
|
+
helper.success("Variáveis de ambiente atualizadas.")
|
|
47
|
+
rescue Api::Client::ApiError => e
|
|
48
|
+
EnvHelper.new.error("Erro: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "unset KEY [KEY...]", "Remover variáveis de ambiente"
|
|
52
|
+
def unset(*keys)
|
|
53
|
+
helper = EnvHelper.new
|
|
54
|
+
helper.require_auth!
|
|
55
|
+
slug = helper.app_slug
|
|
56
|
+
|
|
57
|
+
helper.api.delete("/api/v1/apps/#{slug}/env/#{keys.join(",")}")
|
|
58
|
+
helper.success("Variáveis removidas: #{keys.join(", ")}")
|
|
59
|
+
rescue Api::Client::ApiError => e
|
|
60
|
+
EnvHelper.new.error("Erro: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class EnvHelper < Base; end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
require "tty-prompt"
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Jatai
|
|
6
|
+
module Commands
|
|
7
|
+
class Init < Base
|
|
8
|
+
PRESETS = {
|
|
9
|
+
"micro" => "Micro — 256MB RAM, 1 réplica (Grátis)",
|
|
10
|
+
"hobby" => "Hobby — 1GB RAM, réplicas ilimitadas (R$ 19/mês)",
|
|
11
|
+
"pro" => "Pro — 2GB RAM, réplicas ilimitadas (R$ 69/mês)"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
REGIONS = { "br-se1" => "São Paulo", "br-ne1" => "Nordeste" }.freeze
|
|
15
|
+
STACKS = { "rails" => "Rails", "node" => "Node.js", "docker" => "Docker (custom Dockerfile)" }.freeze
|
|
16
|
+
DATABASE_TIERS = {
|
|
17
|
+
"mini" => "Mini — 512MB, 20 conexões (Grátis)",
|
|
18
|
+
"essencial" => "Essencial — 1GB, 50 conexões (R$ 19/mês)",
|
|
19
|
+
"padrao" => "Padrão — 5GB, 100 conexões (R$ 49/mês)"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def execute(options = {})
|
|
23
|
+
prompt = TTY::Prompt.new
|
|
24
|
+
config_path = File.join(Dir.pwd, Config::PROJECT_FILE)
|
|
25
|
+
|
|
26
|
+
if File.exist?(config_path)
|
|
27
|
+
return unless prompt.yes?("Arquivo .jatai.yml já existe. Sobrescrever?")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
stack = detect_stack
|
|
31
|
+
if stack
|
|
32
|
+
info("Stack detectado: #{STACKS[stack]}")
|
|
33
|
+
unless prompt.yes?("Confirmar stack #{STACKS[stack]}?")
|
|
34
|
+
stack = prompt.select("Stack:", STACKS.map { |k, v| { name: v, value: k } })
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
info("Não foi possível detectar o stack automaticamente.")
|
|
38
|
+
stack = prompt.select("Stack:", STACKS.map { |k, v| { name: v, value: k } })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
name = prompt.ask("Nome do app:") { |q| q.required true }
|
|
42
|
+
preset = prompt.select("Preset:", PRESETS.map { |k, v| { name: v, value: k } })
|
|
43
|
+
region = prompt.select("Região:", REGIONS.map { |k, v| { name: "#{v} (#{k})", value: k } })
|
|
44
|
+
db_tier = prompt.select("Banco de dados:", DATABASE_TIERS.map { |k, v| { name: v, value: k } })
|
|
45
|
+
branch = detect_git_branch || "main"
|
|
46
|
+
|
|
47
|
+
config = {
|
|
48
|
+
"app" => name,
|
|
49
|
+
"stack" => stack,
|
|
50
|
+
"preset" => preset,
|
|
51
|
+
"region" => region,
|
|
52
|
+
"branch" => branch,
|
|
53
|
+
"database_tier" => db_tier
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
File.write(config_path, YAML.dump(config))
|
|
57
|
+
success("Arquivo .jatai.yml criado com sucesso!")
|
|
58
|
+
info("Branch: #{branch}")
|
|
59
|
+
info("Execute `jatai up` para criar e fazer deploy do app.")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def detect_stack
|
|
65
|
+
if File.exist?(File.join(Dir.pwd, "Gemfile"))
|
|
66
|
+
gemfile = File.read(File.join(Dir.pwd, "Gemfile"))
|
|
67
|
+
return "rails" if gemfile.include?("rails")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return "node" if File.exist?(File.join(Dir.pwd, "package.json"))
|
|
71
|
+
|
|
72
|
+
return "docker" if File.exist?(File.join(Dir.pwd, "Dockerfile"))
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def detect_git_branch
|
|
78
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
79
|
+
branch.empty? ? nil : branch
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
require "tty-prompt"
|
|
3
|
+
|
|
4
|
+
module Jatai
|
|
5
|
+
module Commands
|
|
6
|
+
class Login < Base
|
|
7
|
+
def execute(options = {})
|
|
8
|
+
prompt = TTY::Prompt.new
|
|
9
|
+
|
|
10
|
+
email = prompt.ask("Email:")
|
|
11
|
+
password = prompt.mask("Senha:")
|
|
12
|
+
|
|
13
|
+
client = Api::Client.new(token: nil)
|
|
14
|
+
response = client.post("/users/tokens/sign_in", body: { email: email, password: password })
|
|
15
|
+
|
|
16
|
+
Config.save_token(response["token"])
|
|
17
|
+
success("Login realizado com sucesso!")
|
|
18
|
+
rescue Api::Client::AuthenticationError
|
|
19
|
+
error("Credenciais inválidas. Verifique email e senha.")
|
|
20
|
+
rescue Api::Client::ApiError => e
|
|
21
|
+
error("Erro ao fazer login: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
|
|
3
|
+
module Jatai
|
|
4
|
+
module Commands
|
|
5
|
+
class Logs < Base
|
|
6
|
+
DEFAULT_TAIL = 100
|
|
7
|
+
|
|
8
|
+
def execute(options = {})
|
|
9
|
+
require_auth!
|
|
10
|
+
slug = app_slug
|
|
11
|
+
tail = options.fetch("tail", DEFAULT_TAIL).to_s
|
|
12
|
+
|
|
13
|
+
logs = api.get("/api/v1/apps/#{slug}/logs", params: { tail: tail })
|
|
14
|
+
|
|
15
|
+
if logs.nil? || logs.empty?
|
|
16
|
+
info("Nenhum log disponível.")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
logs.each do |entry|
|
|
21
|
+
timestamp = entry["timestamp"]
|
|
22
|
+
source = entry["source"]
|
|
23
|
+
level = entry["level"]
|
|
24
|
+
message = entry["message"]
|
|
25
|
+
|
|
26
|
+
level_str = case level
|
|
27
|
+
when "error" then pastel.red(level)
|
|
28
|
+
when "warn" then pastel.yellow(level)
|
|
29
|
+
when "info" then pastel.green(level)
|
|
30
|
+
else pastel.dim(level || "log")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts "#{pastel.dim(timestamp)} #{pastel.cyan(source)} [#{level_str}] #{message}"
|
|
34
|
+
end
|
|
35
|
+
rescue Api::Client::ApiError => e
|
|
36
|
+
error("Erro: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
|
|
3
|
+
module Jatai
|
|
4
|
+
module Commands
|
|
5
|
+
class LogsExport < Base
|
|
6
|
+
def execute(options = {})
|
|
7
|
+
require_auth!
|
|
8
|
+
slug = app_slug
|
|
9
|
+
|
|
10
|
+
format = options.fetch("format", "json")
|
|
11
|
+
params = { export_format: format }
|
|
12
|
+
params[:since] = options["since"] if options["since"]
|
|
13
|
+
params[:until] = options["until"] if options["until"]
|
|
14
|
+
params[:level] = options["level"] if options["level"]
|
|
15
|
+
|
|
16
|
+
result = api.get("/api/v1/apps/#{slug}/logs/export", params: params)
|
|
17
|
+
|
|
18
|
+
case result
|
|
19
|
+
when Array
|
|
20
|
+
puts JSON.pretty_generate(result)
|
|
21
|
+
when Hash
|
|
22
|
+
puts JSON.pretty_generate(result)
|
|
23
|
+
when String
|
|
24
|
+
puts result
|
|
25
|
+
else
|
|
26
|
+
puts result
|
|
27
|
+
end
|
|
28
|
+
rescue Api::Client::ApiError => e
|
|
29
|
+
error("Erro: #{e.message}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
|
|
3
|
+
module Jatai
|
|
4
|
+
module Commands
|
|
5
|
+
class LogsSearch < Base
|
|
6
|
+
def execute(options = {})
|
|
7
|
+
require_auth!
|
|
8
|
+
slug = app_slug
|
|
9
|
+
query = options["query"]
|
|
10
|
+
|
|
11
|
+
unless query
|
|
12
|
+
info("Uso: jatai logs:search <query> [--level error] [--since 1h]")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
params = { q: query, limit: options.fetch("limit", "100") }
|
|
17
|
+
params[:level] = options["level"] if options["level"]
|
|
18
|
+
params[:source] = options["source"] if options["source"]
|
|
19
|
+
params[:since] = options["since"] if options["since"]
|
|
20
|
+
|
|
21
|
+
results = api.get("/api/v1/apps/#{slug}/logs/search", params: params)
|
|
22
|
+
|
|
23
|
+
if results.nil? || results.empty?
|
|
24
|
+
info("Nenhum resultado encontrado.")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
results.each do |entry|
|
|
29
|
+
puts "#{pastel.dim(entry["timestamp"])} #{pastel.cyan(entry["source"])} [#{colorize_level(entry["level"])}] #{entry["message"]}"
|
|
30
|
+
end
|
|
31
|
+
rescue Api::Client::ApiError => e
|
|
32
|
+
error("Erro: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def colorize_level(level)
|
|
38
|
+
case level
|
|
39
|
+
when "error" then pastel.red(level)
|
|
40
|
+
when "warn" then pastel.yellow(level)
|
|
41
|
+
when "info" then pastel.green(level)
|
|
42
|
+
else pastel.dim(level)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "tty-prompt"
|
|
3
|
+
require "jatai/commands/base"
|
|
4
|
+
|
|
5
|
+
module Jatai
|
|
6
|
+
module Commands
|
|
7
|
+
class Redis < Thor
|
|
8
|
+
namespace :redis
|
|
9
|
+
|
|
10
|
+
MEMORY_TO_PLAN = {
|
|
11
|
+
256 => "starter",
|
|
12
|
+
512 => "pro",
|
|
13
|
+
1024 => "business"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
desc "status", "Ver status do Redis"
|
|
17
|
+
def status
|
|
18
|
+
slug = helper.app_slug
|
|
19
|
+
|
|
20
|
+
addon = helper.api.get("/api/v1/apps/#{slug}/addons/cache")
|
|
21
|
+
|
|
22
|
+
puts helper.pastel.bold("Redis de #{slug}")
|
|
23
|
+
puts " Status: #{addon["status"]}"
|
|
24
|
+
puts " Plano: #{addon["plan"]}"
|
|
25
|
+
puts " Memória: #{addon.dig("config", "memory_mb")}MB"
|
|
26
|
+
puts " Custo: R$ #{addon["monthly_cost_brl"]}/mês"
|
|
27
|
+
rescue Api::Client::ApiError => e
|
|
28
|
+
helper.error("Erro: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "enable", "Habilitar Redis para o app"
|
|
32
|
+
option :memory, type: :numeric, default: 256, desc: "Memória em MB (256, 512, 1024)"
|
|
33
|
+
def enable
|
|
34
|
+
slug = helper.app_slug
|
|
35
|
+
|
|
36
|
+
plan = MEMORY_TO_PLAN[options[:memory]]
|
|
37
|
+
unless plan
|
|
38
|
+
helper.error("Memória inválida. Use: 256, 512 ou 1024")
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
helper.info("Habilitando Redis com #{options[:memory]}MB (plano #{plan})...")
|
|
43
|
+
result = helper.api.post("/api/v1/apps/#{slug}/addons", body: { addon: { slug: "cache", plan: plan } })
|
|
44
|
+
helper.success("Redis habilitado! Status: #{result["status"]}")
|
|
45
|
+
rescue Api::Client::ApiError => e
|
|
46
|
+
helper.error("Erro: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "disable", "Remover Redis do app"
|
|
50
|
+
def disable
|
|
51
|
+
slug = helper.app_slug
|
|
52
|
+
|
|
53
|
+
prompt = TTY::Prompt.new
|
|
54
|
+
unless prompt.yes?("Remover Redis? Todos os dados em cache serão perdidos.")
|
|
55
|
+
helper.info("Operação cancelada.")
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
helper.info("Removendo Redis...")
|
|
60
|
+
helper.api.delete("/api/v1/apps/#{slug}/addons/cache")
|
|
61
|
+
helper.success("Redis removido com sucesso!")
|
|
62
|
+
rescue Api::Client::ApiError => e
|
|
63
|
+
helper.error("Erro: #{e.message}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def helper
|
|
69
|
+
@helper ||= begin
|
|
70
|
+
h = RedisHelper.new
|
|
71
|
+
h.require_auth!
|
|
72
|
+
h
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class RedisHelper < Base; end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
require "tty-prompt"
|
|
3
|
+
|
|
4
|
+
module Jatai
|
|
5
|
+
module Commands
|
|
6
|
+
class Scale < Base
|
|
7
|
+
def execute(options = {})
|
|
8
|
+
require_auth!
|
|
9
|
+
slug = app_slug
|
|
10
|
+
|
|
11
|
+
body = {}
|
|
12
|
+
body[:replicas] = options["replicas"].to_i if options["replicas"]
|
|
13
|
+
body[:preset] = options["preset"] if options["preset"]
|
|
14
|
+
|
|
15
|
+
if body.empty?
|
|
16
|
+
error("Informe ao menos --replicas ou --preset. Ex: jatai scale --replicas 3")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
estimate = api.post("/api/v1/apps/#{slug}/scale/estimate", body: body)
|
|
21
|
+
info("Preset: #{estimate["preset"]}")
|
|
22
|
+
info("Réplicas: #{estimate["replicas"]}") if estimate["replicas"]
|
|
23
|
+
info("CPU: #{estimate["cpu_limit"]} | RAM: #{estimate["memory_limit"]}") if estimate["cpu_limit"]
|
|
24
|
+
info("Custo estimado: R$ #{estimate["monthly_cost"]}/mês")
|
|
25
|
+
|
|
26
|
+
prompt = TTY::Prompt.new
|
|
27
|
+
unless prompt.yes?("Confirmar escalonamento?")
|
|
28
|
+
info("Escalonamento cancelado.")
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
api.put("/api/v1/apps/#{slug}/scale", body: body)
|
|
33
|
+
success("Escalonamento aplicado com sucesso!")
|
|
34
|
+
rescue Api::Client::ApiError => e
|
|
35
|
+
error("Erro: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
|
|
3
|
+
module Jatai
|
|
4
|
+
module Commands
|
|
5
|
+
class Status < Base
|
|
6
|
+
def execute(options = {})
|
|
7
|
+
require_auth!
|
|
8
|
+
slug = app_slug
|
|
9
|
+
|
|
10
|
+
app = api.get("/api/v1/apps/#{slug}")
|
|
11
|
+
|
|
12
|
+
puts pastel.bold("App: #{app["name"]}")
|
|
13
|
+
puts " Slug: #{app["slug"]}"
|
|
14
|
+
puts " Stack: #{app["stack"]}"
|
|
15
|
+
puts " Status: #{colorize_status(app["status"])}"
|
|
16
|
+
puts " Região: #{app["region"]}"
|
|
17
|
+
puts " Preset: #{app["preset"]}"
|
|
18
|
+
puts " DB: #{app["database_tier"]}"
|
|
19
|
+
subdomain = app["subdomain"] || app["slug"]
|
|
20
|
+
puts " URL: https://#{subdomain}.usebrasa.com.br"
|
|
21
|
+
|
|
22
|
+
if app["last_deploy"]
|
|
23
|
+
deploy = app["last_deploy"]
|
|
24
|
+
puts ""
|
|
25
|
+
puts pastel.bold("Último deploy:")
|
|
26
|
+
puts " ID: #{deploy["id"]}"
|
|
27
|
+
puts " Status: #{colorize_status(deploy["status"])}"
|
|
28
|
+
puts " Commit: #{deploy["commit_sha"]}" if deploy["commit_sha"]
|
|
29
|
+
puts " Data: #{deploy["created_at"]}"
|
|
30
|
+
end
|
|
31
|
+
rescue Api::Client::ApiError => e
|
|
32
|
+
error("Erro: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def colorize_status(status)
|
|
38
|
+
case status
|
|
39
|
+
when "active", "live"
|
|
40
|
+
pastel.green(status)
|
|
41
|
+
when "sleeping"
|
|
42
|
+
pastel.blue(status)
|
|
43
|
+
when "failed", "error"
|
|
44
|
+
pastel.red(status)
|
|
45
|
+
when "building", "deploying", "provisioning"
|
|
46
|
+
pastel.yellow(status)
|
|
47
|
+
else
|
|
48
|
+
status
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
require "jatai/commands/base"
|
|
2
|
+
require "jatai/source_packer"
|
|
3
|
+
require "tty-spinner"
|
|
4
|
+
|
|
5
|
+
module Jatai
|
|
6
|
+
module Commands
|
|
7
|
+
class Up < Base
|
|
8
|
+
POLL_INTERVAL = 5
|
|
9
|
+
PROVISION_TIMEOUT = 1200
|
|
10
|
+
DEPLOY_TIMEOUT = 1800
|
|
11
|
+
PROVISION_MAX_POLLS = 240
|
|
12
|
+
DEPLOY_MAX_POLLS = 360
|
|
13
|
+
|
|
14
|
+
def execute(options = {})
|
|
15
|
+
require_auth!
|
|
16
|
+
config = project_config
|
|
17
|
+
|
|
18
|
+
@app = find_or_create_app(config)
|
|
19
|
+
slug = @app["slug"]
|
|
20
|
+
|
|
21
|
+
if %w[active sleeping].include?(@app["status"])
|
|
22
|
+
info("Iniciando deploy...")
|
|
23
|
+
else
|
|
24
|
+
info("Provisionando infraestrutura...")
|
|
25
|
+
trigger_provision(slug) if %w[pending error].include?(@app["status"])
|
|
26
|
+
unless wait_for_provisioning(@app["id"], slug)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
success("Infraestrutura provisionada!")
|
|
30
|
+
info("Iniciando primeiro deploy...")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
deploy = trigger_deploy(slug)
|
|
34
|
+
info(" Na fila...")
|
|
35
|
+
|
|
36
|
+
if wait_for_deploy_complete(slug, deploy["id"])
|
|
37
|
+
subdomain = @app["subdomain"] || slug
|
|
38
|
+
success("Deploy concluído! App disponível em https://#{subdomain}.usebrasa.com.br")
|
|
39
|
+
end
|
|
40
|
+
rescue Api::Client::ValidationError => e
|
|
41
|
+
error("Erro ao criar app: #{e.message}")
|
|
42
|
+
rescue Api::Client::ApiError => e
|
|
43
|
+
error("Erro: #{e.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def find_or_create_app(config)
|
|
49
|
+
existing = api.get("/api/v1/apps/#{config[:app]}")
|
|
50
|
+
info("App #{existing["slug"]} encontrado (#{existing["status"]}).")
|
|
51
|
+
ensure_repo_url(existing["slug"])
|
|
52
|
+
existing
|
|
53
|
+
rescue Api::Client::NotFoundError
|
|
54
|
+
info("Criando app #{config[:app]}...")
|
|
55
|
+
create_app(config)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def trigger_provision(slug)
|
|
59
|
+
api.post("/api/v1/apps/#{slug}/provision")
|
|
60
|
+
rescue Api::Client::ApiError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ensure_repo_url(slug)
|
|
64
|
+
repo = detect_git_remote
|
|
65
|
+
return unless repo
|
|
66
|
+
api.patch("/api/v1/apps/#{slug}", body: { app: { repo_url: repo } })
|
|
67
|
+
rescue Api::Client::ApiError
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def create_app(config)
|
|
71
|
+
app_params = {
|
|
72
|
+
name: config[:app], stack: config[:stack], preset: config[:preset],
|
|
73
|
+
region: config[:region], repo_url: detect_git_remote, repo_branch: config[:branch]
|
|
74
|
+
}
|
|
75
|
+
app_params[:database_tier] = config[:database_tier] if config[:database_tier]
|
|
76
|
+
app_params[:release_command] = config.dig(:release, :command) if config.dig(:release, :command)
|
|
77
|
+
api.post("/api/v1/apps", body: { app: app_params })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def trigger_deploy(slug)
|
|
81
|
+
info("Empacotando código...")
|
|
82
|
+
archive_path = SourcePacker.pack
|
|
83
|
+
size_kb = File.size(archive_path) / 1024
|
|
84
|
+
|
|
85
|
+
spinner = TTY::Spinner.new("#{pastel.cyan(" Enviando #{size_kb}KB...")} :spinner", format: :dots)
|
|
86
|
+
spinner.auto_spin
|
|
87
|
+
result = api.upload("/api/v1/apps/#{slug}/deploys", file_path: archive_path,
|
|
88
|
+
params: { branch: project_config[:branch] || "main" })
|
|
89
|
+
spinner.success(pastel.green("enviado!"))
|
|
90
|
+
result
|
|
91
|
+
ensure
|
|
92
|
+
FileUtils.rm_f(archive_path) if archive_path
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_git_remote
|
|
96
|
+
remote = `git remote get-url origin 2>/dev/null`.strip
|
|
97
|
+
remote.empty? ? nil : remote
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── WebSocket methods ──
|
|
101
|
+
|
|
102
|
+
def wait_for_provisioning(app_id, slug)
|
|
103
|
+
ws = connect_websocket
|
|
104
|
+
status = ws.wait_for("ProvisionStatusChannel",
|
|
105
|
+
params: { app_id: app_id },
|
|
106
|
+
until_status: %w[active error],
|
|
107
|
+
timeout: PROVISION_TIMEOUT)
|
|
108
|
+
|
|
109
|
+
if status == "active"
|
|
110
|
+
true
|
|
111
|
+
else
|
|
112
|
+
error("\nErro no provisionamento.")
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
rescue Jatai::Websocket::CableClient::ConnectionError => e
|
|
116
|
+
warn_fallback(e)
|
|
117
|
+
wait_for_provisioning_polling(slug)
|
|
118
|
+
ensure
|
|
119
|
+
ws&.disconnect
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def wait_for_deploy_complete(slug, deploy_id)
|
|
123
|
+
ws = connect_websocket
|
|
124
|
+
started = Time.now
|
|
125
|
+
|
|
126
|
+
ws.on_log { |msg| print msg["line"] + "\n" if msg.is_a?(Hash) && msg["line"] }
|
|
127
|
+
ws.subscribe("DeployLogChannel", deploy_id: deploy_id)
|
|
128
|
+
|
|
129
|
+
status = ws.wait_for("DeployStatusChannel",
|
|
130
|
+
params: { deploy_id: deploy_id },
|
|
131
|
+
until_status: %w[live failed],
|
|
132
|
+
timeout: DEPLOY_TIMEOUT)
|
|
133
|
+
|
|
134
|
+
if status == "live"
|
|
135
|
+
true
|
|
136
|
+
else
|
|
137
|
+
error("\nDeploy falhou.")
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
rescue Jatai::Websocket::CableClient::ConnectionError => e
|
|
141
|
+
warn_fallback(e)
|
|
142
|
+
wait_for_deploy_polling(slug, deploy_id)
|
|
143
|
+
ensure
|
|
144
|
+
ws&.disconnect
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def connect_websocket
|
|
148
|
+
require "jatai/websocket/cable_client"
|
|
149
|
+
Jatai::Websocket::CableClient.new.tap(&:connect!)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def warn_fallback(err)
|
|
153
|
+
$stderr.puts "\n [WebSocket indisponível: #{err.message}. Usando polling...]"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ── Fallback polling ──
|
|
157
|
+
|
|
158
|
+
def wait_for_provisioning_polling(slug)
|
|
159
|
+
started = Time.now
|
|
160
|
+
last_status = nil
|
|
161
|
+
PROVISION_MAX_POLLS.times do |i|
|
|
162
|
+
app = api.get("/api/v1/apps/#{slug}")
|
|
163
|
+
return true if app["status"] == "active"
|
|
164
|
+
if app["status"] == "error"
|
|
165
|
+
error("\nErro no provisionamento.")
|
|
166
|
+
return false
|
|
167
|
+
end
|
|
168
|
+
if app["status"] != last_status
|
|
169
|
+
print "\n #{status_label(app["status"])}"
|
|
170
|
+
last_status = app["status"]
|
|
171
|
+
end
|
|
172
|
+
print "."
|
|
173
|
+
show_elapsed(started) if (i % 12).zero? && i > 0
|
|
174
|
+
sleep(POLL_INTERVAL)
|
|
175
|
+
rescue Faraday::Error, Errno::ECONNREFUSED, IO::TimeoutError
|
|
176
|
+
print "!"
|
|
177
|
+
sleep(POLL_INTERVAL)
|
|
178
|
+
end
|
|
179
|
+
error("\nTimeout (#{(Time.now - started).to_i}s). Verifique com: jatai status")
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def wait_for_deploy_polling(slug, deploy_id)
|
|
184
|
+
started = Time.now
|
|
185
|
+
last_status = nil
|
|
186
|
+
DEPLOY_MAX_POLLS.times do |i|
|
|
187
|
+
deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
|
|
188
|
+
return true if deploy["status"] == "live"
|
|
189
|
+
if deploy["status"] == "failed"
|
|
190
|
+
error("\nDeploy falhou.")
|
|
191
|
+
return false
|
|
192
|
+
end
|
|
193
|
+
if deploy["status"] != last_status
|
|
194
|
+
print "\n #{status_label(deploy["status"])}"
|
|
195
|
+
last_status = deploy["status"]
|
|
196
|
+
end
|
|
197
|
+
print "."
|
|
198
|
+
show_elapsed(started) if (i % 12).zero? && i > 0
|
|
199
|
+
sleep(POLL_INTERVAL)
|
|
200
|
+
rescue Faraday::Error, Errno::ECONNREFUSED, IO::TimeoutError
|
|
201
|
+
print "!"
|
|
202
|
+
sleep(POLL_INTERVAL)
|
|
203
|
+
end
|
|
204
|
+
error("\nTimeout (#{(Time.now - started).to_i}s). Verifique com: jatai status")
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def status_label(status)
|
|
209
|
+
{ "pending" => "Aguardando...", "provisioning" => "Provisionando...",
|
|
210
|
+
"queued" => "Na fila...", "building" => "Construindo imagem...",
|
|
211
|
+
"deploying" => "Implantando..." }[status] || status
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def show_elapsed(started)
|
|
215
|
+
elapsed = (Time.now - started).to_i
|
|
216
|
+
print " (#{elapsed / 60}m#{elapsed % 60}s)"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|