deploio-cli 0.3.0 → 0.4.1
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/CHANGELOG.md +12 -0
- data/README.md +29 -18
- data/Rakefile +2 -0
- data/deploio-cli.gemspec +5 -2
- data/lib/deploio/bash_completion_generator.rb +97 -0
- data/lib/deploio/cli.rb +12 -3
- data/lib/deploio/commands/mysql.rb +139 -0
- data/lib/deploio/commands/mysql_backups.rb +77 -0
- data/lib/deploio/commands/postgresql_backups.rb +1 -20
- data/lib/deploio/completion_generator.rb +1 -1
- data/lib/deploio/helpers/backups_helper.rb +25 -0
- data/lib/deploio/mysql_backup_service.rb +79 -0
- data/lib/deploio/mysql_database_backup_service.rb +129 -0
- data/lib/deploio/mysql_database_ref.rb +40 -0
- data/lib/deploio/mysql_database_resolver.rb +71 -0
- data/lib/deploio/nctl_client.rb +36 -0
- data/lib/deploio/templates/completion.bash.erb +140 -0
- data/lib/deploio/version.rb +1 -1
- data/lib/deploio.rb +5 -0
- metadata +16 -4
- data/setup +0 -115
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5dcf01de3150cb22e539daf36c3b166063427b1545c2bdf65bac575fea8b463c
|
|
4
|
+
data.tar.gz: 505202b6194b9248f039178c45b23c54089cb1887fb38dbfef37dafc6ad2b43e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8fc759d2107cf92663ff45bc05c8bc6ccdd15f038a4d34e35941324571d6264e69fedd7ae7e179cedabb3029232982f4afd7a8b887af7e20d8bb712d581060c
|
|
7
|
+
data.tar.gz: 92916faa78a8645f47738bb749b872aa83e22852727c594498a26d642279d21db5acda922f103cc0e305ceea9e9eecc1353e401890e71bf80850b679ce30b2d0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.1 - 2026-11-04
|
|
4
|
+
|
|
5
|
+
- Remove the duplicate `homepage_uri` gem metadata that made `gem build` warn
|
|
6
|
+
|
|
7
|
+
## 0.4.0 - 2026-11-04
|
|
8
|
+
|
|
9
|
+
- Add `mysql` commands, including MySQL database backups ([#5](https://github.com/renuo/deploio-cli/pull/5))
|
|
10
|
+
- Add bash completions ([#7](https://github.com/renuo/deploio-cli/pull/7))
|
|
11
|
+
- Replace the fork installer with `bin/setup`, so the checkout runs from any directory
|
|
12
|
+
([#8](https://github.com/renuo/deploio-cli/pull/8))
|
data/README.md
CHANGED
|
@@ -15,10 +15,12 @@ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.co
|
|
|
15
15
|
```bash
|
|
16
16
|
git clone https://github.com/renuo/deploio-cli.git
|
|
17
17
|
cd deploio-cli
|
|
18
|
-
|
|
19
|
-
bundle exec bin/deploio --help
|
|
18
|
+
bin/setup
|
|
20
19
|
```
|
|
21
20
|
|
|
21
|
+
`bin/setup` installs the dependencies and puts `deploio` and `depl` binstubs into `~/.local/bin` (override with `INSTALL_DIR`), so this checkout runs from any directory.
|
|
22
|
+
The binstubs are pinned to the Ruby that ran `bin/setup`; run it again after changing the Ruby version.
|
|
23
|
+
|
|
22
24
|
### As a gem
|
|
23
25
|
|
|
24
26
|
```bash
|
|
@@ -27,10 +29,14 @@ gem install deploio-cli
|
|
|
27
29
|
|
|
28
30
|
## Shell Completion
|
|
29
31
|
|
|
30
|
-
Enable autocompletion by adding
|
|
32
|
+
Enable autocompletion by adding the line for your shell to its rc file:
|
|
31
33
|
|
|
32
34
|
```bash
|
|
35
|
+
# ~/.zshrc
|
|
33
36
|
eval "$(deploio completion)"
|
|
37
|
+
|
|
38
|
+
# ~/.bashrc
|
|
39
|
+
eval "$(deploio completion --shell bash)"
|
|
34
40
|
```
|
|
35
41
|
|
|
36
42
|
After setup, completions work for:
|
|
@@ -108,7 +114,8 @@ EXECUTION
|
|
|
108
114
|
deploio run -a APP -- CMD Alias for exec
|
|
109
115
|
|
|
110
116
|
OTHER
|
|
111
|
-
deploio completion Generate
|
|
117
|
+
deploio completion Generate zsh completion script
|
|
118
|
+
deploio completion -s bash Generate bash completion script
|
|
112
119
|
deploio version Show version
|
|
113
120
|
|
|
114
121
|
FLAGS
|
|
@@ -211,31 +218,35 @@ deploio exec -- rails console
|
|
|
211
218
|
### Running tests
|
|
212
219
|
|
|
213
220
|
```bash
|
|
214
|
-
|
|
221
|
+
bin/check
|
|
222
|
+
bin/fastcheck
|
|
215
223
|
```
|
|
216
224
|
|
|
217
|
-
###
|
|
225
|
+
### Release
|
|
218
226
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
```
|
|
227
|
+
1. Bump the version in `lib/deploio/version.rb`, run `bundle install`, update `CHANGELOG.md` and commit to `main`.
|
|
228
|
+
2. Tag the commit with the bare version and push the tag:
|
|
222
229
|
|
|
223
|
-
|
|
230
|
+
```bash
|
|
231
|
+
git tag -a 1.2.3 -m 1.2.3
|
|
232
|
+
git push origin 1.2.3
|
|
233
|
+
```
|
|
224
234
|
|
|
225
|
-
|
|
235
|
+
3. Create a GitHub release for the tag with the changelog section as notes.
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
Semaphore runs the tests on the tag and, when they pass, promotes the `release` pipeline, which pushes the gem to
|
|
238
|
+
[rubygems.org](https://rubygems.org). It fails when the tag does not match the gem version.
|
|
239
|
+
|
|
240
|
+
### Testing commands
|
|
230
241
|
|
|
231
|
-
|
|
242
|
+
After `bin/setup`, `deploio` runs this checkout, so changes take effect immediately.
|
|
243
|
+
After adding or changing commands or options, refresh the autocompletion options:
|
|
232
244
|
|
|
233
245
|
```shell
|
|
234
|
-
eval "$(deploio completion)"
|
|
246
|
+
eval "$(deploio completion)" # zsh
|
|
247
|
+
eval "$(deploio completion --shell bash)" # bash
|
|
235
248
|
```
|
|
236
249
|
|
|
237
|
-
to refresh the autocompletion options.
|
|
238
|
-
|
|
239
250
|
## License
|
|
240
251
|
|
|
241
252
|
MIT
|
data/Rakefile
CHANGED
data/deploio-cli.gemspec
CHANGED
|
@@ -14,7 +14,6 @@ Gem::Specification.new do |spec|
|
|
|
14
14
|
spec.license = "MIT"
|
|
15
15
|
spec.required_ruby_version = ">= 3.0.0"
|
|
16
16
|
|
|
17
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
17
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
18
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
19
|
|
|
@@ -23,12 +22,16 @@ Gem::Specification.new do |spec|
|
|
|
23
22
|
|
|
24
23
|
eval "$(deploio completion)"
|
|
25
24
|
|
|
25
|
+
or this to your ~/.bashrc:
|
|
26
|
+
|
|
27
|
+
eval "$(deploio completion --shell bash)"
|
|
28
|
+
|
|
26
29
|
MSG
|
|
27
30
|
|
|
28
31
|
spec.files = Dir.chdir(__dir__) do
|
|
29
32
|
`git ls-files -z`.split("\x0").reject do |f|
|
|
30
33
|
(File.expand_path(f) == __FILE__) ||
|
|
31
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
34
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .semaphore appveyor Gemfile])
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
spec.bindir = "bin"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "completion_generator"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
# Generates bash completion script from Thor command metadata.
|
|
7
|
+
# Completers are shell commands printing the candidates.
|
|
8
|
+
#
|
|
9
|
+
# @example With custom option completers
|
|
10
|
+
# BashCompletionGenerator.new(MyCLI, option_completers: {
|
|
11
|
+
# 'env' => 'echo production staging development'
|
|
12
|
+
# }).generate
|
|
13
|
+
#
|
|
14
|
+
class BashCompletionGenerator < CompletionGenerator
|
|
15
|
+
TEMPLATE_PATH = File.expand_path("templates/completion.bash.erb", __dir__)
|
|
16
|
+
|
|
17
|
+
ROOT_OPTIONS = {version: Thor::Option.new(:version, type: :boolean, aliases: "-v")}.freeze
|
|
18
|
+
|
|
19
|
+
# A command path ("" is the root, "pg:backups" a nested subcommand)
|
|
20
|
+
# with the candidates that can follow it.
|
|
21
|
+
Context = Struct.new(:path, :commands, :options, :positional, :passthrough, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def default_option_completers
|
|
26
|
+
{
|
|
27
|
+
"app" => "_#{program_name}_apps_list",
|
|
28
|
+
"size" => "echo micro mini standard",
|
|
29
|
+
"project" => "_#{program_name}_projects_list"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_positional_completers
|
|
34
|
+
{
|
|
35
|
+
"orgs:set" => "_#{program_name}_orgs_list",
|
|
36
|
+
"pg:info" => "_#{program_name}_pg_databases_list",
|
|
37
|
+
"pg:backups:capture" => "_#{program_name}_pg_databases_list",
|
|
38
|
+
"pg:backups:list" => "_#{program_name}_pg_databases_list",
|
|
39
|
+
"pg:backups:download" => "_#{program_name}_pg_databases_list"
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def contexts
|
|
44
|
+
@contexts ||= [
|
|
45
|
+
Context.new(path: "", commands: main_commands.map(&:first), options: ROOT_OPTIONS),
|
|
46
|
+
*direct_commands.map { |name, options| leaf_context(name, all_options(options)) },
|
|
47
|
+
*passthrough_commands.map { |name, options| leaf_context(name, all_options(options), passthrough: true) },
|
|
48
|
+
*subcommands.flat_map do |name, commands, class_options, default_task, klass|
|
|
49
|
+
group_contexts(name, commands, class_options, default_task, klass.subcommand_classes)
|
|
50
|
+
end,
|
|
51
|
+
*nested_subcommands.flat_map do |name, commands, class_options|
|
|
52
|
+
group_contexts(name, commands, class_options)
|
|
53
|
+
end
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def group_contexts(name, commands, class_options, default_task = nil, nested = {})
|
|
58
|
+
default_options = commands.find { |cmd_name, _, _| cmd_name == default_task }&.last || {}
|
|
59
|
+
group = Context.new(path: name, commands: commands.map(&:first),
|
|
60
|
+
options: all_options(default_options, class_options))
|
|
61
|
+
|
|
62
|
+
leaves = commands.reject { |cmd_name, _, _| nested.key?(cmd_name) }.map do |cmd_name, _, options|
|
|
63
|
+
leaf_context("#{name}:#{cmd_name}", all_options(options, class_options))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[group, *leaves]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def leaf_context(path, options, passthrough: false)
|
|
70
|
+
Context.new(path: path, commands: [], options: options,
|
|
71
|
+
positional: positional_completers[path], passthrough: passthrough)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def all_options(method_options, class_options = {})
|
|
75
|
+
cli_class_options.merge(class_options).merge(method_options)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def flags(context)
|
|
79
|
+
context.options.flat_map { |name, opt| option_flags(name, opt) }.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def value_flags(context)
|
|
83
|
+
context.options.reject { |_, opt| opt.type == :boolean }.flat_map { |name, opt| option_flags(name, opt) }.uniq
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def option_value_completers
|
|
87
|
+
contexts.flat_map { |context| context.options.to_a }.uniq(&:first).filter_map do |name, opt|
|
|
88
|
+
completer = option_completers[name.to_s]
|
|
89
|
+
[option_flags(name, opt), completer] if completer
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def option_flags(name, opt)
|
|
94
|
+
["--#{name.to_s.tr("_", "-")}", *opt.aliases]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/deploio/cli.rb
CHANGED
|
@@ -7,9 +7,13 @@ require_relative "commands/builds"
|
|
|
7
7
|
require_relative "commands/orgs"
|
|
8
8
|
require_relative "commands/projects"
|
|
9
9
|
require_relative "commands/services"
|
|
10
|
+
require_relative "helpers/backups_helper"
|
|
10
11
|
require_relative "commands/postgresql_backups"
|
|
11
12
|
require_relative "commands/postgresql"
|
|
13
|
+
require_relative "commands/mysql_backups"
|
|
14
|
+
require_relative "commands/mysql"
|
|
12
15
|
require_relative "completion_generator"
|
|
16
|
+
require_relative "bash_completion_generator"
|
|
13
17
|
|
|
14
18
|
module Deploio
|
|
15
19
|
class CLI < Thor
|
|
@@ -28,11 +32,13 @@ module Deploio
|
|
|
28
32
|
case options[:shell].downcase
|
|
29
33
|
when "zsh"
|
|
30
34
|
puts CompletionGenerator.new.generate
|
|
31
|
-
when "bash"
|
|
32
|
-
|
|
35
|
+
when "bash"
|
|
36
|
+
puts BashCompletionGenerator.new.generate
|
|
37
|
+
when "fish"
|
|
38
|
+
Output.error("#{options[:shell]} completion is not yet supported. Only zsh and bash are available.")
|
|
33
39
|
exit 1
|
|
34
40
|
else
|
|
35
|
-
Output.error("Unknown shell: #{options[:shell]}. Supported: zsh")
|
|
41
|
+
Output.error("Unknown shell: #{options[:shell]}. Supported: zsh, bash")
|
|
36
42
|
exit 1
|
|
37
43
|
end
|
|
38
44
|
end
|
|
@@ -57,6 +63,9 @@ module Deploio
|
|
|
57
63
|
desc "pg COMMAND", "PostgreSQL database management commands"
|
|
58
64
|
subcommand "pg", Commands::PostgreSQL
|
|
59
65
|
|
|
66
|
+
desc "mysql COMMAND", "MySQL database management commands"
|
|
67
|
+
subcommand "mysql", Commands::MySQL
|
|
68
|
+
|
|
60
69
|
# Shortcut for auth:login
|
|
61
70
|
desc "login", "Authenticate with nctl (alias for auth:login)"
|
|
62
71
|
def login
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
module Commands
|
|
5
|
+
class MySQL < Thor
|
|
6
|
+
include SharedOptions
|
|
7
|
+
|
|
8
|
+
namespace "mysql"
|
|
9
|
+
|
|
10
|
+
class_option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
11
|
+
|
|
12
|
+
default_task :list
|
|
13
|
+
|
|
14
|
+
desc "list", "List all MySQL databases"
|
|
15
|
+
def list
|
|
16
|
+
setup_options
|
|
17
|
+
raw_dbs = @nctl.get_all_mysql_databases
|
|
18
|
+
|
|
19
|
+
if options[:json]
|
|
20
|
+
puts JSON.pretty_generate(raw_dbs)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if raw_dbs.empty?
|
|
25
|
+
Output.warning("No MySQL databases found") unless merged_options[:dry_run]
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
|
|
30
|
+
|
|
31
|
+
rows = raw_dbs.map do |mysql_database|
|
|
32
|
+
kind = mysql_database["kind"] || ""
|
|
33
|
+
metadata = mysql_database["metadata"] || {}
|
|
34
|
+
spec = mysql_database["spec"] || {}
|
|
35
|
+
for_provider = spec["forProvider"] || {}
|
|
36
|
+
version = for_provider["version"]
|
|
37
|
+
namespace = metadata["namespace"] || ""
|
|
38
|
+
name = metadata["name"] || ""
|
|
39
|
+
|
|
40
|
+
[
|
|
41
|
+
resolver.short_name_for(namespace, name),
|
|
42
|
+
project_from_namespace(namespace, resolver.current_org),
|
|
43
|
+
presence(kind, default: "-"),
|
|
44
|
+
presence(version, default: "?")
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Output.table(rows, headers: ["NAME", "PROJECT", "KIND", "VERSION"])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "info NAME", "Show MySQL database details"
|
|
52
|
+
def info(name)
|
|
53
|
+
setup_options
|
|
54
|
+
resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
|
|
55
|
+
db_ref = resolver.resolve(database_name: name)
|
|
56
|
+
data = @nctl.get_mysql_database(db_ref)
|
|
57
|
+
|
|
58
|
+
if options[:json]
|
|
59
|
+
puts JSON.pretty_generate(data)
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
kind = data["kind"]
|
|
64
|
+
metadata = data["metadata"] || {}
|
|
65
|
+
spec = data["spec"] || {}
|
|
66
|
+
for_provider = spec["forProvider"] || {}
|
|
67
|
+
status = data["status"] || {}
|
|
68
|
+
at_provider = status["atProvider"] || {}
|
|
69
|
+
|
|
70
|
+
Output.header("MySQL Database: #{db_ref.full_name}")
|
|
71
|
+
puts
|
|
72
|
+
|
|
73
|
+
Output.header("General")
|
|
74
|
+
Output.table(
|
|
75
|
+
[
|
|
76
|
+
["Name", presence(metadata["name"])],
|
|
77
|
+
["Project", presence(metadata["namespace"])],
|
|
78
|
+
["Kind", presence(kind, default: "-")],
|
|
79
|
+
["Version", presence(for_provider["version"], default: "?")],
|
|
80
|
+
["FQDN", presence(at_provider["fqdn"])],
|
|
81
|
+
["Size", presence(at_provider["size"], default: "-")]
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
puts
|
|
86
|
+
|
|
87
|
+
Output.header("Status")
|
|
88
|
+
conditions = status["conditions"] || []
|
|
89
|
+
ready_condition = conditions.find { |c| c["type"] == "Ready" }
|
|
90
|
+
synced_condition = conditions.find { |c| c["type"] == "Synced" }
|
|
91
|
+
|
|
92
|
+
Output.table(
|
|
93
|
+
[
|
|
94
|
+
["Ready", presence(ready_condition&.dig("status"))],
|
|
95
|
+
["Synced", presence(synced_condition&.dig("status"))]
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if for_provider["allowedCIDRs"].is_a?(Array)
|
|
100
|
+
puts
|
|
101
|
+
|
|
102
|
+
Output.header("Access")
|
|
103
|
+
Output.table(
|
|
104
|
+
[
|
|
105
|
+
["Allowed CIDRs", for_provider["allowedCIDRs"].join(", ")]
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
puts
|
|
110
|
+
|
|
111
|
+
Output.header("SSH Keys")
|
|
112
|
+
for_provider["sshKeys"].each do |key|
|
|
113
|
+
puts "- #{key}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
rescue Deploio::Error => e
|
|
117
|
+
Output.error(e.message)
|
|
118
|
+
exit 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
desc "backups COMMAND", "Manage MySQL database backups"
|
|
122
|
+
subcommand "backups", Commands::MySQLBackups
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def presence(value, default: "-")
|
|
127
|
+
(value.nil? || value.to_s.empty?) ? default : value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def project_from_namespace(namespace, current_org)
|
|
131
|
+
if current_org && namespace.start_with?("#{current_org}-")
|
|
132
|
+
namespace.delete_prefix("#{current_org}-")
|
|
133
|
+
else
|
|
134
|
+
namespace
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Deploio
|
|
2
|
+
module Commands
|
|
3
|
+
class MySQLBackups < Thor
|
|
4
|
+
include SharedOptions
|
|
5
|
+
include BackupsHelper
|
|
6
|
+
|
|
7
|
+
namespace "mysql:backups"
|
|
8
|
+
|
|
9
|
+
DEDICATED_KIND = "MySQL"
|
|
10
|
+
ECONOMY_KIND = "MySQLDatabase"
|
|
11
|
+
|
|
12
|
+
desc "capture NAME", "Capture a new backup for the specified MySQL database"
|
|
13
|
+
def capture(name)
|
|
14
|
+
backup_service_for(name).capture
|
|
15
|
+
rescue Deploio::Error => e
|
|
16
|
+
Output.error(e.message)
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "list NAME", "List the available backups for the specified MySQL database"
|
|
21
|
+
def list(name)
|
|
22
|
+
backups = backup_service_for(name).backups
|
|
23
|
+
if backups.empty?
|
|
24
|
+
Output.warning("No backups found for '#{name}'")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rows = backups.map do |backup|
|
|
29
|
+
[format_time(backup["ModTime"]), format_size(backup["Size"]), backup["Name"]]
|
|
30
|
+
end
|
|
31
|
+
Output.table(rows, headers: ["DATE", "SIZE", "NAME"])
|
|
32
|
+
rescue Deploio::Error => e
|
|
33
|
+
Output.error(e.message)
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "download NAME [--output destination_path]", "Download the latest backup for the specified MySQL database instance"
|
|
38
|
+
method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)"
|
|
39
|
+
method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil
|
|
40
|
+
def download(name)
|
|
41
|
+
service = backup_service_for(name)
|
|
42
|
+
destination = merged_options[:output] || service.default_destination
|
|
43
|
+
backup = service.download(destination: destination, db_name: merged_options[:db_name])
|
|
44
|
+
|
|
45
|
+
# Only the economy tier knows when its backup was taken.
|
|
46
|
+
if backup
|
|
47
|
+
Output.success("Downloaded backup from #{format_time(backup["ModTime"])} to #{destination}")
|
|
48
|
+
else
|
|
49
|
+
Output.success("Downloaded backup to #{destination}")
|
|
50
|
+
end
|
|
51
|
+
rescue Deploio::Error => e
|
|
52
|
+
Output.error(e.message)
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def backup_service_for(name)
|
|
59
|
+
setup_options
|
|
60
|
+
resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
|
|
61
|
+
db_ref = resolver.resolve(database_name: name)
|
|
62
|
+
data = @nctl.get_mysql_database(db_ref)
|
|
63
|
+
raise Deploio::Error, "Could not read database '#{db_ref.full_name}'." if data.nil?
|
|
64
|
+
|
|
65
|
+
case data["kind"]
|
|
66
|
+
when DEDICATED_KIND
|
|
67
|
+
MysqlBackupService.new(data: data, name: name, dry_run: @nctl.dry_run)
|
|
68
|
+
when ECONOMY_KIND
|
|
69
|
+
MysqlDatabaseBackupService.new(db_ref: db_ref, data: data, nctl_client: @nctl, name: name)
|
|
70
|
+
else
|
|
71
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
72
|
+
"Backups are not supported for databases of kind '#{data["kind"]}'."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
1
|
module Deploio
|
|
4
2
|
module Commands
|
|
5
3
|
class PostgreSQLBackups < Thor
|
|
6
4
|
include SharedOptions
|
|
5
|
+
include BackupsHelper
|
|
7
6
|
|
|
8
7
|
namespace "pg:backups"
|
|
9
8
|
|
|
@@ -73,24 +72,6 @@ module Deploio
|
|
|
73
72
|
"Backups are not supported for databases of kind '#{data["kind"]}'."
|
|
74
73
|
end
|
|
75
74
|
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
75
|
end
|
|
95
76
|
end
|
|
96
77
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
module BackupsHelper
|
|
7
|
+
def format_size(bytes)
|
|
8
|
+
bytes = bytes.to_i
|
|
9
|
+
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
10
|
+
index = 0
|
|
11
|
+
size = bytes.to_f
|
|
12
|
+
while size >= 1024 && index < units.size - 1
|
|
13
|
+
size /= 1024
|
|
14
|
+
index += 1
|
|
15
|
+
end
|
|
16
|
+
(index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index]))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format_time(value)
|
|
20
|
+
Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
21
|
+
rescue ArgumentError, TypeError
|
|
22
|
+
value.to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlBackupService
|
|
5
|
+
DEFAULT_EXTENSION = ".zst"
|
|
6
|
+
|
|
7
|
+
# @param name [String] the name the user typed, used for hints in messages
|
|
8
|
+
def initialize(data:, name: nil, dry_run: false)
|
|
9
|
+
@data = data || {}
|
|
10
|
+
@name = name
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def default_destination = "./#{@name}-latest-backup"
|
|
15
|
+
|
|
16
|
+
def backups
|
|
17
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
18
|
+
"Listing backups is not yet supported for dedicated MySQL instances; Feel free to implement it!\n" \
|
|
19
|
+
"Use 'deploio mysql backups download #{@name}' to fetch it."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def capture
|
|
23
|
+
cmd = ["ssh", "dbadmin@#{fqdn}", "sudo", "nine-mysql-backup"]
|
|
24
|
+
Output.command(cmd.join(" "))
|
|
25
|
+
system(*cmd) unless @dry_run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def download(destination:, db_name: nil)
|
|
29
|
+
name = resolve_db_name(db_name)
|
|
30
|
+
|
|
31
|
+
cmd = [
|
|
32
|
+
"rsync",
|
|
33
|
+
"-av",
|
|
34
|
+
"dbadmin@#{fqdn}:~/backup/mysql/latest/customer/#{name}/",
|
|
35
|
+
destination
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
Output.command(cmd.join(" "))
|
|
39
|
+
unless @dry_run || system(*cmd)
|
|
40
|
+
raise Deploio::Error, "Failed to download MySQL backup for database '#{name}'."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_db_name(db_name)
|
|
49
|
+
if databases.empty?
|
|
50
|
+
raise Deploio::Error,
|
|
51
|
+
"No databases found in MySQL instance; cannot download backup."
|
|
52
|
+
elsif databases.size > 1 && db_name.nil?
|
|
53
|
+
raise Deploio::Error,
|
|
54
|
+
"Multiple databases found in MySQL instance\n" \
|
|
55
|
+
"Databases: #{databases.join(", ")}\n" \
|
|
56
|
+
"Please specify the database name using the --db_name option."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
db_name || databases.first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def databases
|
|
63
|
+
@databases ||= (
|
|
64
|
+
@data.dig("status", "atProvider", "databases")&.keys || []
|
|
65
|
+
).reject { |db| db.strip.empty? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fqdn
|
|
69
|
+
value = @data.dig("status", "atProvider", "fqdn")
|
|
70
|
+
|
|
71
|
+
if value.nil? || value.empty?
|
|
72
|
+
raise Deploio::Error,
|
|
73
|
+
"Database FQDN not found; cannot reach the database server."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlDatabaseBackupService
|
|
5
|
+
DEFAULT_EXTENSION = ".sql.zst"
|
|
6
|
+
BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
db_ref:,
|
|
10
|
+
data:,
|
|
11
|
+
nctl_client:,
|
|
12
|
+
name: nil,
|
|
13
|
+
rclone_client_factory: nil
|
|
14
|
+
)
|
|
15
|
+
@db_ref = db_ref
|
|
16
|
+
@data = data || {}
|
|
17
|
+
@nctl = nctl_client
|
|
18
|
+
@name = name || db_ref.full_name
|
|
19
|
+
@rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_destination
|
|
23
|
+
"./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Nine takes these backups on a schedule and there is no way to trigger one.
|
|
27
|
+
def capture
|
|
28
|
+
raise Deploio::UnsupportedBackupOperationError,
|
|
29
|
+
"'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
|
|
30
|
+
"on their configured schedule and cannot be captured manually.\n" \
|
|
31
|
+
"Use 'deploio mysql backups list #{@name}' to see the available backups."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def backups
|
|
35
|
+
@backups ||= begin
|
|
36
|
+
entries = rclone.list(bucket_name)
|
|
37
|
+
entries = entries.select { |entry| own_backup?(entry["Name"].to_s) }
|
|
38
|
+
entries.sort_by { |entry| entry["ModTime"].to_s }.reverse
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def download(destination:, db_name: nil)
|
|
43
|
+
backup = backups.first
|
|
44
|
+
|
|
45
|
+
unless backup
|
|
46
|
+
raise Deploio::Error,
|
|
47
|
+
"No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
rclone.download(bucket_name, backup["Name"], destination)
|
|
51
|
+
backup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def own_backup?(object_name)
|
|
57
|
+
return true if instance_name.empty?
|
|
58
|
+
|
|
59
|
+
object_name.start_with?("MySQLDatabase-#{instance_name}-")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def instance_name
|
|
63
|
+
@instance_name ||= @data.dig("status", "atProvider", "name").to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bucket_name
|
|
67
|
+
bucket.dig("metadata", "name")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The database resource holds no reference to its backup bucket.
|
|
71
|
+
# Therefore, match the bucket by name for now.
|
|
72
|
+
def bucket
|
|
73
|
+
@bucket ||= begin
|
|
74
|
+
candidates = @nctl
|
|
75
|
+
.get_services_by_type("bucket", project: @db_ref.project_name)
|
|
76
|
+
.select { |candidate| backup_bucket_for_database?(candidate) }
|
|
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 " \
|
|
82
|
+
"(spec.forProvider.backupSchedule)."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
candidates.first
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def backup_bucket_for_database?(bucket)
|
|
90
|
+
metadata = bucket["metadata"] || {}
|
|
91
|
+
labels = metadata["labels"] || {}
|
|
92
|
+
|
|
93
|
+
return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
|
|
94
|
+
|
|
95
|
+
metadata["name"].to_s.match?(
|
|
96
|
+
/\Amysqldatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rclone
|
|
101
|
+
@rclone ||= @rclone_client_factory.call
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_rclone_client
|
|
105
|
+
endpoint = bucket.dig("status", "atProvider", "endpoint")
|
|
106
|
+
|
|
107
|
+
if endpoint.nil? || endpoint.to_s.empty?
|
|
108
|
+
raise Deploio::Error,
|
|
109
|
+
"Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
client = RcloneClient.new(
|
|
113
|
+
endpoint: "https://#{endpoint}",
|
|
114
|
+
access_key: @nctl.get_bucket_user_access_key(
|
|
115
|
+
bucket_name,
|
|
116
|
+
project: @db_ref.project_name
|
|
117
|
+
),
|
|
118
|
+
secret_key: @nctl.get_bucket_user_secret_key(
|
|
119
|
+
bucket_name,
|
|
120
|
+
project: @db_ref.project_name
|
|
121
|
+
),
|
|
122
|
+
dry_run: @nctl.dry_run
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
client.check_requirements unless @nctl.dry_run
|
|
126
|
+
client
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
class MysqlDatabaseRef
|
|
7
|
+
attr_reader :project_name, :database_name
|
|
8
|
+
|
|
9
|
+
def initialize(input, available_databases: {})
|
|
10
|
+
@input = input.to_s
|
|
11
|
+
if available_databases.key?(@input)
|
|
12
|
+
match = available_databases[@input]
|
|
13
|
+
@project_name = match[:project_name]
|
|
14
|
+
@database_name = match[:database_name]
|
|
15
|
+
else
|
|
16
|
+
raise_not_found_error(@input, available_databases.keys)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def full_name = "#{project_name}-#{database_name}"
|
|
21
|
+
def to_s = full_name
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
other.is_a?(self.class) && project_name == other.project_name && database_name == other.database_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def raise_not_found_error(input, available_database_names)
|
|
30
|
+
message = "Database not found: '#{input}'"
|
|
31
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: available_database_names).correct(input) unless available_database_names.empty?
|
|
32
|
+
unless suggestions.nil? || suggestions.empty?
|
|
33
|
+
message += "\n\nDid you mean?"
|
|
34
|
+
suggestions.each { |suggestion| message += "\n #{suggestion}" }
|
|
35
|
+
end
|
|
36
|
+
message += "\n\nRun 'deploio mysql' to see available MySQL databases."
|
|
37
|
+
raise Deploio::MysqlDatabaseNotFoundError, message
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class MysqlDatabaseResolver
|
|
5
|
+
attr_reader :nctl, :current_org
|
|
6
|
+
|
|
7
|
+
def initialize(nctl_client:)
|
|
8
|
+
@nctl = nctl_client
|
|
9
|
+
@database_ref = MysqlDatabaseRef
|
|
10
|
+
@fetch_databases = -> { @nctl.get_all_mysql_databases }
|
|
11
|
+
@current_org = @nctl.current_org
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(database_name: nil)
|
|
15
|
+
if database_name
|
|
16
|
+
return @database_ref.new(
|
|
17
|
+
database_name,
|
|
18
|
+
available_databases: available_databases_hash
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
raise Deploio::Error, "No database specified"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns hash mapping database names -> { project_name:, database_name: }
|
|
26
|
+
def available_databases_hash
|
|
27
|
+
@available_databases_hash ||= begin
|
|
28
|
+
hash = {}
|
|
29
|
+
current_org = @nctl.current_org
|
|
30
|
+
|
|
31
|
+
@fetch_databases.call.each do |database|
|
|
32
|
+
metadata = database["metadata"] || {}
|
|
33
|
+
project_name = metadata["namespace"] || ""
|
|
34
|
+
database_name = metadata["name"]
|
|
35
|
+
full_name = "#{project_name}-#{database_name}"
|
|
36
|
+
|
|
37
|
+
hash[full_name] = {
|
|
38
|
+
project_name: project_name,
|
|
39
|
+
database_name: database_name
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Also index by short name (without org prefix) for convenience
|
|
43
|
+
if current_org && project_name.start_with?("#{current_org}-")
|
|
44
|
+
project = project_name.delete_prefix("#{current_org}-")
|
|
45
|
+
short_name = "#{project}-#{database_name}"
|
|
46
|
+
|
|
47
|
+
hash[short_name] ||= {
|
|
48
|
+
project_name: project_name,
|
|
49
|
+
database_name: database_name
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
hash
|
|
55
|
+
end
|
|
56
|
+
rescue
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def short_name_for(namespace, database_name)
|
|
61
|
+
org = current_org
|
|
62
|
+
|
|
63
|
+
if org && namespace.start_with?("#{org}-")
|
|
64
|
+
project = namespace.delete_prefix("#{org}-")
|
|
65
|
+
"#{project}-#{database_name}"
|
|
66
|
+
else
|
|
67
|
+
"#{namespace}-#{database_name}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/deploio/nctl_client.rb
CHANGED
|
@@ -100,6 +100,42 @@ module Deploio
|
|
|
100
100
|
nil
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def get_all_mysql_databases
|
|
104
|
+
output_dedicated_dbs = capture("get", "mysql", "-A", "-o", "json")
|
|
105
|
+
output_shared_dbs = capture("get", "mysqldatabase", "-A", "-o", "json")
|
|
106
|
+
if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
|
|
107
|
+
(output_shared_dbs.nil? || output_shared_dbs.empty?)
|
|
108
|
+
return []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
*JSON.parse(output_dedicated_dbs),
|
|
113
|
+
*JSON.parse(output_shared_dbs)
|
|
114
|
+
]
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_mysql_database(db_ref)
|
|
120
|
+
output = begin
|
|
121
|
+
capture("get", "mysql", db_ref.database_name,
|
|
122
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
123
|
+
rescue Deploio::NctlError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if output.nil? || output.empty?
|
|
128
|
+
output = capture("get", "mysqldatabase", db_ref.database_name,
|
|
129
|
+
"--project", db_ref.project_name, "-o", "json")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return nil if output.nil? || output.empty?
|
|
133
|
+
|
|
134
|
+
JSON.parse(output)
|
|
135
|
+
rescue JSON::ParserError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
103
139
|
def get_apps_by_project(project)
|
|
104
140
|
output = capture("get", "apps", "--project", project, "-o", "json")
|
|
105
141
|
return [] if output.nil? || output.empty?
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Bash completion for <%= program_name %>-cli
|
|
2
|
+
# Auto-generated from Thor command definitions
|
|
3
|
+
# Enable with: eval "$(<%= program_name %> completion --shell bash)"
|
|
4
|
+
|
|
5
|
+
# Dynamic completion for organizations
|
|
6
|
+
_<%= program_name %>_orgs_list() {
|
|
7
|
+
<%= program_name %> orgs --json 2>/dev/null | ruby -rjson -e '
|
|
8
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
9
|
+
data.each { |o| puts o["name"] }
|
|
10
|
+
' 2>/dev/null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
# Dynamic completion for projects
|
|
14
|
+
_<%= program_name %>_projects_list() {
|
|
15
|
+
<%= program_name %> projects --json 2>/dev/null | ruby -rjson -e '
|
|
16
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
17
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
18
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
19
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
20
|
+
|
|
21
|
+
data.each do |p|
|
|
22
|
+
name = p.dig("metadata", "name") || ""
|
|
23
|
+
short_name = current_org && name.start_with?("#{current_org}-") ? name.delete_prefix("#{current_org}-") : name
|
|
24
|
+
puts short_name
|
|
25
|
+
end
|
|
26
|
+
' 2>/dev/null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Dynamic completion for apps
|
|
30
|
+
_<%= program_name %>_apps_list() {
|
|
31
|
+
<%= program_name %> apps --json 2>/dev/null | ruby -rjson -e '
|
|
32
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
33
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
34
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
35
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
36
|
+
|
|
37
|
+
data.each do |a|
|
|
38
|
+
ns = a.dig("metadata", "namespace") || ""
|
|
39
|
+
name = a.dig("metadata", "name") || ""
|
|
40
|
+
project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
|
|
41
|
+
puts "#{project}-#{name}"
|
|
42
|
+
end
|
|
43
|
+
' 2>/dev/null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Dynamic completion for PostgreSQL databases
|
|
47
|
+
_<%= program_name %>_pg_databases_list() {
|
|
48
|
+
<%= program_name %> pg list --json 2>/dev/null | ruby -rjson -e '
|
|
49
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
50
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
51
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
52
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
53
|
+
|
|
54
|
+
data.each do |db|
|
|
55
|
+
ns = db.dig("metadata", "namespace") || ""
|
|
56
|
+
name = db.dig("metadata", "name") || ""
|
|
57
|
+
project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
|
|
58
|
+
puts "#{project}-#{name}"
|
|
59
|
+
end
|
|
60
|
+
' 2>/dev/null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Sets the candidates following a command path in the caller's scope
|
|
64
|
+
_<%= program_name %>_context() {
|
|
65
|
+
commands="" flags="" value_flags="" positional="" passthrough=""
|
|
66
|
+
case "$1" in
|
|
67
|
+
<% contexts.each do |context| -%>
|
|
68
|
+
"<%= context.path %>")
|
|
69
|
+
<% unless context.commands.empty? -%>
|
|
70
|
+
commands="<%= context.commands.join(" ") %>"
|
|
71
|
+
<% end -%>
|
|
72
|
+
flags="<%= flags(context).join(" ") %>"
|
|
73
|
+
<% unless value_flags(context).empty? -%>
|
|
74
|
+
value_flags="<%= value_flags(context).join(" ") %>"
|
|
75
|
+
<% end -%>
|
|
76
|
+
<% if context.positional -%>
|
|
77
|
+
positional="<%= context.positional %>"
|
|
78
|
+
<% end -%>
|
|
79
|
+
<% if context.passthrough -%>
|
|
80
|
+
passthrough=1
|
|
81
|
+
<% end -%>
|
|
82
|
+
;;
|
|
83
|
+
<% end -%>
|
|
84
|
+
esac
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_<%= program_name %>_option_values() {
|
|
88
|
+
case "$1" in
|
|
89
|
+
<% option_value_completers.each do |flags, completer| -%>
|
|
90
|
+
<%= flags.join("|") %>) <%= completer %> ;;
|
|
91
|
+
<% end -%>
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_<%= program_name %>_includes() {
|
|
96
|
+
[[ -n "$2" && " $1 " == *" $2 "* ]]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Main completion function
|
|
100
|
+
_<%= program_name %>() {
|
|
101
|
+
local cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
102
|
+
local cmd="" args=0 word i
|
|
103
|
+
local commands flags value_flags positional passthrough
|
|
104
|
+
COMPREPLY=()
|
|
105
|
+
|
|
106
|
+
_<%= program_name %>_context ""
|
|
107
|
+
for ((i = 1; i < COMP_CWORD; i++)); do
|
|
108
|
+
word="${COMP_WORDS[i]}"
|
|
109
|
+
if [[ "$word" == "--" && -n "$passthrough" ]]; then
|
|
110
|
+
if ((i + 1 == COMP_CWORD)); then
|
|
111
|
+
COMPREPLY=($(compgen -c -- "$cur"))
|
|
112
|
+
else
|
|
113
|
+
COMPREPLY=($(compgen -f -- "$cur"))
|
|
114
|
+
fi
|
|
115
|
+
return
|
|
116
|
+
elif _<%= program_name %>_includes "$value_flags" "$word"; then
|
|
117
|
+
((++i))
|
|
118
|
+
elif [[ "$word" == -* ]]; then
|
|
119
|
+
continue
|
|
120
|
+
elif ((args == 0)) && _<%= program_name %>_includes "$commands" "$word"; then
|
|
121
|
+
cmd="${cmd:+$cmd:}$word"
|
|
122
|
+
_<%= program_name %>_context "$cmd"
|
|
123
|
+
else
|
|
124
|
+
((++args))
|
|
125
|
+
fi
|
|
126
|
+
done
|
|
127
|
+
|
|
128
|
+
if _<%= program_name %>_includes "$value_flags" "$prev"; then
|
|
129
|
+
COMPREPLY=($(compgen -W "$(_<%= program_name %>_option_values "$prev")" -- "$cur"))
|
|
130
|
+
elif [[ "$cur" == -* ]]; then
|
|
131
|
+
COMPREPLY=($(compgen -W "$flags" -- "$cur"))
|
|
132
|
+
elif ((args == 0)) && [[ -n "$commands" ]]; then
|
|
133
|
+
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
|
134
|
+
elif ((args == 0)) && [[ -n "$positional" ]]; then
|
|
135
|
+
COMPREPLY=($(compgen -W "$($positional)" -- "$cur"))
|
|
136
|
+
fi
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Register the completion function
|
|
140
|
+
complete -F _<%= program_name %> <%= program_name %> depl
|
data/lib/deploio/version.rb
CHANGED
data/lib/deploio.rb
CHANGED
|
@@ -7,12 +7,16 @@ require_relative "deploio/utils"
|
|
|
7
7
|
require_relative "deploio/output"
|
|
8
8
|
require_relative "deploio/app_ref"
|
|
9
9
|
require_relative "deploio/pg_database_ref"
|
|
10
|
+
require_relative "deploio/mysql_database_ref"
|
|
10
11
|
require_relative "deploio/nctl_client"
|
|
11
12
|
require_relative "deploio/rclone_client"
|
|
12
13
|
require_relative "deploio/app_resolver"
|
|
13
14
|
require_relative "deploio/pg_database_resolver"
|
|
14
15
|
require_relative "deploio/postgres_backup_service"
|
|
15
16
|
require_relative "deploio/postgres_database_backup_service"
|
|
17
|
+
require_relative "deploio/mysql_database_resolver"
|
|
18
|
+
require_relative "deploio/mysql_backup_service"
|
|
19
|
+
require_relative "deploio/mysql_database_backup_service"
|
|
16
20
|
require_relative "deploio/price_fetcher"
|
|
17
21
|
require_relative "deploio/shared_options"
|
|
18
22
|
require_relative "deploio/cli"
|
|
@@ -21,6 +25,7 @@ module Deploio
|
|
|
21
25
|
class Error < StandardError; end
|
|
22
26
|
class AppNotFoundError < Error; end
|
|
23
27
|
class PgDatabaseNotFoundError < Error; end
|
|
28
|
+
class MysqlDatabaseNotFoundError < Error; end
|
|
24
29
|
class NctlError < Error; end
|
|
25
30
|
class RcloneError < Error; end
|
|
26
31
|
class UnsupportedBackupOperationError < Error; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: deploio-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Renuo AG
|
|
@@ -46,6 +46,7 @@ extensions: []
|
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
48
|
- ".ruby-version"
|
|
49
|
+
- CHANGELOG.md
|
|
49
50
|
- README.md
|
|
50
51
|
- Rakefile
|
|
51
52
|
- bin/deploio
|
|
@@ -53,16 +54,24 @@ files:
|
|
|
53
54
|
- lib/deploio.rb
|
|
54
55
|
- lib/deploio/app_ref.rb
|
|
55
56
|
- lib/deploio/app_resolver.rb
|
|
57
|
+
- lib/deploio/bash_completion_generator.rb
|
|
56
58
|
- lib/deploio/cli.rb
|
|
57
59
|
- lib/deploio/commands/apps.rb
|
|
58
60
|
- lib/deploio/commands/auth.rb
|
|
59
61
|
- lib/deploio/commands/builds.rb
|
|
62
|
+
- lib/deploio/commands/mysql.rb
|
|
63
|
+
- lib/deploio/commands/mysql_backups.rb
|
|
60
64
|
- lib/deploio/commands/orgs.rb
|
|
61
65
|
- lib/deploio/commands/postgresql.rb
|
|
62
66
|
- lib/deploio/commands/postgresql_backups.rb
|
|
63
67
|
- lib/deploio/commands/projects.rb
|
|
64
68
|
- lib/deploio/commands/services.rb
|
|
65
69
|
- lib/deploio/completion_generator.rb
|
|
70
|
+
- lib/deploio/helpers/backups_helper.rb
|
|
71
|
+
- lib/deploio/mysql_backup_service.rb
|
|
72
|
+
- lib/deploio/mysql_database_backup_service.rb
|
|
73
|
+
- lib/deploio/mysql_database_ref.rb
|
|
74
|
+
- lib/deploio/mysql_database_resolver.rb
|
|
66
75
|
- lib/deploio/nctl_client.rb
|
|
67
76
|
- lib/deploio/output.rb
|
|
68
77
|
- lib/deploio/pg_database_ref.rb
|
|
@@ -72,15 +81,14 @@ files:
|
|
|
72
81
|
- lib/deploio/price_fetcher.rb
|
|
73
82
|
- lib/deploio/rclone_client.rb
|
|
74
83
|
- lib/deploio/shared_options.rb
|
|
84
|
+
- lib/deploio/templates/completion.bash.erb
|
|
75
85
|
- lib/deploio/templates/completion.zsh.erb
|
|
76
86
|
- lib/deploio/utils.rb
|
|
77
87
|
- lib/deploio/version.rb
|
|
78
|
-
- setup
|
|
79
88
|
homepage: https://github.com/renuo/deploio-cli
|
|
80
89
|
licenses:
|
|
81
90
|
- MIT
|
|
82
91
|
metadata:
|
|
83
|
-
homepage_uri: https://github.com/renuo/deploio-cli
|
|
84
92
|
source_code_uri: https://github.com/renuo/deploio-cli
|
|
85
93
|
changelog_uri: https://github.com/renuo/deploio-cli/blob/main/CHANGELOG.md
|
|
86
94
|
post_install_message: |+
|
|
@@ -88,6 +96,10 @@ post_install_message: |+
|
|
|
88
96
|
|
|
89
97
|
eval "$(deploio completion)"
|
|
90
98
|
|
|
99
|
+
or this to your ~/.bashrc:
|
|
100
|
+
|
|
101
|
+
eval "$(deploio completion --shell bash)"
|
|
102
|
+
|
|
91
103
|
rdoc_options: []
|
|
92
104
|
require_paths:
|
|
93
105
|
- lib
|
|
@@ -102,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
102
114
|
- !ruby/object:Gem::Version
|
|
103
115
|
version: '0'
|
|
104
116
|
requirements: []
|
|
105
|
-
rubygems_version:
|
|
117
|
+
rubygems_version: 4.0.2
|
|
106
118
|
specification_version: 4
|
|
107
119
|
summary: CLI for Deploio
|
|
108
120
|
test_files: []
|
data/setup
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
APP_NAME=${APP_NAME:-deploio}
|
|
4
|
-
INSTALL_DIR=${INSTALL_DIR:-"${HOME}/.local/bin"}
|
|
5
|
-
RAW_BASE=${RAW_BASE:-"https://raw.githubusercontent.com/CuddlyBunion341/deploio-cli/main"}
|
|
6
|
-
CLI_URL="${RAW_BASE}/deploio"
|
|
7
|
-
MAN_URL="${RAW_BASE}/deploio.1"
|
|
8
|
-
COMPLETION_URL="${RAW_BASE}/_deploio"
|
|
9
|
-
COLOR_RESET='\033[0m'
|
|
10
|
-
COLOR_GREEN='\033[0;32m'
|
|
11
|
-
COLOR_YELLOW='\033[0;33m'
|
|
12
|
-
COLOR_CYAN='\033[0;36m'
|
|
13
|
-
COLOR_RED='\033[0;31m'
|
|
14
|
-
print() { printf "%b\n" "$*"; }
|
|
15
|
-
info() { print "${COLOR_CYAN}$*${COLOR_RESET}"; }
|
|
16
|
-
success() { print "${COLOR_GREEN}$*${COLOR_RESET}"; }
|
|
17
|
-
warn() { print "${COLOR_YELLOW}$*${COLOR_RESET}"; }
|
|
18
|
-
err() { print "${COLOR_RED}$*${COLOR_RESET}" 1>&2; }
|
|
19
|
-
have_cmd() { command -v "$1" >/dev/null 2>&1; }
|
|
20
|
-
fetch() {
|
|
21
|
-
local url="$1" dest="$2"
|
|
22
|
-
if have_cmd curl; then
|
|
23
|
-
curl -fsSL "$url" -o "$dest"
|
|
24
|
-
elif have_cmd wget; then
|
|
25
|
-
wget -qO "$dest" "$url"
|
|
26
|
-
else
|
|
27
|
-
err "Need curl or wget to download: $url"
|
|
28
|
-
return 1
|
|
29
|
-
fi
|
|
30
|
-
}
|
|
31
|
-
main() {
|
|
32
|
-
info "Installing ${APP_NAME} (deplo.io app CLI)"
|
|
33
|
-
mkdir -p "$INSTALL_DIR"
|
|
34
|
-
tmp_file="$(mktemp -t ${APP_NAME}.XXXXXX)"
|
|
35
|
-
tmp_man="$(mktemp -t ${APP_NAME}-man.XXXXXX)"
|
|
36
|
-
tmp_completion="$(mktemp -t ${APP_NAME}-completion.XXXXXX)"
|
|
37
|
-
trap 'rm -f "$tmp_file" "$tmp_man" "$tmp_completion"' EXIT
|
|
38
|
-
|
|
39
|
-
info "Downloading CLI from: $CLI_URL"
|
|
40
|
-
if ! fetch "$CLI_URL" "$tmp_file"; then
|
|
41
|
-
err "Failed to download ${APP_NAME} from $CLI_URL"
|
|
42
|
-
exit 1
|
|
43
|
-
fi
|
|
44
|
-
|
|
45
|
-
info "Downloading man page from: $MAN_URL"
|
|
46
|
-
if ! fetch "$MAN_URL" "$tmp_man"; then
|
|
47
|
-
warn "Failed to download man page from $MAN_URL (continuing without man page)"
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
info "Downloading zsh completion from: $COMPLETION_URL"
|
|
51
|
-
if ! fetch "$COMPLETION_URL" "$tmp_completion"; then
|
|
52
|
-
warn "Failed to download completion script from $COMPLETION_URL (continuing without completions)"
|
|
53
|
-
fi
|
|
54
|
-
|
|
55
|
-
install_path="${INSTALL_DIR}/${APP_NAME}"
|
|
56
|
-
mv "$tmp_file" "$install_path"
|
|
57
|
-
chmod +x "$install_path"
|
|
58
|
-
|
|
59
|
-
# Install man page if download succeeded
|
|
60
|
-
if [[ -s "$tmp_man" ]]; then
|
|
61
|
-
man_dir="${HOME}/.local/share/man/man1"
|
|
62
|
-
mkdir -p "$man_dir"
|
|
63
|
-
mv "$tmp_man" "${man_dir}/${APP_NAME}.1"
|
|
64
|
-
success "Installed man page to ${man_dir}/${APP_NAME}.1"
|
|
65
|
-
fi
|
|
66
|
-
|
|
67
|
-
# Install zsh completion if download succeeded
|
|
68
|
-
if [[ -s "$tmp_completion" ]]; then
|
|
69
|
-
completion_dir="${HOME}/.local/share/zsh/site-functions"
|
|
70
|
-
mkdir -p "$completion_dir"
|
|
71
|
-
mv "$tmp_completion" "${completion_dir}/_${APP_NAME}"
|
|
72
|
-
success "Installed zsh completion to ${completion_dir}/_${APP_NAME}"
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# Create shorthand symlink 'depl'
|
|
76
|
-
(cd "$INSTALL_DIR" && ln -sf "$APP_NAME" depl)
|
|
77
|
-
success "Installed ${APP_NAME} to ${install_path}"
|
|
78
|
-
case ":${PATH}:" in
|
|
79
|
-
*":${INSTALL_DIR}:"*) ;;
|
|
80
|
-
*)
|
|
81
|
-
warn "${INSTALL_DIR} is not on your PATH. Add this to your ~/.zshrc:"
|
|
82
|
-
echo ""
|
|
83
|
-
echo " export PATH=\"$INSTALL_DIR:\$PATH\""
|
|
84
|
-
echo ""
|
|
85
|
-
echo "Then reload your shell:"
|
|
86
|
-
echo " source ~/.zshrc"
|
|
87
|
-
;;
|
|
88
|
-
esac
|
|
89
|
-
|
|
90
|
-
# Check if man page directory is in MANPATH
|
|
91
|
-
man_dir="${HOME}/.local/share/man"
|
|
92
|
-
if [[ -f "${man_dir}/man1/${APP_NAME}.1" ]] && [[ ":${MANPATH}:" != *":${man_dir}:"* ]]; then
|
|
93
|
-
warn "Man page installed but ${man_dir} is not in MANPATH."
|
|
94
|
-
echo "Add this to your ~/.zshrc to access man pages:"
|
|
95
|
-
echo ""
|
|
96
|
-
echo " export MANPATH=\"${man_dir}:\$MANPATH\""
|
|
97
|
-
echo ""
|
|
98
|
-
fi
|
|
99
|
-
|
|
100
|
-
# Check if completion directory is in fpath
|
|
101
|
-
completion_dir="${HOME}/.local/share/zsh/site-functions"
|
|
102
|
-
if [[ -f "${completion_dir}/_${APP_NAME}" ]]; then
|
|
103
|
-
echo "Zsh completion installed. To enable it, add this to your ~/.zshrc:"
|
|
104
|
-
echo ""
|
|
105
|
-
echo " fpath=(\"${completion_dir}\" \$fpath)"
|
|
106
|
-
echo " autoload -Uz compinit && compinit"
|
|
107
|
-
echo ""
|
|
108
|
-
echo "Then restart your shell or run: source ~/.zshrc"
|
|
109
|
-
fi
|
|
110
|
-
echo ""
|
|
111
|
-
info "Test the installation:"
|
|
112
|
-
echo " ${APP_NAME} --dry-run list"
|
|
113
|
-
echo " man ${APP_NAME}"
|
|
114
|
-
}
|
|
115
|
-
main "$@"
|