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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfbf5567b54c09796623b3347320fa36f2999d18269bf4c3e9d36cbcdd15ee59
4
- data.tar.gz: 81019ea594eded66c41bea3d5d9a696858f7f8f23023d160782e42e6b4028a01
3
+ metadata.gz: 1ef7a08d44a816dd88331bf7e007a536ef399b10afbf05229bef8e1e3137629e
4
+ data.tar.gz: d042f89dc44e64df49d599f1291f88151af45461132156af5c36486fa6408baa
5
5
  SHA512:
6
- metadata.gz: 89924672422ab905ca8143a1a4ec51302057ffdcf58c26be1272dca11572a02a6a9f54ab48af8428171587ad9eceeddcc6af933dc40b4f3d6ab004a09b2da4e8
7
- data.tar.gz: 977f56ebf1bca8df8f09a2759f0c67228b92f44243eacd4573035a5b34fb4e16abc3fcb90b2d18dee1576b744b191eedad23e3dcd58aecd73c9977ac90bdf71a
6
+ metadata.gz: 603238b92b6b23a32bf92f81a0a89a4cc0edbf8daad40519f1f165c095629ccb5f83775f01f0aae67541f6177f845ebc9797584fdcdb977425fa2307c2d8f98e
7
+ data.tar.gz: 2ac16607435cc562bcf3d3e94066002315bb06f198122fe6d088ee25782f9153106ab065e217f34a86d5aa99a4cb2d7d154876eabcc958266f2e74cc6771ef09
data/README.md CHANGED
@@ -6,6 +6,7 @@ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.co
6
6
 
7
7
  - Ruby 3.3+
8
8
  - nctl version 1.10.0 or higher
