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 +4 -4
- data/README.md +73 -1
- data/lib/deploio/cli.rb +19 -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 +96 -0
- data/lib/deploio/commands/projects.rb +185 -0
- data/lib/deploio/commands/services.rb +186 -0
- data/lib/deploio/completion_generator.rb +22 -3
- data/lib/deploio/nctl_client.rb +205 -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/postgres_backup_service.rb +68 -0
- data/lib/deploio/postgres_database_backup_service.rb +116 -0
- data/lib/deploio/price_fetcher.rb +201 -0
- data/lib/deploio/rclone_client.rb +99 -0
- data/lib/deploio/shared_options.rb +18 -2
- data/lib/deploio/templates/completion.zsh.erb +134 -1
- data/lib/deploio/version.rb +1 -1
- data/lib/deploio.rb +9 -0
- metadata +13 -2
data/lib/deploio/output.rb
CHANGED
|
@@ -42,6 +42,15 @@ module Deploio
|
|
|
42
42
|
puts pastel.magenta.bold(text)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Create a clickable hyperlink using OSC 8 escape sequences
|
|
46
|
+
# Supported by most modern terminal emulators (iTerm2, GNOME Terminal, Windows Terminal, etc.)
|
|
47
|
+
def link(text, url = nil)
|
|
48
|
+
return text unless color_enabled
|
|
49
|
+
|
|
50
|
+
url ||= text.start_with?("http") ? text : "https://#{text}"
|
|
51
|
+
"\e]8;;#{url}\e\\#{text}\e]8;;\e\\"
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
def table(rows, headers: nil)
|
|
46
55
|
return if rows.empty?
|
|
47
56
|
|
|
@@ -49,6 +58,37 @@ module Deploio
|
|
|
49
58
|
puts tty_table.render(:unicode, padding: [0, 2, 0, 1], width: 10_000)
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
def list(items)
|
|
62
|
+
items.each { |item| puts " • #{item}" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def grouped_table(groups, headers: nil)
|
|
66
|
+
return if groups.empty?
|
|
67
|
+
|
|
68
|
+
all_rows = groups.values.flatten(1)
|
|
69
|
+
return if all_rows.empty?
|
|
70
|
+
|
|
71
|
+
# Track which row indices should have separators after them (0-indexed)
|
|
72
|
+
# The separator lambda receives the row index (0 = first data row)
|
|
73
|
+
separator_after = []
|
|
74
|
+
current_idx = 0
|
|
75
|
+
groups.each_with_index do |(_, group_rows), group_idx|
|
|
76
|
+
current_idx += group_rows.size
|
|
77
|
+
# Add separator after last row of each group (except the last group)
|
|
78
|
+
separator_after << (current_idx - 1) if group_idx < groups.size - 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
rows = groups.values.flatten(1)
|
|
82
|
+
tty_table = headers ? TTY::Table.new(header: headers, rows: rows) : TTY::Table.new(rows: rows)
|
|
83
|
+
|
|
84
|
+
output = tty_table.render(:unicode, padding: [0, 2, 0, 1], width: 10_000) do |renderer|
|
|
85
|
+
# row_idx 0 = separator after header, then data row indices
|
|
86
|
+
renderer.border.separator = ->(row_idx) { row_idx == 0 || separator_after.include?(row_idx - 1) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
puts output
|
|
90
|
+
end
|
|
91
|
+
|
|
52
92
|
private
|
|
53
93
|
|
|
54
94
|
def pastel
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
class PgDatabaseRef
|
|
7
|
+
attr_reader :project_name, :database_name
|
|
8
|
+
|
|
9
|
+
# The input is given in the format "<project>-<database>"
|
|
10
|
+
def initialize(input, available_databases: {})
|
|
11
|
+
@input = input.to_s
|
|
12
|
+
parse_from_available_databases(available_databases)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def full_name
|
|
16
|
+
"#{project_name}-#{database_name}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
full_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
return false unless other.is_a?(PgDatabaseRef)
|
|
25
|
+
|
|
26
|
+
project_name == other.project_name && database_name == other.database_name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def parse_from_available_databases(available_databases)
|
|
32
|
+
if available_databases.key?(@input)
|
|
33
|
+
match = available_databases[@input]
|
|
34
|
+
@project_name = match[:project_name]
|
|
35
|
+
@database_name = match[:database_name]
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# If available_databases provided but no match, raise error with suggestions
|
|
40
|
+
raise_not_found_error(@input, available_databases.keys) unless available_databases.empty?
|
|
41
|
+
|
|
42
|
+
raise_not_found_error(@input, [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def raise_not_found_error(input, available_database_names)
|
|
46
|
+
message = "Database not found: '#{input}'"
|
|
47
|
+
|
|
48
|
+
suggestions = suggest_similar(input, available_database_names)
|
|
49
|
+
unless suggestions.empty?
|
|
50
|
+
message += "\n\nDid you mean?"
|
|
51
|
+
suggestions.each { |s| message += "\n #{s}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
message += "\n\nRun 'deploio pg' to see available Postgres databases."
|
|
55
|
+
|
|
56
|
+
raise Deploio::PgDatabaseNotFoundError, message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def suggest_similar(input, dictionary)
|
|
60
|
+
return [] if dictionary.empty?
|
|
61
|
+
|
|
62
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
|
|
63
|
+
spell_checker.correct(input)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class PgDatabaseResolver
|
|
5
|
+
attr_reader :nctl, :current_org
|
|
6
|
+
|
|
7
|
+
def initialize(nctl_client:)
|
|
8
|
+
@nctl = nctl_client
|
|
9
|
+
@current_org = @nctl.current_org
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(database_name: nil)
|
|
13
|
+
if database_name
|
|
14
|
+
return PgDatabaseRef.new(database_name, available_databases: available_databases_hash)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
raise Deploio::Error, "No database specified"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns hash mapping database names -> {project_name:, app_name:}
|
|
21
|
+
def available_databases_hash
|
|
22
|
+
@available_apps_hash ||= begin
|
|
23
|
+
hash = {}
|
|
24
|
+
current_org = @nctl.current_org
|
|
25
|
+
@nctl.get_all_pg_databases.each do |database|
|
|
26
|
+
metadata = database["metadata"] || {}
|
|
27
|
+
project_name = metadata["namespace"] || ""
|
|
28
|
+
database_name = metadata["name"]
|
|
29
|
+
full_name = "#{project_name}-#{database_name}"
|
|
30
|
+
hash[full_name] = {project_name: project_name, database_name: database_name}
|
|
31
|
+
|
|
32
|
+
# Also index by short name (without org prefix) for convenience
|
|
33
|
+
if current_org && project_name.start_with?("#{current_org}-")
|
|
34
|
+
project = project_name.delete_prefix("#{current_org}-")
|
|
35
|
+
short_name = "#{project}-#{database_name}"
|
|
36
|
+
hash[short_name] ||= {project_name: project_name, database_name: database_name}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
hash
|
|
40
|
+
end
|
|
41
|
+
rescue
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def short_name_for(namespace, database_name)
|
|
46
|
+
org = current_org
|
|
47
|
+
if org && namespace.start_with?("#{org}-")
|
|
48
|
+
project = namespace.delete_prefix("#{org}-")
|
|
49
|
+
"#{project}-#{database_name}"
|
|
50
|
+
else
|
|
51
|
+
"#{namespace}-#{database_name}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
# Backups for the dedicated tier (kind: Postgres), where we own the whole
|
|
5
|
+
# database server and reach it over SSH
|
|
6
|
+
# The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
|
|
7
|
+
# consistent with that
|
|
8
|
+
class PostgresBackupService
|
|
9
|
+
DEFAULT_EXTENSION = ".zst"
|
|
10
|
+
|
|
11
|
+
# @param name [String] the name the user typed, used for hints in messages
|
|
12
|
+
def initialize(data:, name: nil, dry_run: false)
|
|
13
|
+
@data = data || {}
|
|
14
|
+
@name = name
|
|
15
|
+
@dry_run = dry_run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def default_destination = "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
19
|
+
|
|
20
|
+
def backups
|
|
21
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
22
|
+
"Listing backups is not yet supported for dedicated PostgreSQL instances; Feel free to implement it!\n" \
|
|
23
|
+
"Use 'deploio pg backups download #{@name}' to fetch it."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture
|
|
27
|
+
cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
|
|
28
|
+
Output.command(cmd.join(" "))
|
|
29
|
+
system(*cmd) unless @dry_run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def download(destination:, db_name: nil)
|
|
33
|
+
name = resolve_db_name(db_name)
|
|
34
|
+
|
|
35
|
+
cmd = ["rsync", "-av", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{name}/#{name}.zst", destination]
|
|
36
|
+
Output.command(cmd.join(" "))
|
|
37
|
+
system(*cmd) unless @dry_run
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def resolve_db_name(db_name)
|
|
45
|
+
if databases.empty?
|
|
46
|
+
raise Deploio::Error, "No databases found in PostgreSQL instance; cannot download backup."
|
|
47
|
+
elsif databases.size > 1 && db_name.nil?
|
|
48
|
+
raise Deploio::Error,
|
|
49
|
+
"Multiple databases found in PostgreSQL instance\n" \
|
|
50
|
+
"Databases: #{databases.join(", ")}\n" \
|
|
51
|
+
"Please specify the database name using the --db_name option."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
db_name || databases.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def databases
|
|
58
|
+
@databases ||= (@data.dig("status", "atProvider", "databases")&.keys || []).reject { |db| db.strip.empty? }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fqdn
|
|
62
|
+
value = @data.dig("status", "atProvider", "fqdn")
|
|
63
|
+
raise Deploio::Error, "Database FQDN not found; cannot reach the database server." if value.nil? || value.empty?
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
# Backups for the economy tier (kind: PostgresDatabase), where the database
|
|
5
|
+
# lives on a shared server we have no access to.
|
|
6
|
+
# The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
|
|
7
|
+
# consistent with that
|
|
8
|
+
class PostgresDatabaseBackupService
|
|
9
|
+
DEFAULT_EXTENSION = ".sql.zst"
|
|
10
|
+
|
|
11
|
+
BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
|
|
12
|
+
|
|
13
|
+
def initialize(db_ref:, data:, nctl_client:, name: nil, rclone_client_factory: nil)
|
|
14
|
+
@db_ref = db_ref
|
|
15
|
+
@data = data || {}
|
|
16
|
+
@nctl = nctl_client
|
|
17
|
+
@name = name || db_ref.full_name
|
|
18
|
+
@rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def default_destination
|
|
22
|
+
"./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Nine takes these backups on a schedule
|
|
26
|
+
# (you can even see them using `kubectl get databasebackupschedules.storage.nine.ch -n renuo-chess-tracker -o json`)
|
|
27
|
+
# => there is no way to trigger one.
|
|
28
|
+
def capture
|
|
29
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
30
|
+
"'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
|
|
31
|
+
"on their configured schedule and cannot be captured manually.\n" \
|
|
32
|
+
"Use 'deploio pg backups list #{@name}' to see the available backups."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def backups
|
|
36
|
+
@backups ||= begin
|
|
37
|
+
entries = rclone.list(bucket_name)
|
|
38
|
+
entries = entries.select { |e| own_backup?(e["Name"].to_s) }
|
|
39
|
+
entries.sort_by { |e| e["ModTime"].to_s }.reverse
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def download(destination:, db_name: nil)
|
|
44
|
+
backup = backups.first
|
|
45
|
+
unless backup
|
|
46
|
+
raise Deploio::Error, "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rclone.download(bucket_name, backup["Name"], destination)
|
|
50
|
+
backup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def own_backup?(object_name)
|
|
56
|
+
return true if instance_name.empty?
|
|
57
|
+
|
|
58
|
+
object_name.start_with?("PostgresDatabase-#{instance_name}-")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def instance_name
|
|
62
|
+
@instance_name ||= @data.dig("status", "atProvider", "name").to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bucket_name
|
|
66
|
+
bucket.dig("metadata", "name")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The PostgresDatabase resource holds no reference to its backup bucket
|
|
70
|
+
# Changes with service connections (I assume), but we're not there yet as we still have projects with the old setup.
|
|
71
|
+
# Therefore, we match by name
|
|
72
|
+
def bucket
|
|
73
|
+
@bucket ||= begin
|
|
74
|
+
candidates = @nctl.get_services_by_type("bucket", project: @db_ref.project_name).select do |bucket|
|
|
75
|
+
backup_bucket_for_database?(bucket)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if candidates.empty?
|
|
79
|
+
raise Deploio::Error,
|
|
80
|
+
"No backup bucket found for '#{@db_ref.full_name}'. " \
|
|
81
|
+
"Check that backups are enabled for this database (spec.forProvider.backupSchedule)."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
candidates.first
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def backup_bucket_for_database?(bucket)
|
|
89
|
+
metadata = bucket["metadata"] || {}
|
|
90
|
+
labels = metadata["labels"] || {}
|
|
91
|
+
return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
|
|
92
|
+
|
|
93
|
+
metadata["name"].to_s.match?(/\Apostgresdatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rclone
|
|
97
|
+
@rclone ||= @rclone_client_factory.call
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_rclone_client
|
|
101
|
+
endpoint = bucket.dig("status", "atProvider", "endpoint")
|
|
102
|
+
if endpoint.nil? || endpoint.to_s.empty?
|
|
103
|
+
raise Deploio::Error, "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
client = RcloneClient.new(
|
|
107
|
+
endpoint: "https://#{endpoint}",
|
|
108
|
+
access_key: @nctl.get_bucket_user_access_key(bucket_name, project: @db_ref.project_name),
|
|
109
|
+
secret_key: @nctl.get_bucket_user_secret_key(bucket_name, project: @db_ref.project_name),
|
|
110
|
+
dry_run: @nctl.dry_run
|
|
111
|
+
)
|
|
112
|
+
client.check_requirements unless @nctl.dry_run
|
|
113
|
+
client
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Deploio
|
|
9
|
+
class PriceFetcher
|
|
10
|
+
CACHE_DIR = File.expand_path("~/.deploio")
|
|
11
|
+
CACHE_FILE = File.join(CACHE_DIR, "prices.json")
|
|
12
|
+
CACHE_TTL = 24 * 60 * 60 # 24 hours in seconds
|
|
13
|
+
API_URL = "https://calculator-api-production.2deb129.deploio.app/product"
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@prices = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch
|
|
20
|
+
@prices ||= load_cached_prices || fetch_and_cache_prices
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def price_for_service(type, spec)
|
|
24
|
+
fetch
|
|
25
|
+
return nil unless @prices
|
|
26
|
+
|
|
27
|
+
case type
|
|
28
|
+
when "postgres", "mysql"
|
|
29
|
+
price_for_database(type, spec)
|
|
30
|
+
when "postgresdatabases", "mysqldatabases"
|
|
31
|
+
price_for_single_database
|
|
32
|
+
when "keyvaluestore"
|
|
33
|
+
price_for_keyvaluestore(spec)
|
|
34
|
+
when "opensearch"
|
|
35
|
+
price_for_opensearch
|
|
36
|
+
when "bucket"
|
|
37
|
+
price_for_bucket
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def price_for_app(app_data)
|
|
42
|
+
fetch
|
|
43
|
+
return nil unless @prices
|
|
44
|
+
|
|
45
|
+
spec = app_data["spec"] || app_data
|
|
46
|
+
status = app_data["status"] || {}
|
|
47
|
+
for_provider = spec["forProvider"] || {}
|
|
48
|
+
at_provider = status["atProvider"] || {}
|
|
49
|
+
config = for_provider["config"] || {}
|
|
50
|
+
|
|
51
|
+
size = (config["size"] || "micro").downcase
|
|
52
|
+
# Use status replicas (actual running) if available, otherwise spec replicas, default to 1
|
|
53
|
+
replicas = at_provider["replicas"] || for_provider["replicas"] || 1
|
|
54
|
+
replicas = replicas.to_i
|
|
55
|
+
|
|
56
|
+
size_price = @prices.dig("app", size)
|
|
57
|
+
return nil if size_price.nil?
|
|
58
|
+
|
|
59
|
+
size_price * replicas
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def format_price(price)
|
|
63
|
+
return "-" if price.nil?
|
|
64
|
+
|
|
65
|
+
"CHF #{price}/mo"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_cached_prices
|
|
71
|
+
return nil unless File.exist?(CACHE_FILE)
|
|
72
|
+
|
|
73
|
+
cache_data = JSON.parse(File.read(CACHE_FILE))
|
|
74
|
+
cached_at = cache_data["cached_at"]
|
|
75
|
+
return nil if cached_at.nil? || Time.now.to_i - cached_at > CACHE_TTL
|
|
76
|
+
|
|
77
|
+
cache_data["prices"]
|
|
78
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch_and_cache_prices
|
|
83
|
+
prices = fetch_prices_from_api
|
|
84
|
+
return nil if prices.nil?
|
|
85
|
+
|
|
86
|
+
cache_prices(prices)
|
|
87
|
+
prices
|
|
88
|
+
rescue
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fetch_prices_from_api
|
|
93
|
+
uri = URI.parse(API_URL)
|
|
94
|
+
response = Net::HTTP.get_response(uri)
|
|
95
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
products = JSON.parse(response.body)
|
|
98
|
+
build_price_map(products)
|
|
99
|
+
rescue JSON::ParserError, Net::OpenTimeout, Net::ReadTimeout, SocketError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_price_map(products)
|
|
104
|
+
prices = {
|
|
105
|
+
"postgres" => {},
|
|
106
|
+
"mysql" => {},
|
|
107
|
+
"keyvaluestore" => {"base" => 15},
|
|
108
|
+
"opensearch" => {"base" => 60},
|
|
109
|
+
"single_database" => {"base" => 5},
|
|
110
|
+
"bucket" => {"base" => 0},
|
|
111
|
+
"app" => {"micro" => 8, "mini" => 16, "standard-1" => 32, "standard-2" => 58},
|
|
112
|
+
"ram_per_gib" => 5
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
products.each do |product|
|
|
116
|
+
name = product["name"]
|
|
117
|
+
list_price = product["list_price"]
|
|
118
|
+
categ_path = (product["categ_path"] || []).join("/")
|
|
119
|
+
|
|
120
|
+
case name
|
|
121
|
+
when /^PostgreSQL - (nine-(?:db|single-db)-\S+)/
|
|
122
|
+
machine_type = normalize_machine_type($1)
|
|
123
|
+
prices["postgres"][machine_type] = list_price
|
|
124
|
+
when /^MySQL - (nine-(?:db|single-db)-\S+)/
|
|
125
|
+
machine_type = normalize_machine_type($1)
|
|
126
|
+
prices["mysql"][machine_type] = list_price
|
|
127
|
+
when "Managed Service: Key-Value Store (Redis compatible)"
|
|
128
|
+
prices["keyvaluestore"]["base"] = list_price
|
|
129
|
+
when "Managed Service: OpenSearch (Elasticsearch compatible)"
|
|
130
|
+
prices["opensearch"]["base"] = list_price
|
|
131
|
+
when "Micro", "Mini", "Standard-1", "Standard-2"
|
|
132
|
+
# Only use deplo.io app sizes
|
|
133
|
+
prices["app"][name.downcase] = list_price if categ_path.include?("deplo")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
prices
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_machine_type(raw)
|
|
141
|
+
# "nine-single-db-l - 10GB" -> "nine-single-db-l"
|
|
142
|
+
raw.split(" ").first
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cache_prices(prices)
|
|
146
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
147
|
+
cache_data = {
|
|
148
|
+
"cached_at" => Time.now.to_i,
|
|
149
|
+
"prices" => prices
|
|
150
|
+
}
|
|
151
|
+
File.write(CACHE_FILE, JSON.pretty_generate(cache_data))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def price_for_database(type, spec)
|
|
155
|
+
for_provider = spec.dig("forProvider") || {}
|
|
156
|
+
machine_type = for_provider["machineType"] || for_provider["singleDBMachineType"]
|
|
157
|
+
return nil if machine_type.nil?
|
|
158
|
+
|
|
159
|
+
@prices.dig(type, machine_type)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def price_for_keyvaluestore(spec)
|
|
163
|
+
base_price = @prices.dig("keyvaluestore", "base") || 15
|
|
164
|
+
ram_price = @prices["ram_per_gib"] || 5
|
|
165
|
+
|
|
166
|
+
memory_size = spec.dig("forProvider", "memorySize")
|
|
167
|
+
return base_price if memory_size.nil?
|
|
168
|
+
|
|
169
|
+
# Parse memory size (e.g., "256Mi", "1Gi", "512Mi")
|
|
170
|
+
gib = parse_memory_to_gib(memory_size)
|
|
171
|
+
(base_price + (gib * ram_price)).round
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def price_for_opensearch
|
|
175
|
+
@prices.dig("opensearch", "base") || 60
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def price_for_single_database
|
|
179
|
+
# Single databases on shared instances - base price for smallest tier
|
|
180
|
+
@prices.dig("single_database", "base") || 5
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def price_for_bucket
|
|
184
|
+
# Buckets are usage-based, show base/minimum price
|
|
185
|
+
@prices.dig("bucket", "base") || 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_memory_to_gib(memory_str)
|
|
189
|
+
case memory_str
|
|
190
|
+
when /^(\d+(?:\.\d+)?)Gi$/
|
|
191
|
+
$1.to_f
|
|
192
|
+
when /^(\d+(?:\.\d+)?)Mi$/
|
|
193
|
+
$1.to_f / 1024
|
|
194
|
+
when /^(\d+(?:\.\d+)?)Ki$/
|
|
195
|
+
$1.to_f / (1024 * 1024)
|
|
196
|
+
else
|
|
197
|
+
0
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Deploio
|
|
7
|
+
class RcloneClient
|
|
8
|
+
REMOTE = "DEPLOIO"
|
|
9
|
+
|
|
10
|
+
attr_reader :dry_run
|
|
11
|
+
|
|
12
|
+
def initialize(endpoint:, access_key:, secret_key:, dry_run: false)
|
|
13
|
+
@endpoint = endpoint
|
|
14
|
+
@access_key = access_key
|
|
15
|
+
@secret_key = secret_key
|
|
16
|
+
@dry_run = dry_run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check_requirements
|
|
20
|
+
check_rclone_installed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list(bucket)
|
|
24
|
+
output = capture("lsjson", remote_path(bucket))
|
|
25
|
+
return [] if output.nil? || output.empty?
|
|
26
|
+
|
|
27
|
+
data = JSON.parse(output)
|
|
28
|
+
data.is_a?(Array) ? data : []
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def download(bucket, object, destination)
|
|
34
|
+
run("copyto", remote_path(bucket, object), destination, "--progress", "--stats-one-line")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# --s3-no-check-bucket skips the HeadBucket call, which the read-only bucket
|
|
40
|
+
# user is not permitted to make.
|
|
41
|
+
# See also https://docs.nine.ch/docs/object-storage/object-storage-client-tools#rclone
|
|
42
|
+
def build_command(args)
|
|
43
|
+
["rclone", *args.map(&:to_s), "--s3-no-check-bucket"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remote_path(bucket, object = nil)
|
|
47
|
+
object ? "#{REMOTE}:#{bucket}/#{object}" : "#{REMOTE}:#{bucket}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def env
|
|
51
|
+
{
|
|
52
|
+
"RCLONE_CONFIG_#{REMOTE}_TYPE" => "s3",
|
|
53
|
+
"RCLONE_CONFIG_#{REMOTE}_PROVIDER" => "Other",
|
|
54
|
+
"RCLONE_CONFIG_#{REMOTE}_ENDPOINT" => @endpoint,
|
|
55
|
+
"RCLONE_CONFIG_#{REMOTE}_ACCESS_KEY_ID" => @access_key,
|
|
56
|
+
"RCLONE_CONFIG_#{REMOTE}_SECRET_ACCESS_KEY" => @secret_key
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def capture(*args)
|
|
61
|
+
cmd = build_command(args)
|
|
62
|
+
if dry_run
|
|
63
|
+
Output.command(cmd.join(" "))
|
|
64
|
+
return ""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
|
|
68
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
69
|
+
unless status.success?
|
|
70
|
+
raise Deploio::RcloneError, "rclone command failed: #{stderr}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
stdout
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run(*args)
|
|
77
|
+
cmd = build_command(args)
|
|
78
|
+
Output.command(cmd.join(" "))
|
|
79
|
+
return true if dry_run
|
|
80
|
+
|
|
81
|
+
unless system(env, *cmd)
|
|
82
|
+
raise Deploio::RcloneError, "rclone command failed: #{cmd.join(" ")}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_rclone_installed
|
|
89
|
+
_stdout, _stderr, status = Open3.capture3("rclone", "version")
|
|
90
|
+
return if status.success?
|
|
91
|
+
|
|
92
|
+
raise Deploio::RcloneError,
|
|
93
|
+
"rclone not found. Please install it: brew install rclone"
|
|
94
|
+
rescue Errno::ENOENT
|
|
95
|
+
raise Deploio::RcloneError,
|
|
96
|
+
"rclone not found. Please install it: brew install rclone"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -7,8 +7,8 @@ module Deploio
|
|
|
7
7
|
def self.included(base)
|
|
8
8
|
base.class_option :app, aliases: "-a", type: :string, desc: "App in <project>-<app> format"
|
|
9
9
|
base.class_option :org, aliases: "-o", type: :string, desc: "Organization"
|
|
10
|
-
base.class_option :dry_run, type: :boolean,
|
|
11
|
-
base.class_option :no_color, type: :boolean,
|
|
10
|
+
base.class_option :dry_run, type: :boolean, desc: "Print commands without executing"
|
|
11
|
+
base.class_option :no_color, type: :boolean, desc: "Disable colored output"
|
|
12
12
|
|
|
13
13
|
base.define_singleton_method(:exit_on_failure?) { true }
|
|
14
14
|
end
|
|
@@ -30,6 +30,7 @@ module Deploio
|
|
|
30
30
|
@nctl.check_requirements unless merged_options[:dry_run]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# @return [Deploio::AppRef]
|
|
33
34
|
def resolve_app
|
|
34
35
|
resolver = AppResolver.new(nctl_client: @nctl)
|
|
35
36
|
resolver.resolve(app_name: merged_options[:app])
|
|
@@ -37,5 +38,20 @@ module Deploio
|
|
|
37
38
|
Output.error(e.message)
|
|
38
39
|
exit 1
|
|
39
40
|
end
|
|
41
|
+
|
|
42
|
+
# Resolves a project name to its fully qualified form (org-project).
|
|
43
|
+
# Users can type short names like "myproject" and this will prepend the org.
|
|
44
|
+
# @param project [String] Project name (short or fully qualified)
|
|
45
|
+
# @return [String] Fully qualified project name
|
|
46
|
+
def resolve_project(project)
|
|
47
|
+
current_org = @nctl.current_org
|
|
48
|
+
return project unless current_org
|
|
49
|
+
|
|
50
|
+
# Special case: project equals org name (default project)
|
|
51
|
+
return project if project == current_org
|
|
52
|
+
|
|
53
|
+
# Always prepend org to get fully qualified name
|
|
54
|
+
"#{current_org}-#{project}"
|
|
55
|
+
end
|
|
40
56
|
end
|
|
41
57
|
end
|