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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4464fa0c5bfb5ca034fc5b7936d2b3d856224068fb984bea2bf03018b1e33841
4
- data.tar.gz: 8b9a0dc13c1262ebc81b8bee5e4aac9d9d47e70fb2850a6259ded636a40ea3af
3
+ metadata.gz: eae3786b2b0c98a4d02b1ac9037e9536da6cfc90ab7120ce80d4e98a76df90d7
4
+ data.tar.gz: ac86804058048327a006cd3343839299408425dd4e3702fe99aafc571e8e372c
5
5
  SHA512:
6
- metadata.gz: 61678961554d647509a198b88510862d178774814e2fe094aa42e891ed655f079a8216b9663addd75da1861a2d24d7d9c0419ec6abb50bed139cdbab1176dec3
7
- data.tar.gz: 9fcedee868c9a4621b4e0441618267074dc6963cb7ff0f8e7d00565b9f2e4f01c8f926e38e1d8ec5029c4775f975cd687e40b13f7b6e35141c2c20d814841d18
6
+ metadata.gz: 9a6a68ed578e07a9d0026f1dd61f3971ddd5f5e6b2ec590e2960644bef88de65cf054c04c2255051e0b550061eaa965a521b5700bfaf139ae5e3d76bf1f1aa19
7
+ data.tar.gz: 36614043d839a6a59c7ecc761f6d47ce8b9ff05cf149a630f33b17cb10b45ed8e374c2d1293c3ef7b9d98324290eeeb9332c2c530aea90642e18878faa08b9c4
data/README.md CHANGED
@@ -71,8 +71,25 @@ AUTHENTICATION
71
71
 
72
72
  APPS
73
73
  deploio apps List all apps
74
+ deploio apps -p PROJECT List apps in a specific project
75
+ deploio apps --chf Show estimated monthly price (CHF) for each app
74
76
  deploio apps:info -a APP Show app details
75
77
 
78
+ PROJECTS
79
+ deploio projects List all projects
80
+ deploio projects --chf Show estimated total price (CHF) for each project
81
+
82
+ BUILDS
83
+ deploio builds List all builds
84
+ deploio builds -a APP List builds for a specific app
85
+
86
+ SERVICES
87
+ deploio services List all services
88
+ deploio services -p PROJECT List services in a specific project
89
+ deploio services -p PROJECT --url List services with connection URLs (requires -p)
90
+ deploio services -p PROJECT --connected-apps Show which apps use each service (requires -p)
91
+ deploio services --chf Show estimated monthly price (CHF) for each service
92
+
76
93
  LOGS
77
94
  deploio logs -a APP Show recent logs
78
95
  deploio logs -a APP --tail Stream logs continuously
@@ -111,11 +128,58 @@ deploio auth:whoami
111
128
  # List all apps
112
129
  deploio apps
113
130
 
131
+ # List apps in a specific project
132
+ deploio apps -p myproject
133
+
134
+ # List apps with estimated monthly prices (CHF)
135
+ deploio apps --chf
136
+
114
137
  # Show app info
115
138
  deploio apps:info -a myproject-staging
139
+ ```
140
+
141
+ ### Working with projects
142
+
143
+ ```bash
144
+ # List all projects
145
+ deploio projects
146
+
147
+ # List projects with estimated total prices (apps + services)
148
+ deploio projects --chf
149
+
150
+ # Output as JSON
151
+ deploio projects --json
152
+ ```
116
153
 
117
154
  ```
118
155
 
156
+ ### Working with services
157
+
158
+ ```bash
159
+ # List all services
160
+ deploio services
161
+
162
+ # List services in a specific project
163
+ deploio services -p myproject
164
+
165
+ # List services with estimated monthly prices (CHF)
166
+ deploio services --chf
167
+
168
+ # List services with connection URLs (requires --project)
169
+ deploio services -p myproject --url
170
+
171
+ # Show which apps are connected to each service (requires --project)
172
+ deploio services -p myproject --connected-apps
173
+
174
+ # Combine options
175
+ deploio services -p myproject --url --connected-apps --chf
176
+
177
+ # Output as JSON
178
+ deploio services --json
179
+ ```
180
+
181
+ Note: Prices (--chf) are fetched from the Nine calculator API and cached locally in `~/.deploio/prices.json` for 24 hours.
182
+
119
183
  ### Logs and execution
120
184
 
