deploio-cli 0.1.1 → 0.3.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,18 @@ 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:list" => "'1:database:_#{program_name}_pg_databases_list'",
65
+ "pg:backups:download" => "'1:database:_#{program_name}_pg_databases_list'"
61
66
  }
62
67
  end
63
68
 
@@ -71,10 +76,24 @@ module Deploio
71
76
  commands = klass.commands.except("help").map do |cmd_name, cmd|
72
77
  [cmd_name, cmd.description, cmd.options]
73
78
  end
74
- [name, commands, klass.class_options]
79
+ default_task = klass.default_command if klass.respond_to?(:default_command)
80
+ [name, commands, klass.class_options, default_task, klass]
75
81
  end
76
82
  end
77
83
 
84
+ def nested_subcommands
85
+ result = []
86
+ cli_class.subcommand_classes.each do |parent_name, parent_klass|
87
+ parent_klass.subcommand_classes.each do |nested_name, nested_klass|
88
+ commands = nested_klass.commands.except("help").map do |cmd_name, cmd|
89
+ [cmd_name, cmd.description, cmd.options]
90
+ end
91
+ result << ["#{parent_name}:#{nested_name}", commands, nested_klass.class_options]
92
+ end
93
+ end
94
+ result
95
+ end
96
+
78
97
  def main_commands
79
98
  cli_class.commands.except("help").map do |name, cmd|
80
99
  [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,145 @@ 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
+ []
139
+ end
140
+
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
62
152
  end
63
153
 
64
- def edit_app(app_ref)
65
- exec_passthrough("edit", "app", app_ref.app_name,
66
- "--project", app_ref.project_name)
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
+ []
172
+ end
173
+
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
67
183
  end
68
184
 
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)
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
75
198
  end
76
199
 
77
- def delete_app(app_ref)
78
- run("delete", "app", app_ref.app_name,
79
- "--project", app_ref.project_name)
200
+ def get_bucket_user_access_key(name, project:)
201
+ capture("get", "bucketuser", name, "--project", project, "--print-access-key").strip
80
202
  end
81
203
 
82
- def create_project(project_name)
83
- run("create", "project", project_name)
204
+ def get_bucket_user_secret_key(name, project:)
205
+ capture("get", "bucketuser", name, "--project", project, "--print-secret-key").strip
84
206
  end
85
207
 
86
208
  def get_projects
@@ -88,11 +210,42 @@ module Deploio
88
210
  return [] if output.nil? || output.empty?
89
211
 
90
212
  data = JSON.parse(output)
91
- data.is_a?(Array) ? data : (data["items"] || [])
213
+ projects = data.is_a?(Array) ? data : (data["items"] || [])
214
+
215
+ display_names = get_project_display_names
216
+ projects.each do |project|
217
+ name = project.dig("metadata", "name")
218
+ if display_names[name]
219
+ project["spec"] ||= {}
220
+ project["spec"]["displayName"] = display_names[name]
221
+ end
222
+ end
223
+
224
+ projects
92
225
  rescue JSON::ParserError
93
226
  []
94
227
  end
95
228
 
229
+ # Enrich with display names from table output (since JSON excludes spec.displayName)
230
+ # TODO: https://github.com/ninech/nctl/pull/339
231
+ def get_project_display_names
232
+ output = capture("get", "projects")
233
+ return {} if output.nil? || output.empty?
234
+
235
+ display_names = {}
236
+ output.each_line.drop(1).each do |line| # Skip header
237
+ parts = line.strip.split(" ").map(&:strip)
238
+ next if parts.length < 2
239
+
240
+ project_name = parts[0]
241
+ display_name = parts[1]
242
+ display_names[project_name] = display_name unless display_name == "<none>"
243
+ end
244
+ display_names
245
+ rescue Deploio::NctlError
246
+ {}
247
+ end
248
+
96
249
  def get_orgs
97
250
  output = capture("auth", "whoami")
98
251
  return [] if output.nil? || output.empty?
@@ -178,6 +331,7 @@ module Deploio
178
331
  Output.command(cmd.join(" "))
179
332
  ""
180
333
  else
334
+ puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
181
335
  stdout, stderr, status = Open3.capture3(*cmd)
182
336
  unless status.success?
183
337
  raise Deploio::NctlError, "nctl command failed: #{stderr}"
@@ -210,5 +364,38 @@ module Deploio
210
364
  raise Deploio::NctlError,
211
365
  "nctl version #{version} is too old. Need #{REQUIRED_VERSION}+. Run: brew upgrade nctl"
212
366
  end
367
+
368
+ def build_keyvaluestore_connection_string(name, project)
369
+ data = get_service("keyvaluestore", name, project: project)
370
+ return nil unless data
371
+
372
+ at_provider = data.dig("status", "atProvider") || {}
373
+ fqdn = at_provider["fqdn"]
374
+ return nil if fqdn.nil? || fqdn.empty?
375
+
376
+ token = capture("get", "keyvaluestore", name, "--project", project, "--print-token").strip
377
+ "rediss://:#{token}@#{fqdn}:6379"
378
+ end
379
+
380
+ def build_opensearch_connection_string(name, project)
381
+ data = get_service("opensearch", name, project: project)
382
+ return nil unless data
383
+
384
+ at_provider = data.dig("status", "atProvider") || {}
385
+ hosts = at_provider["hosts"] || []
386
+ return nil if hosts.empty?
387
+
388
+ user = capture("get", "opensearch", name, "--project", project, "--print-user").strip
389
+ password = capture("get", "opensearch", name, "--project", project, "--print-password").strip
390
+ host = hosts.first
391
+ "https://#{user}:#{password}@#{host}"
392
+ end
393
+
394
+ def build_bucket_url(name, project)
395
+ data = get_service("bucket", name, project: project)
396
+ return nil unless data
397
+
398
+ data.dig("status", "atProvider", "publicURL")
399
+ end
213
400
  end
214
401
  end