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.
@@ -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
- [name, commands, klass.class_options]
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]
@@ -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 get_app_stats(app_ref)
59
- capture("get", "app", app_ref.app_name,
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
- "-o", "stats")
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 edit_app(app_ref)
65
- exec_passthrough("edit", "app", app_ref.app_name,
66
- "--project", app_ref.project_name)
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 create_app(project, app_name, git_url:, git_revision:, size: "mini")
70
- run("create", "app", app_name,
71
- "--project", project,
72
- "--git-url", git_url,
73
- "--git-revision", git_revision,
74
- "--size", size)
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 delete_app(app_ref)
78
- run("delete", "app", app_ref.app_name,
79
- "--project", app_ref.project_name)
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 create_project(project_name)
83
- run("create", "project", project_name)
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