121
185
  ```bash
data/deploio-cli.gemspec CHANGED
@@ -18,6 +18,13 @@ Gem::Specification.new do |spec|
18
18
  spec.metadata["source_code_uri"] = spec.homepage
19
19
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
20
 
21
+ spec.post_install_message = <<~MSG
22
+ To enable shell autocompletion for deploio, add this to your ~/.zshrc:
23
+
24
+ eval "$(deploio completion)"
25
+
26
+ MSG
27
+
21
28
  spec.files = Dir.chdir(__dir__) do
22
29
  `git ls-files -z`.split("\x0").reject do |f|
23
30
  (File.expand_path(f) == __FILE__) ||
data/lib/deploio/cli.rb CHANGED
@@ -3,7 +3,12 @@
3
3
  require "thor"
4
4
  require_relative "commands/auth"
5
5
  require_relative "commands/apps"
6
+ require_relative "commands/builds"
6
7
  require_relative "commands/orgs"
8
+ require_relative "commands/projects"
9
+ require_relative "commands/services"
10
+ require_relative "commands/postgresql_backups"
11
+ require_relative "commands/postgresql"
7
12
  require_relative "completion_generator"
8
13
 
9
14
  module Deploio
@@ -40,6 +45,18 @@ module Deploio
40
45
  desc "orgs COMMAND", "Organization management commands"
41
46
  subcommand "orgs", Commands::Orgs
42
47
 
48
+ desc "projects COMMAND", "Project management commands"
49
+ subcommand "projects", Commands::Projects
50
+
51
+ desc "services COMMAND", "Service management commands"
52
+ subcommand "services", Commands::Services
53
+
54
+ desc "builds COMMAND", "Build management commands"
55
+ subcommand "builds", Commands::Builds
56
+
57
+ desc "pg COMMAND", "PostgreSQL database management commands"
58
+ subcommand "pg", Commands::PostgreSQL
59
+
43
60
  # Shortcut for auth:login
44
61
  desc "login", "Authenticate with nctl (alias for auth:login)"
45
62
  def login
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../price_fetcher"
4
+
3
5
  module Deploio
4
6
  module Commands
5
7
  class Apps < Thor
@@ -11,10 +13,15 @@ module Deploio
11
13
 
12
14
  default_task :list
13
15
 
14
- desc "list", "List all apps"
16
+ desc "list", "List apps (all or filtered by project)"
17
+ method_option :project, aliases: "-p", type: :string,
18
+ desc: "Filter by project name"
19
+ method_option :chf, type: :boolean, default: false,
20
+ desc: "Show estimated price (CHF) for each app"
15
21
  def list
16
22
  setup_options
17
- raw_apps = @nctl.get_all_apps
23
+ project = merged_options[:project] ? resolve_project(merged_options[:project]) : nil
24
+ raw_apps = project ? @nctl.get_apps_by_project(project) : @nctl.get_all_apps
18
25
 
19
26
  if options[:json]
20
27
  puts JSON.pretty_generate(raw_apps)
@@ -22,11 +29,14 @@ module Deploio
22
29
  end
23
30
 
24
31
  if raw_apps.empty?
25
- Output.warning("No apps found") unless merged_options[:dry_run]
32
+ msg = project ? "No apps found in project #{merged_options[:project]}" : "No apps found"
33
+ Output.warning(msg) unless merged_options[:dry_run]
26
34
  return
27
35
  end
28
36
 
29
37
  resolver = AppResolver.new(nctl_client: @nctl)
38
+ show_price = merged_options[:chf]
39
+ price_fetcher = PriceFetcher.new if show_price
30
40
 
31
41
  rows = raw_apps.map do |app|
32
42
  metadata = app["metadata"] || {}
@@ -37,15 +47,24 @@ module Deploio
37
47
  namespace = metadata["namespace"] || ""
38
48
  name = metadata["name"] || ""
39
49
 
40
- [
50
+ row = [
41
51
  resolver.short_name_for(namespace, name),
42
52
  project_from_namespace(namespace, resolver.current_org),
43
53
  presence(config["size"], default: "micro"),
44
54
  presence(git["revision"])
45
55
  ]
56
+
57
+ if show_price
58
+ price = price_fetcher.price_for_app(app)
59
+ row << price_fetcher.format_price(price)
60
+ end
61
+
62
+ row
46
63
  end
47
64
 
48
- Output.table(rows, headers: %w[APP PROJECT SIZE REVISION])
65
+ headers = %w[APP PROJECT SIZE REVISION]
66
+ headers << "PRICE" if show_price
67
+ Output.table(rows, headers: headers)
49
68
  end
50
69
 
51
70
  desc "info", "Show app details"
@@ -93,10 +112,13 @@ module Deploio
93
112
  ready_condition = conditions.find { |c| c["type"] == "Ready" }
94
113
  synced_condition = conditions.find { |c| c["type"] == "Synced" }
95
114
 
115
+ default_url = at_provider["defaultURL"]
116
+ default_url_display = default_url ? Output.link(default_url) : "-"
117
+
96
118
  Output.table([
97
119
  ["Ready", presence(ready_condition&.dig("status"))],
98
120
  ["Synced", presence(synced_condition&.dig("status"))],
99
- ["Default URL", presence(at_provider["defaultURL"])],
121
+ ["Default URL", default_url_display],
100
122
  ["Latest Build", presence(at_provider["latestBuild"])],
101
123
  ["Latest Release", presence(at_provider["latestRelease"])]
102
124
  ])
@@ -106,7 +128,7 @@ module Deploio
106
128
  hosts = for_provider["hosts"] || []
107
129
  if hosts.any?
108
130
  Output.header("Hosts")
109
- Output.table(hosts.map { |h| [presence(h)] })
131
+ Output.list(hosts.map { |h| Output.link(h, "https://#{h}") })
110
132
  puts
111
133
  end
112
134
 
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Deploio
6
+ module Commands
7
+ class Builds < Thor
8
+ include SharedOptions
9
+
10
+ namespace "builds"
11
+
12
+ class_option :json, type: :boolean, default: false, desc: "Output as JSON"
13
+
14
+ desc "list", "List builds (all or for a specific app)"
15
+ def list
16
+ setup_options
17
+
18
+ if merged_options[:app]
19
+ list_for_app
20
+ else
21
+ list_all
22
+ end
23
+ end
24
+
25
+ desc "logs [BUILD_NAME]", "Show build logs"
26
+ method_option :tail, aliases: "-t", type: :boolean, default: false, desc: "Stream logs continuously"
27
+ method_option :lines, aliases: "-n", type: :numeric, default: 5000, desc: "Number of lines to show"
28
+ def logs(build_name = nil)
29
+ setup_options
30
+ app_ref = merged_options[:app] ? resolve_app : nil
31
+ @nctl.build_logs(build_name, app_ref: app_ref, tail: options[:tail], lines: options[:lines])
32
+ end
33
+
34
+ private
35
+
36
+ def list_all
37
+ raw_builds = @nctl.get_all_builds
38
+
39
+ if options[:json]
40
+ puts JSON.pretty_generate(raw_builds)
41
+ return
42
+ end
43
+
44
+ if raw_builds.empty?
45
+ Output.warning("No builds found") unless merged_options[:dry_run]
46
+ return
47
+ end
48
+
49
+ resolver = AppResolver.new(nctl_client: @nctl)
50
+
51
+ rows = raw_builds.map do |build|
52
+ metadata = build["metadata"] || {}
53
+ spec = build["spec"] || {}
54
+ status = build["status"] || {}
55
+ for_provider = spec["forProvider"] || {}
56
+ git = for_provider["sourceConfig"]&.dig("git") || {}
57
+ at_provider = status["atProvider"] || {}
58
+ labels = metadata["labels"] || {}
59
+ app_name = labels["application.apps.nine.ch/name"] || "-"
60
+ namespace = metadata["namespace"] || ""
61
+
62
+ [
63
+ resolver.short_name_for(namespace, app_name),
64
+ presence(metadata["name"]),
65
+ format_status(at_provider["buildStatus"]),
66
+ presence(git["revision"], max_length: 20),
67
+ format_timestamp(metadata["creationTimestamp"])
68
+ ]
69
+ end
70
+
71
+ Output.table(rows, headers: %w[APP BUILD STATUS REVISION CREATED])
72
+ end
73
+
74
+ def list_for_app
75
+ app_ref = resolve_app
76
+ raw_builds = @nctl.get_builds(app_ref)
77
+
78
+ if options[:json]
79
+ puts JSON.pretty_generate(raw_builds)
80
+ return
81
+ end
82
+
83
+ if raw_builds.empty?
84
+ Output.warning("No builds found for #{app_ref.full_name}") unless merged_options[:dry_run]
85
+ return
86
+ end
87
+
88
+ rows = raw_builds.map do |build|
89
+ metadata = build["metadata"] || {}
90
+ spec = build["spec"] || {}
91
+ status = build["status"] || {}
92
+ for_provider = spec["forProvider"] || {}
93
+ git = for_provider["sourceConfig"]&.dig("git") || {}
94
+ at_provider = status["atProvider"] || {}
95
+
96
+ [
97
+ presence(metadata["name"]),
98
+ format_status(at_provider["buildStatus"]),
99
+ presence(git["revision"], max_length: 30),
100
+ format_timestamp(metadata["creationTimestamp"])
101
+ ]
102
+ end
103
+
104
+ Output.table(rows, headers: %w[BUILD STATUS REVISION CREATED])
105
+ end
106
+
107
+ def presence(value, default: "-", max_length: nil)
108
+ return default if value.nil? || value.to_s.empty?
109
+
110
+ str = value.to_s
111
+ (max_length && str.length > max_length) ? "#{str[0, max_length - 3]}..." : str
112
+ end
113
+
114
+ def format_status(status)
115
+ return "-" if status.nil? || status.to_s.empty?
116
+
117
+ case status.downcase
118
+ when "succeeded", "success"
119
+ Output.color_enabled ? "\e[32m#{status}\e[0m" : status
120
+ when "error", "failed"
121
+ Output.color_enabled ? "\e[31m#{status}\e[0m" : status
122
+ when "building", "running", "pending"
123
+ Output.color_enabled ? "\e[33m#{status}\e[0m" : status
124
+ else
125
+ status
126
+ end
127
+ end
128
+
129
+ def format_timestamp(timestamp)
130
+ return "-" if timestamp.nil? || timestamp.to_s.empty?
131
+
132
+ time = Time.parse(timestamp)
133
+ time.strftime("%Y-%m-%d %H:%M")
134
+ rescue ArgumentError
135
+ timestamp
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,131 @@
1
+ module Deploio
2
+ module Commands
3
+ class PostgreSQL < Thor
4
+ include SharedOptions
5
+
6
+ namespace "pg"
7
+
8
+ class_option :json, type: :boolean, default: false, desc: "Output as JSON"
9
+
10
+ default_task :list
11
+
12
+ desc "list", "List all PostgreSQL databases"
13
+ def list
14
+ setup_options
15
+ raw_dbs = @nctl.get_all_pg_databases
16
+
17
+ if options[:json]
18
+ puts JSON.pretty_generate(raw_dbs)
19
+ return
20
+ end
21
+
22
+ if raw_dbs.empty?
23
+ Output.warning("No PostgreSQL databases found") unless merged_options[:dry_run]
24
+ return
25
+ end
26
+
27
+ resolver = PgDatabaseResolver.new(nctl_client: @nctl)
28
+
29
+ rows = raw_dbs.map do |pg|
30
+ kind = pg["kind"] || ""
31
+ metadata = pg["metadata"] || {}
32
+ spec = pg["spec"] || {}
33
+ for_provider = spec["forProvider"] || {}
34
+ version = for_provider["version"]
35
+ namespace = metadata["namespace"] || ""
36
+ name = metadata["name"] || ""
37
+
38
+ [
39
+ resolver.short_name_for(namespace, name),
40
+ project_from_namespace(namespace, resolver.current_org),
41
+ presence(kind, default: "-"),
42
+ presence(version, default: "?")
43
+ ]
44
+ end
45
+
46
+ Output.table(rows, headers: ["NAME", "PROJECT", "KIND", "VERSION"])
47
+ end
48
+
49
+ desc "info NAME", "Show PostgreSQL database details"
50
+ def info(name)
51
+ setup_options
52
+ resolver = PgDatabaseResolver.new(nctl_client: @nctl)
53
+ db_ref = resolver.resolve(database_name: name)
54
+ data = @nctl.get_pg_database(db_ref)
55
+
56
+ if options[:json]
57
+ puts JSON.pretty_generate(data)
58
+ return
59
+ end
60
+
61
+ kind = data["kind"]
62
+ metadata = data["metadata"] || {}
63
+ spec = data["spec"] || {}
64
+ for_provider = spec["forProvider"] || {}
65
+ status = data["status"] || {}
66
+ at_provider = status["atProvider"] || {}
67
+
68
+ Output.header("PostgreSQL Database: #{db_ref.full_name}")
69
+ puts
70
+
71
+ Output.header("General")
72
+ Output.table([
73
+ ["Name", presence(metadata["name"])],
74
+ ["Project", presence(metadata["namespace"])],
75
+ ["Kind", presence(kind, default: "-")],
76
+ ["Version", presence(for_provider["version"], default: "?")],
77
+ ["FQDN", presence(at_provider["fqdn"])],
78
+ ["Size", presence(at_provider["size"], default: "-")]
79
+ ])
80
+
81
+ puts
82
+
83
+ Output.header("Status")
84
+ conditions = status["conditions"] || []
85
+ ready_condition = conditions.find { |c| c["type"] == "Ready" }
86
+ synced_condition = conditions.find { |c| c["type"] == "Synced" }
87
+
88
+ Output.table([
89
+ ["Ready", presence(ready_condition&.dig("status"))],
90
+ ["Synced", presence(synced_condition&.dig("status"))]
91
+ ])
92
+
93
+ if for_provider["allowedCIDRs"].is_a?(Array)
94
+ puts
95
+
96
+ Output.header("Access")
97
+ Output.table([
98
+ ["Allowed CIDRs", for_provider["allowedCIDRs"].join(", ")]
99
+ ])
100
+
101
+ puts
102
+
103
+ Output.header("SSH Keys")
104
+ for_provider["sshKeys"].each do |key|
105
+ puts "- #{key}"
106
+ end
107
+ end
108
+ rescue Deploio::Error => e
109
+ Output.error(e.message)
110
+ exit 1
111
+ end
112
+
113
+ desc "backups COMMAND", "Manage PostgreSQL database backups"
114
+ subcommand "backups", Commands::PostgreSQLBackups
115
+
116
+ private
117
+
118
+ def presence(value, default: "-")
119
+ (value.nil? || value.to_s.empty?) ? default : value
120
+ end
121
+
122
+ def project_from_namespace(namespace, current_org)
123
+ if current_org && namespace.start_with?("#{current_org}-")
124
+ namespace.delete_prefix("#{current_org}-")
125
+ else
126
+ namespace
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,75 @@
1
+ module Deploio
2
+ module Commands
3
+ class PostgreSQLBackups < Thor
4
+ include SharedOptions
5
+
6
+ namespace "pg:backups"
7
+
8
+ desc "capture NAME", "Capture a new backup for the specified PostgreSQL database"
9
+ def capture(name)
10
+ setup_options
11
+ resolver = PgDatabaseResolver.new(nctl_client: @nctl)
12
+ db_ref = resolver.resolve(database_name: name)
13
+ data = @nctl.get_pg_database(db_ref)
14
+ kind = data["kind"] || ""
15
+
16
+ unless kind == "Postgres" || @nctl.dry_run
17
+ Output.error("Backups can only be captured for PostgreSQL databases. (shared dbs are not supported)")
18
+ exit 1
19
+ end
20
+
21
+ fqdn = data.dig("status", "atProvider", "fqdn")
22
+ if fqdn.nil? || fqdn.empty?
23
+ Output.error("Database FQDN not found; cannot capture backup.")
24
+ exit 1
25
+ end
26
+
27
+ cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
28
+ Output.command(cmd.join(" "))
29
+ system(*cmd) unless @nctl.dry_run
30
+ end
31
+
32
+ desc "download NAME [--output destination_path]", "Download the latest backup for the specified PostgreSQL database instance"
33
+ method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)"
34
+ method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil
35
+ def download(name)
36
+ destination = options[:output] || "./#{name}-latest-backup.zst"
37
+
38
+ setup_options
39
+ resolver = PgDatabaseResolver.new(nctl_client: @nctl)
40
+ db_ref = resolver.resolve(database_name: name)
41
+ data = @nctl.get_pg_database(db_ref)
42
+ kind = data["kind"] || ""
43
+
44
+ unless kind == "Postgres" || @nctl.dry_run
45
+ Output.error("Backups can only be downloaded for PostgreSQL databases. (shared dbs are not supported)")
46
+ exit 1
47
+ end
48
+
49
+ databases = data.dig("status", "atProvider", "databases")&.keys || []
50
+ databases.reject! { |db| db.strip.empty? }
51
+ if databases.empty?
52
+ Output.error("No databases found in PostgreSQL instance; cannot download backup.")
53
+ exit 1
54
+ elsif databases.size > 1 && options[:db_name].nil?
55
+ Output.error("Multiple databases found in PostgreSQL instance")
56
+ Output.error("Databases: #{databases.join(", ")}")
57
+ Output.error("Please specify the database name using the --db_name option.")
58
+ exit 1
59
+ end
60
+
61
+ db_name = options[:db_name] || databases.first
62
+
63
+ fqdn = data.dig("status", "atProvider", "fqdn")
64
+ if fqdn.nil? || fqdn.empty?
65
+ Output.error("Database FQDN not found; cannot download backup.")
66
+ exit 1
67
+ end
68
+
69
+ cmd = ["rsync", "-avz", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{db_name}/#{db_name}.zst", destination]
70
+ Output.command(cmd.join(" "))
71
+ system(*cmd) unless @nctl.dry_run
72
+ end
73
+ end
74
+ end
75
+ end