9
+ - [rclone](https://rclone.org/) (`brew install rclone`) — only needed for `deploio pg backups` on economy-tier databases
9
10
 
10
11
  ## Installation
11
12
 
@@ -18,7 +19,7 @@ bundle install
18
19
  bundle exec bin/deploio --help
19
20
  ```
20
21
 
21
- ### As a gem (coming soon)
22
+ ### As a gem
22
23
 
23
24
  ```bash
24
25
  gem install deploio-cli
@@ -71,8 +72,32 @@ AUTHENTICATION
71
72
 
72
73
  APPS
73
74
  deploio apps List all apps
75
+ deploio apps -p PROJECT List apps in a specific project
76
+ deploio apps --chf Show estimated monthly price (CHF) for each app
74
77
  deploio apps:info -a APP Show app details
75
78
 
79
+ PROJECTS
80
+ deploio projects List all projects
81
+ deploio projects --chf Show estimated total price (CHF) for each project
82
+
83
+ BUILDS
84
+ deploio builds List all builds
85
+ deploio builds -a APP List builds for a specific app
86
+
87
+ SERVICES
88
+ deploio services List all services
89
+ deploio services -p PROJECT List services in a specific project
90
+ deploio services -p PROJECT --url List services with connection URLs (requires -p)
91
+ deploio services -p PROJECT --connected-apps Show which apps use each service (requires -p)
92
+ deploio services --chf Show estimated monthly price (CHF) for each service
93
+
94
+ POSTGRESQL
95
+ deploio pg List all PostgreSQL databases
96
+ deploio pg:info NAME Show database details
97
+ deploio pg backups list NAME List available backups
98
+ deploio pg backups download NAME Download the latest backup
99
+ deploio pg backups capture NAME Capture a new backup
100
+
76
101
  LOGS
77
102
  deploio logs -a APP Show recent logs
78
103
  deploio logs -a APP --tail Stream logs continuously
@@ -111,11 +136,58 @@ deploio auth:whoami
111
136
  # List all apps
112
137
  deploio apps
113
138
 
139
+ # List apps in a specific project
140
+ deploio apps -p myproject
141
+
142
+ # List apps with estimated monthly prices (CHF)
143
+ deploio apps --chf
144
+
114
145
  # Show app info
115
146
  deploio apps:info -a myproject-staging
147
+ ```
148
+
149
+ ### Working with projects
150
+
151
+ ```bash
152
+ # List all projects
153
+ deploio projects
154
+
155
+ # List projects with estimated total prices (apps + services)
156
+ deploio projects --chf
157
+
158
+ # Output as JSON
159
+ deploio projects --json
160
+ ```
161
+
162
+ ```
163
+
164
+ ### Working with services
165
+
166
+ ```bash
167
+ # List all services
168
+ deploio services
116
169
 
170
+ # List services in a specific project
171
+ deploio services -p myproject
172
+
173
+ # List services with estimated monthly prices (CHF)
174
+ deploio services --chf
175
+
176
+ # List services with connection URLs (requires --project)
177
+ deploio services -p myproject --url
178
+
179
+ # Show which apps are connected to each service (requires --project)
180
+ deploio services -p myproject --connected-apps
181
+
182
+ # Combine options
183
+ deploio services -p myproject --url --connected-apps --chf
184
+
185
+ # Output as JSON
186
+ deploio services --json
117
187
  ```
118
188
 
189
+ Note: Prices (--chf) are fetched from the Nine calculator API and cached locally in `~/.deploio/prices.json` for 24 hours.
190
+
119
191
  ### Logs and execution
120
192
 
121
193
  ```bash
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
@@ -83,6 +100,8 @@ module Deploio
83
100
 
84
101
  private
85
102
 
103
+ # The aliases above hand off with Thor.start, which starts from a fresh
104
+ # option parse, so the shared class options have to be passed as arguments.
86
105
  def build_option_args
87
106
  args = []
88
107
  args << "--dry-run" if options[:dry_run]
@@ -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,96 @@
1
+ require "time"
2
+
3
+ module Deploio
4
+ module Commands
5
+ class PostgreSQLBackups < Thor
6
+ include SharedOptions
7
+
8
+ namespace "pg:backups"
9
+
10
+ DEDICATED_KIND = "Postgres"
11
+ ECONOMY_KIND = "PostgresDatabase"
12
+
13
+ desc "capture NAME", "Capture a new backup for the specified PostgreSQL database"
14
+ def capture(name)
15
+ backup_service_for(name).capture
16
+ rescue Deploio::Error => e
17
+ Output.error(e.message)
18
+ exit 1
19
+ end
20
+
21
+ desc "list NAME", "List the available backups for the specified PostgreSQL database"
22
+ def list(name)
23
+ backups = backup_service_for(name).backups
24
+ if backups.empty?
25
+ Output.warning("No backups found for '#{name}'")
26
+ return
27
+ end
28
+
29
+ rows = backups.map do |backup|
30
+ [format_time(backup["ModTime"]), format_size(backup["Size"]), backup["Name"]]
31
+ end
32
+ Output.table(rows, headers: ["DATE", "SIZE", "NAME"])
33
+ rescue Deploio::Error => e
34
+ Output.error(e.message)
35
+ exit 1
36
+ end
37
+
38
+ desc "download NAME [--output destination_path]", "Download the latest backup for the specified PostgreSQL database instance"
39
+ method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)"
40
+ method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil
41
+ def download(name)
42
+ service = backup_service_for(name)
43
+ destination = merged_options[:output] || service.default_destination
44
+ backup = service.download(destination: destination, db_name: merged_options[:db_name])
45
+
46
+ # Only the economy tier knows when its backup was taken.
47
+ if backup
48
+ Output.success("Downloaded backup from #{format_time(backup["ModTime"])} to #{destination}")
49
+ else
50
+ Output.success("Downloaded backup to #{destination}")
51
+ end
52
+ rescue Deploio::Error => e
53
+ Output.error(e.message)
54
+ exit 1
55
+ end
56
+
57
+ private
58
+
59
+ def backup_service_for(name)
60
+ setup_options
61
+ resolver = PgDatabaseResolver.new(nctl_client: @nctl)
62
+ db_ref = resolver.resolve(database_name: name)
63
+ data = @nctl.get_pg_database(db_ref)
64
+ raise Deploio::Error, "Could not read database '#{db_ref.full_name}'." if data.nil?
65
+
66
+ case data["kind"]
67
+ when DEDICATED_KIND
68
+ PostgresBackupService.new(data: data, name: name, dry_run: @nctl.dry_run)
69
+ when ECONOMY_KIND
70
+ PostgresDatabaseBackupService.new(db_ref: db_ref, data: data, nctl_client: @nctl, name: name)
71
+ else
72
+ raise Deploio::UnsupportedBackupOperationError,
73
+ "Backups are not supported for databases of kind '#{data["kind"]}'."
74
+ end
75
+ end
76
+
77
+ def format_time(value)
78
+ Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M")
79
+ rescue ArgumentError, TypeError
80
+ value.to_s
81
+ end
82
+
83
+ def format_size(bytes)
84
+ bytes = bytes.to_i
85
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
86
+ index = 0
87
+ size = bytes.to_f
88
+ while size >= 1024 && index < units.size - 1
89
+ size /= 1024
90
+ index += 1
91
+ end
92
+ (index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index]))
93
+ end
94
+ end
95
+ end
96
+ end