deploio-cli 0.1.0 → 0.2.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/README.md +64 -0
- data/deploio-cli.gemspec +7 -0
- data/lib/deploio/cli.rb +17 -0
- data/lib/deploio/commands/apps.rb +29 -7
- data/lib/deploio/commands/builds.rb +139 -0
- data/lib/deploio/commands/postgresql.rb +131 -0
- data/lib/deploio/commands/postgresql_backups.rb +75 -0
- data/lib/deploio/commands/projects.rb +185 -0
- data/lib/deploio/commands/services.rb +186 -0
- data/lib/deploio/completion_generator.rb +21 -3
- data/lib/deploio/nctl_client.rb +197 -18
- data/lib/deploio/output.rb +40 -0
- data/lib/deploio/pg_database_ref.rb +66 -0
- data/lib/deploio/pg_database_resolver.rb +55 -0
- data/lib/deploio/price_fetcher.rb +201 -0
- data/lib/deploio/shared_options.rb +16 -0
- data/lib/deploio/templates/completion.zsh.erb +134 -1
- data/lib/deploio/version.rb +1 -1
- data/lib/deploio.rb +4 -0
- metadata +16 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../price_fetcher"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
module Commands
|
|
7
|
+
class Projects < Thor
|
|
8
|
+
include SharedOptions
|
|
9
|
+
|
|
10
|
+
namespace "projects"
|
|
11
|
+
|
|
12
|
+
class_option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
13
|
+
|
|
14
|
+
default_task :list
|
|
15
|
+
|
|
16
|
+
desc "list", "List all projects"
|
|
17
|
+
method_option :chf, type: :boolean, default: false,
|
|
18
|
+
desc: "Show estimated total price (CHF) for each project with breakdown"
|
|
19
|
+
def list
|
|
20
|
+
setup_options
|
|
21
|
+
raw_projects = @nctl.get_projects
|
|
22
|
+
|
|
23
|
+
if options[:json]
|
|
24
|
+
puts JSON.pretty_generate(raw_projects)
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if raw_projects.empty?
|
|
29
|
+
Output.warning("No projects found") unless merged_options[:dry_run]
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
current_org = @nctl.current_org
|
|
34
|
+
show_price = merged_options[:chf]
|
|
35
|
+
|
|
36
|
+
if show_price
|
|
37
|
+
display_projects_with_breakdown(raw_projects, current_org)
|
|
38
|
+
else
|
|
39
|
+
display_projects_simple(raw_projects, current_org)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def display_projects_simple(raw_projects, current_org)
|
|
46
|
+
rows = raw_projects.map do |project|
|
|
47
|
+
metadata = project["metadata"] || {}
|
|
48
|
+
spec = project["spec"] || {}
|
|
49
|
+
labels = metadata["labels"] || {}
|
|
50
|
+
namespace = metadata["namespace"] || ""
|
|
51
|
+
name = metadata["name"] || ""
|
|
52
|
+
|
|
53
|
+
display_name = compute_display_name(spec, labels, name, namespace)
|
|
54
|
+
|
|
55
|
+
[
|
|
56
|
+
presence(name),
|
|
57
|
+
presence(display_name, default: ""),
|
|
58
|
+
project_from_namespace(namespace, current_org)
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Output.table(rows, headers: ["PROJECT", "DISPLAY NAME", "ORGANIZATION"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def display_projects_with_breakdown(raw_projects, current_org)
|
|
66
|
+
price_fetcher = PriceFetcher.new
|
|
67
|
+
all_apps = @nctl.get_all_apps
|
|
68
|
+
all_services = @nctl.get_all_services
|
|
69
|
+
|
|
70
|
+
# Group apps and services by project
|
|
71
|
+
apps_by_project = all_apps.group_by { |a| a.dig("metadata", "namespace") }
|
|
72
|
+
services_by_project = all_services.group_by { |s| s.dig("metadata", "namespace") }
|
|
73
|
+
|
|
74
|
+
groups = {}
|
|
75
|
+
grand_total = 0
|
|
76
|
+
|
|
77
|
+
raw_projects.sort_by { |p| p.dig("metadata", "name") || "" }.each do |project|
|
|
78
|
+
metadata = project["metadata"] || {}
|
|
79
|
+
spec = project["spec"] || {}
|
|
80
|
+
labels = metadata["labels"] || {}
|
|
81
|
+
name = metadata["name"] || ""
|
|
82
|
+
|
|
83
|
+
display_name = compute_display_name(spec, labels, name, metadata["namespace"] || "")
|
|
84
|
+
project_label = display_name.empty? ? name : display_name
|
|
85
|
+
|
|
86
|
+
project_apps = apps_by_project[name] || []
|
|
87
|
+
project_services = services_by_project[name] || []
|
|
88
|
+
|
|
89
|
+
# Skip projects with no apps or services
|
|
90
|
+
next if project_apps.empty? && project_services.empty?
|
|
91
|
+
|
|
92
|
+
rows = []
|
|
93
|
+
project_total = 0
|
|
94
|
+
|
|
95
|
+
# Add apps
|
|
96
|
+
project_apps.sort_by { |a| a.dig("metadata", "name") || "" }.each do |app|
|
|
97
|
+
app_name = app.dig("metadata", "name") || "-"
|
|
98
|
+
app_spec = app["spec"] || {}
|
|
99
|
+
for_provider = app_spec["forProvider"] || {}
|
|
100
|
+
config = for_provider["config"] || {}
|
|
101
|
+
status = app["status"] || {}
|
|
102
|
+
at_provider = status["atProvider"] || {}
|
|
103
|
+
|
|
104
|
+
size = config["size"] || "micro"
|
|
105
|
+
replicas = at_provider["replicas"] || for_provider["replicas"] || 1
|
|
106
|
+
|
|
107
|
+
price = price_fetcher.price_for_app(app) || 0
|
|
108
|
+
project_total += price
|
|
109
|
+
|
|
110
|
+
size_info = (replicas.to_i > 1) ? "#{size} ×#{replicas}" : size
|
|
111
|
+
rows << [project_label, app_name, "app", size_info, format_price(price)]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Add services
|
|
115
|
+
project_services.sort_by { |s| [s["_type"] || "", s.dig("metadata", "name") || ""] }.each do |service|
|
|
116
|
+
service_name = service.dig("metadata", "name") || "-"
|
|
117
|
+
service_type = service["_type"] || "-"
|
|
118
|
+
service_spec = service["spec"] || {}
|
|
119
|
+
|
|
120
|
+
price = price_fetcher.price_for_service(service_type, service_spec) || 0
|
|
121
|
+
project_total += price
|
|
122
|
+
|
|
123
|
+
size_info = service_size_info(service_type, service_spec)
|
|
124
|
+
rows << [project_label, service_name, service_type, size_info, format_price(price)]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Add subtotal row
|
|
128
|
+
rows << [project_label, "SUBTOTAL", "", "", format_price(project_total)]
|
|
129
|
+
grand_total += project_total
|
|
130
|
+
|
|
131
|
+
groups[project_label] = rows
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if groups.empty?
|
|
135
|
+
Output.warning("No apps or services found")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
Output.grouped_table(groups, headers: %w[PROJECT NAME TYPE SIZE PRICE])
|
|
140
|
+
puts
|
|
141
|
+
puts "Grand Total: CHF #{grand_total}/mo"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def service_size_info(type, spec)
|
|
145
|
+
for_provider = spec["forProvider"] || {}
|
|
146
|
+
|
|
147
|
+
case type
|
|
148
|
+
when "postgres", "mysql"
|
|
149
|
+
for_provider["machineType"] || for_provider["singleDBMachineType"] || "-"
|
|
150
|
+
when "keyvaluestore"
|
|
151
|
+
for_provider["memorySize"] || "-"
|
|
152
|
+
else
|
|
153
|
+
"-"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format_price(price)
|
|
158
|
+
(price > 0) ? "CHF #{price}/mo" : "-"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def compute_display_name(spec, _labels, name, namespace)
|
|
164
|
+
# Use displayName from spec if available (enriched from table output)
|
|
165
|
+
return spec["displayName"] if spec["displayName"] && !spec["displayName"].empty?
|
|
166
|
+
|
|
167
|
+
# Fallback: strip namespace prefix from name
|
|
168
|
+
# e.g., "n10518-mytextur" -> "mytextur"
|
|
169
|
+
name.delete_prefix("#{namespace}-")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def presence(value, default: "-")
|
|
173
|
+
(value.nil? || value.to_s.empty?) ? default : value
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def project_from_namespace(namespace, current_org)
|
|
177
|
+
if current_org && namespace == current_org
|
|
178
|
+
"#{namespace} (current)"
|
|
179
|
+
else
|
|
180
|
+
namespace
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../price_fetcher"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
module Commands
|
|
7
|
+
class Services < Thor
|
|
8
|
+
include SharedOptions
|
|
9
|
+
|
|
10
|
+
namespace "services"
|
|
11
|
+
|
|
12
|
+
class_option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
13
|
+
|
|
14
|
+
default_task :list
|
|
15
|
+
|
|
16
|
+
desc "list", "List services (all or filtered by project)"
|
|
17
|
+
method_option :project, aliases: "-p", type: :string,
|
|
18
|
+
desc: "Filter by project name"
|
|
19
|
+
method_option :url, aliases: "-u", type: :boolean, default: false,
|
|
20
|
+
desc: "Show connection URL for each service (requires --project)"
|
|
21
|
+
method_option :connected_apps, aliases: "-c", type: :boolean, default: false,
|
|
22
|
+
desc: "Show apps connected to each service (requires --project)"
|
|
23
|
+
method_option :chf, type: :boolean, default: false,
|
|
24
|
+
desc: "Show estimated price (CHF) for each service"
|
|
25
|
+
def list
|
|
26
|
+
setup_options
|
|
27
|
+
|
|
28
|
+
validate_project_required_options!
|
|
29
|
+
|
|
30
|
+
project = merged_options[:project] ? resolve_project(merged_options[:project]) : nil
|
|
31
|
+
all_services = @nctl.get_all_services(project: project)
|
|
32
|
+
|
|
33
|
+
if options[:json]
|
|
34
|
+
puts JSON.pretty_generate(all_services)
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if all_services.empty?
|
|
39
|
+
msg = project ? "No services found in project #{merged_options[:project]}" : "No services found"
|
|
40
|
+
Output.warning(msg) unless merged_options[:dry_run]
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
show_url = merged_options[:url]
|
|
45
|
+
show_connected_apps = merged_options[:connected_apps]
|
|
46
|
+
show_price = merged_options[:chf]
|
|
47
|
+
current_org = @nctl.current_org
|
|
48
|
+
|
|
49
|
+
# Pre-fetch apps and their env vars if we need to show connected apps
|
|
50
|
+
apps_by_project = {}
|
|
51
|
+
if show_connected_apps
|
|
52
|
+
all_services.map { |s| s.dig("metadata", "namespace") }.uniq.each do |ns|
|
|
53
|
+
apps_by_project[ns] = @nctl.get_apps_by_project(ns)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Initialize price fetcher if needed
|
|
58
|
+
price_fetcher = PriceFetcher.new if show_price
|
|
59
|
+
|
|
60
|
+
# Build rows and group by project
|
|
61
|
+
grouped_rows = {}
|
|
62
|
+
all_services.each do |service|
|
|
63
|
+
metadata = service["metadata"] || {}
|
|
64
|
+
status = service["status"] || {}
|
|
65
|
+
spec = service["spec"] || {}
|
|
66
|
+
conditions = status["conditions"] || []
|
|
67
|
+
ready_condition = conditions.find { |c| c["type"] == "Ready" }
|
|
68
|
+
|
|
69
|
+
namespace = metadata["namespace"] || ""
|
|
70
|
+
name = metadata["name"] || ""
|
|
71
|
+
type = service["_type"] || "-"
|
|
72
|
+
project_name = project_from_namespace(namespace, current_org)
|
|
73
|
+
|
|
74
|
+
row = [
|
|
75
|
+
short_service_name(namespace, name, current_org),
|
|
76
|
+
project_name,
|
|
77
|
+
type,
|
|
78
|
+
presence(ready_condition&.dig("status"))
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if show_price
|
|
82
|
+
price = price_fetcher.price_for_service(type, spec)
|
|
83
|
+
row << price_fetcher.format_price(price)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get URL if needed (for display or for connected apps search)
|
|
87
|
+
url = nil
|
|
88
|
+
if show_url || show_connected_apps
|
|
89
|
+
url = @nctl.get_service_connection_string(type, name, project: namespace)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
row << presence(url) if show_url
|
|
93
|
+
|
|
94
|
+
if show_connected_apps
|
|
95
|
+
connected = find_connected_apps(apps_by_project[namespace] || [], url)
|
|
96
|
+
row << (connected.empty? ? "-" : connected.join(", "))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
grouped_rows[project_name] ||= []
|
|
100
|
+
grouped_rows[project_name] << row
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Sort groups by project name
|
|
104
|
+
sorted_groups = grouped_rows.sort.to_h
|
|
105
|
+
|
|
106
|
+
headers = %w[SERVICE PROJECT TYPE READY]
|
|
107
|
+
headers << "PRICE" if show_price
|
|
108
|
+
headers << "URL" if show_url
|
|
109
|
+
headers << "CONNECTED APPS" if show_connected_apps
|
|
110
|
+
Output.grouped_table(sorted_groups, headers: headers)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def validate_project_required_options!
|
|
116
|
+
options_requiring_project = []
|
|
117
|
+
options_requiring_project << "--url" if merged_options[:url]
|
|
118
|
+
options_requiring_project << "--connected-apps" if merged_options[:connected_apps]
|
|
119
|
+
|
|
120
|
+
return if options_requiring_project.empty? || merged_options[:project]
|
|
121
|
+
|
|
122
|
+
Output.error("The #{options_requiring_project.join(" and ")} option(s) require --project to be specified")
|
|
123
|
+
Output.info("Fetching this data for all services is too slow. Please filter by project first.")
|
|
124
|
+
Output.info("Example: deploio services -p myproject #{options_requiring_project.first}")
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def find_connected_apps(apps, service_url)
|
|
129
|
+
return [] if service_url.nil? || service_url.empty?
|
|
130
|
+
|
|
131
|
+
apps.filter_map do |app|
|
|
132
|
+
app_name = app.dig("metadata", "name")
|
|
133
|
+
env_vars = app.dig("spec", "forProvider", "config", "env") || []
|
|
134
|
+
|
|
135
|
+
# Check if any env var value contains the service URL (or a recognizable part of it)
|
|
136
|
+
connected = env_vars.any? do |env|
|
|
137
|
+
value = env["value"].to_s
|
|
138
|
+
next false if value.empty?
|
|
139
|
+
|
|
140
|
+
# Match the service URL or its host part
|
|
141
|
+
value.include?(service_url) || url_host_matches?(value, service_url)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
app_name if connected
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def url_host_matches?(env_value, service_url)
|
|
149
|
+
# Extract host from service URL and check if it appears in the env value
|
|
150
|
+
# This handles cases where the env var might have a slightly different URL format
|
|
151
|
+
service_host = extract_host(service_url)
|
|
152
|
+
return false if service_host.nil?
|
|
153
|
+
|
|
154
|
+
env_value.include?(service_host)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_host(url)
|
|
158
|
+
# Extract host from URLs like:
|
|
159
|
+
# postgres://user:pass@host.example.com/db
|
|
160
|
+
# rediss://:token@host.example.com:6379
|
|
161
|
+
# mysql://user:pass@host.example.com:3306/db
|
|
162
|
+
match = url.match(%r{://[^/]*@([^/:]+)})
|
|
163
|
+
match&.[](1)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def presence(value, default: "-")
|
|
167
|
+
return default if value.nil? || value.to_s.empty?
|
|
168
|
+
|
|
169
|
+
value.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def short_service_name(namespace, name, current_org)
|
|
173
|
+
project = project_from_namespace(namespace, current_org)
|
|
174
|
+
"#{project}-#{name}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def project_from_namespace(namespace, current_org)
|
|
178
|
+
if current_org && namespace.start_with?("#{current_org}-")
|
|
179
|
+
namespace.delete_prefix("#{current_org}-")
|
|
180
|
+
else
|
|
181
|
+
namespace
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -51,13 +51,17 @@ module Deploio
|
|
|
51
51
|
def default_option_completers
|
|
52
52
|
{
|
|
53
53
|
"app" => "app:_#{program_name}_apps_list",
|
|
54
|
-
"size" => "size:(micro mini standard)"
|
|
54
|
+
"size" => "size:(micro mini standard)",
|
|
55
|
+
"project" => "project:_#{program_name}_projects_list"
|
|
55
56
|
}
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def default_positional_completers
|
|
59
60
|
{
|
|
60
|
-
"orgs:set" => "'1:organization:_#{program_name}_orgs_list'"
|
|
61
|
+
"orgs:set" => "'1:organization:_#{program_name}_orgs_list'",
|
|
62
|
+
"pg:info" => "'1:database:_#{program_name}_pg_databases_list'",
|
|
63
|
+
"pg:backups:capture" => "'1:database:_#{program_name}_pg_databases_list'",
|
|
64
|
+
"pg:backups:download" => "'1:database:_#{program_name}_pg_databases_list'"
|
|
61
65
|
}
|
|
62
66
|
end
|
|
63
67
|
|
|
@@ -71,10 +75,24 @@ module Deploio
|
|
|
71
75
|
commands = klass.commands.except("help").map do |cmd_name, cmd|
|
|
72
76
|
[cmd_name, cmd.description, cmd.options]
|
|
73
77
|
end
|
|
74
|
-
|
|
78
|
+
default_task = klass.default_command if klass.respond_to?(:default_command)
|
|
79
|
+
[name, commands, klass.class_options, default_task, klass]
|
|
75
80
|
end
|
|
76
81
|
end
|
|
77
82
|
|
|
83
|
+
def nested_subcommands
|
|
84
|
+
result = []
|
|
85
|
+
cli_class.subcommand_classes.each do |parent_name, parent_klass|
|
|
86
|
+
parent_klass.subcommand_classes.each do |nested_name, nested_klass|
|
|
87
|
+
commands = nested_klass.commands.except("help").map do |cmd_name, cmd|
|
|
88
|
+
[cmd_name, cmd.description, cmd.options]
|
|
89
|
+
end
|
|
90
|
+
result << ["#{parent_name}:#{nested_name}", commands, nested_klass.class_options]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
78
96
|
def main_commands
|
|
79
97
|
cli_class.commands.except("help").map do |name, cmd|
|
|
80
98
|
[name, cmd.description]
|
data/lib/deploio/nctl_client.rb
CHANGED
|
@@ -26,6 +26,15 @@ module Deploio
|
|
|
26
26
|
*args)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def build_logs(build_name, app_ref: nil, tail: false, lines: 5000)
|
|
30
|
+
args = ["--lines=#{lines}"]
|
|
31
|
+
args << "-f" if tail
|
|
32
|
+
if app_ref
|
|
33
|
+
args += ["--project", app_ref.project_name, "-a", app_ref.app_name]
|
|
34
|
+
end
|
|
35
|
+
exec_passthrough("logs", "build", *[build_name].compact, *args)
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def exec_command(app_ref, command)
|
|
30
39
|
exec_passthrough("exec", "app", app_ref.app_name,
|
|
31
40
|
"--project", app_ref.project_name,
|
|
@@ -55,32 +64,137 @@ module Deploio
|
|
|
55
64
|
{}
|
|
56
65
|
end
|
|
57
66
|
|
|
58
|
-
def
|
|
59
|
-
capture("get", "
|
|
67
|
+
def get_all_pg_databases
|
|
68
|
+
output_dedicated_dbs = capture("get", "postgres", "-A", "-o", "json")
|
|
69
|
+
output_shared_dbs = capture("get", "postgresdatabase", "-A", "-o", "json")
|
|
70
|
+
if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
|
|
71
|
+
(output_shared_dbs.nil? || output_shared_dbs.empty?)
|
|
72
|
+
return []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
[
|
|
76
|
+
*JSON.parse(output_dedicated_dbs),
|
|
77
|
+
*JSON.parse(output_shared_dbs)
|
|
78
|
+
]
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def get_pg_database(db_ref)
|
|
84
|
+
output = begin
|
|
85
|
+
capture("get", "postgres", db_ref.database_name,
|
|
86
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
87
|
+
rescue Deploio::NctlError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if output.nil? || output.empty?
|
|
92
|
+
output = capture("get", "postgresdatabase", db_ref.database_name,
|
|
93
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return nil if output.nil? || output.empty?
|
|
97
|
+
|
|
98
|
+
JSON.parse(output)
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def get_apps_by_project(project)
|
|
104
|
+
output = capture("get", "apps", "--project", project, "-o", "json")
|
|
105
|
+
return [] if output.nil? || output.empty?
|
|
106
|
+
|
|
107
|
+
data = JSON.parse(output)
|
|
108
|
+
data.is_a?(Array) ? data : (data["items"] || [])
|
|
109
|
+
rescue JSON::ParserError
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def get_all_builds
|
|
114
|
+
output = capture("get", "builds", "-A", "-o", "json")
|
|
115
|
+
return [] if output.nil? || output.empty?
|
|
116
|
+
|
|
117
|
+
data = JSON.parse(output)
|
|
118
|
+
builds = data.is_a?(Array) ? data : (data["items"] || [])
|
|
119
|
+
# Sort by creation timestamp descending (newest first)
|
|
120
|
+
builds.sort_by { |b| b.dig("metadata", "creationTimestamp") || "" }.reverse
|
|
121
|
+
rescue JSON::ParserError
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param app_ref [Deploio::AppRef]
|
|
126
|
+
def get_builds(app_ref)
|
|
127
|
+
output = capture("get", "builds",
|
|
60
128
|
"--project", app_ref.project_name,
|
|
61
|
-
"-
|
|
129
|
+
"-a", app_ref.app_name,
|
|
130
|
+
"-o", "json")
|
|
131
|
+
return [] if output.nil? || output.empty?
|
|
132
|
+
|
|
133
|
+
data = JSON.parse(output)
|
|
134
|
+
builds = data.is_a?(Array) ? data : (data["items"] || [])
|
|
135
|
+
# Sort by creation timestamp descending (newest first)
|
|
136
|
+
builds.sort_by { |b| b.dig("metadata", "creationTimestamp") || "" }.reverse
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
[]
|
|
62
139
|
end
|
|
63
140
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
141
|
+
def get_all_services(project: nil)
|
|
142
|
+
service_types = %w[keyvaluestore postgres postgresdatabases mysql mysqldatabases opensearch bucket]
|
|
143
|
+
all_services = []
|
|
144
|
+
|
|
145
|
+
service_types.each do |type|
|
|
146
|
+
services = get_services_by_type(type, project: project)
|
|
147
|
+
services.each { |s| s["_type"] = type }
|
|
148
|
+
all_services.concat(services)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
all_services
|
|
67
152
|
end
|
|
68
153
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"--
|
|
73
|
-
|
|
74
|
-
"
|
|
154
|
+
def get_services_by_type(type, project: nil)
|
|
155
|
+
args = ["get", type]
|
|
156
|
+
if project
|
|
157
|
+
args += ["--project", project]
|
|
158
|
+
else
|
|
159
|
+
args << "-A"
|
|
160
|
+
end
|
|
161
|
+
args += ["-o", "json"]
|
|
162
|
+
|
|
163
|
+
output = capture(*args)
|
|
164
|
+
return [] if output.nil? || output.empty?
|
|
165
|
+
|
|
166
|
+
data = JSON.parse(output)
|
|
167
|
+
data.is_a?(Array) ? data : (data["items"] || [])
|
|
168
|
+
rescue JSON::ParserError
|
|
169
|
+
[]
|
|
170
|
+
rescue Deploio::NctlError
|
|
171
|
+
[]
|
|
75
172
|
end
|
|
76
173
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
174
|
+
def get_service(type, name, project:)
|
|
175
|
+
output = capture("get", type, name, "--project", project, "-o", "json")
|
|
176
|
+
return nil if output.nil? || output.empty?
|
|
177
|
+
|
|
178
|
+
JSON.parse(output)
|
|
179
|
+
rescue JSON::ParserError
|
|
180
|
+
nil
|
|
181
|
+
rescue Deploio::NctlError
|
|
182
|
+
nil
|
|
80
183
|
end
|
|
81
184
|
|
|
82
|
-
def
|
|
83
|
-
|
|
185
|
+
def get_service_connection_string(type, name, project:)
|
|
186
|
+
case type
|
|
187
|
+
when "postgres", "mysql", "postgresdatabases", "mysqldatabases"
|
|
188
|
+
capture("get", type, name, "--project", project, "--print-connection-string").strip
|
|
189
|
+
when "keyvaluestore"
|
|
190
|
+
build_keyvaluestore_connection_string(name, project)
|
|
191
|
+
when "opensearch"
|
|
192
|
+
build_opensearch_connection_string(name, project)
|
|
193
|
+
when "bucket"
|
|
194
|
+
build_bucket_url(name, project)
|
|
195
|
+
end
|
|
196
|
+
rescue Deploio::NctlError
|
|
197
|
+
nil
|
|
84
198
|
end
|
|
85
199
|
|
|
86
200
|
def get_projects
|
|
@@ -88,11 +202,42 @@ module Deploio
|
|
|
88
202
|
return [] if output.nil? || output.empty?
|
|
89
203
|
|
|
90
204
|
data = JSON.parse(output)
|
|
91
|
-
data.is_a?(Array) ? data : (data["items"] || [])
|
|
205
|
+
projects = data.is_a?(Array) ? data : (data["items"] || [])
|
|
206
|
+
|
|
207
|
+
display_names = get_project_display_names
|
|
208
|
+
projects.each do |project|
|
|
209
|
+
name = project.dig("metadata", "name")
|
|
210
|
+
if display_names[name]
|
|
211
|
+
project["spec"] ||= {}
|
|
212
|
+
project["spec"]["displayName"] = display_names[name]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
projects
|
|
92
217
|
rescue JSON::ParserError
|
|
93
218
|
[]
|
|
94
219
|
end
|
|
95
220
|
|
|
221
|
+
# Enrich with display names from table output (since JSON excludes spec.displayName)
|
|
222
|
+
# TODO: https://github.com/ninech/nctl/pull/339
|
|
223
|
+
def get_project_display_names
|
|
224
|
+
output = capture("get", "projects")
|
|
225
|
+
return {} if output.nil? || output.empty?
|
|
226
|
+
|
|
227
|
+
display_names = {}
|
|
228
|
+
output.each_line.drop(1).each do |line| # Skip header
|
|
229
|
+
parts = line.strip.split(" ").map(&:strip)
|
|
230
|
+
next if parts.length < 2
|
|
231
|
+
|
|
232
|
+
project_name = parts[0]
|
|
233
|
+
display_name = parts[1]
|
|
234
|
+
display_names[project_name] = display_name unless display_name == "<none>"
|
|
235
|
+
end
|
|
236
|
+
display_names
|
|
237
|
+
rescue Deploio::NctlError
|
|
238
|
+
{}
|
|
239
|
+
end
|
|
240
|
+
|
|
96
241
|
def get_orgs
|
|
97
242
|
output = capture("auth", "whoami")
|
|
98
243
|
return [] if output.nil? || output.empty?
|
|
@@ -178,6 +323,7 @@ module Deploio
|
|
|
178
323
|
Output.command(cmd.join(" "))
|
|
179
324
|
""
|
|
180
325
|
else
|
|
326
|
+
puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
|
|
181
327
|
stdout, stderr, status = Open3.capture3(*cmd)
|
|
182
328
|
unless status.success?
|
|
183
329
|
raise Deploio::NctlError, "nctl command failed: #{stderr}"
|
|
@@ -210,5 +356,38 @@ module Deploio
|
|
|
210
356
|
raise Deploio::NctlError,
|
|
211
357
|
"nctl version #{version} is too old. Need #{REQUIRED_VERSION}+. Run: brew upgrade nctl"
|
|
212
358
|
end
|
|
359
|
+
|
|
360
|
+
def build_keyvaluestore_connection_string(name, project)
|
|
361
|
+
data = get_service("keyvaluestore", name, project: project)
|
|
362
|
+
return nil unless data
|
|
363
|
+
|
|
364
|
+
at_provider = data.dig("status", "atProvider") || {}
|
|
365
|
+
fqdn = at_provider["fqdn"]
|
|
366
|
+
return nil if fqdn.nil? || fqdn.empty?
|
|
367
|
+
|
|
368
|
+
token = capture("get", "keyvaluestore", name, "--project", project, "--print-token").strip
|
|
369
|
+
"rediss://:#{token}@#{fqdn}:6379"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def build_opensearch_connection_string(name, project)
|
|
373
|
+
data = get_service("opensearch", name, project: project)
|
|
374
|
+
return nil unless data
|
|
375
|
+
|
|
376
|
+
at_provider = data.dig("status", "atProvider") || {}
|
|
377
|
+
hosts = at_provider["hosts"] || []
|
|
378
|
+
return nil if hosts.empty?
|
|
379
|
+
|
|
380
|
+
user = capture("get", "opensearch", name, "--project", project, "--print-user").strip
|
|
381
|
+
password = capture("get", "opensearch", name, "--project", project, "--print-password").strip
|
|
382
|
+
host = hosts.first
|
|
383
|
+
"https://#{user}:#{password}@#{host}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def build_bucket_url(name, project)
|
|
387
|
+
data = get_service("bucket", name, project: project)
|
|
388
|
+
return nil unless data
|
|
389
|
+
|
|
390
|
+
data.dig("status", "atProvider", "publicURL")
|
|
391
|
+
end
|
|
213
392
|
end
|
|
214
393
|
end
|