nvoi 0.1.5 → 0.1.6

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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/_target.md +79 -0
  15. data/.claude/todo/scaleway.impl.md +644 -0
  16. data/.claude/todo/scaleway.reference.md +520 -0
  17. data/Gemfile +1 -0
  18. data/Gemfile.lock +12 -2
  19. data/doc/config-schema.yaml +44 -11
  20. data/examples/golang/deploy.enc +0 -0
  21. data/examples/golang/main.go +18 -0
  22. data/exe/nvoi +3 -1
  23. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  24. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  25. data/lib/nvoi/cli/db/command.rb +308 -0
  26. data/lib/nvoi/cli/delete/command.rb +75 -0
  27. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  28. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
  29. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  30. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  31. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  32. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  33. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  34. data/lib/nvoi/cli/deploy/command.rb +184 -0
  35. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  36. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  37. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
  38. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
  39. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  40. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  41. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  42. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
  43. data/lib/nvoi/cli/exec/command.rb +173 -0
  44. data/lib/nvoi/cli.rb +83 -142
  45. data/lib/nvoi/config_api/actions/app.rb +53 -0
  46. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  47. data/lib/nvoi/config_api/actions/database.rb +70 -0
  48. data/lib/nvoi/config_api/actions/env.rb +32 -0
  49. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  50. data/lib/nvoi/config_api/actions/server.rb +66 -0
  51. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  52. data/lib/nvoi/config_api/base.rb +44 -0
  53. data/lib/nvoi/config_api/result.rb +26 -0
  54. data/lib/nvoi/config_api.rb +70 -0
  55. data/lib/nvoi/errors.rb +68 -50
  56. data/lib/nvoi/external/cloud/aws.rb +425 -0
  57. data/lib/nvoi/external/cloud/base.rb +99 -0
  58. data/lib/nvoi/external/cloud/factory.rb +48 -0
  59. data/lib/nvoi/external/cloud/hetzner.rb +376 -0
  60. data/lib/nvoi/external/cloud/scaleway.rb +533 -0
  61. data/lib/nvoi/external/cloud.rb +15 -0
  62. data/lib/nvoi/external/containerd.rb +82 -0
  63. data/lib/nvoi/external/database/mysql.rb +84 -0
  64. data/lib/nvoi/external/database/postgres.rb +82 -0
  65. data/lib/nvoi/external/database/provider.rb +65 -0
  66. data/lib/nvoi/external/database/sqlite.rb +72 -0
  67. data/lib/nvoi/external/database.rb +22 -0
  68. data/lib/nvoi/external/dns/cloudflare.rb +292 -0
  69. data/lib/nvoi/external/kubectl.rb +65 -0
  70. data/lib/nvoi/external/ssh.rb +106 -0
  71. data/lib/nvoi/objects/config_override.rb +60 -0
  72. data/lib/nvoi/objects/configuration.rb +463 -0
  73. data/lib/nvoi/objects/database.rb +56 -0
  74. data/lib/nvoi/objects/dns.rb +14 -0
  75. data/lib/nvoi/objects/firewall.rb +11 -0
  76. data/lib/nvoi/objects/network.rb +11 -0
  77. data/lib/nvoi/objects/server.rb +14 -0
  78. data/lib/nvoi/objects/service_spec.rb +26 -0
  79. data/lib/nvoi/objects/tunnel.rb +14 -0
  80. data/lib/nvoi/objects/volume.rb +17 -0
  81. data/lib/nvoi/utils/config_loader.rb +172 -0
  82. data/lib/nvoi/utils/constants.rb +61 -0
  83. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  84. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  85. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  86. data/lib/nvoi/utils/logger.rb +84 -0
  87. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
  88. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  89. data/lib/nvoi/utils/templates.rb +62 -0
  90. data/lib/nvoi/version.rb +1 -1
  91. data/lib/nvoi.rb +10 -54
  92. data/templates/error-backend.yaml.erb +134 -0
  93. metadata +97 -44
  94. data/examples/golang/deploy.yml +0 -54
  95. data/lib/nvoi/cloudflare/client.rb +0 -287
  96. data/lib/nvoi/config/config.rb +0 -248
  97. data/lib/nvoi/config/loader.rb +0 -102
  98. data/lib/nvoi/config/ssh_keys.rb +0 -82
  99. data/lib/nvoi/config/types.rb +0 -274
  100. data/lib/nvoi/constants.rb +0 -59
  101. data/lib/nvoi/credentials/editor.rb +0 -272
  102. data/lib/nvoi/deployer/cleaner.rb +0 -36
  103. data/lib/nvoi/deployer/image_builder.rb +0 -23
  104. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  105. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  106. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  107. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  108. data/lib/nvoi/deployer/types.rb +0 -8
  109. data/lib/nvoi/k8s/renderer.rb +0 -44
  110. data/lib/nvoi/k8s/templates.rb +0 -29
  111. data/lib/nvoi/logger.rb +0 -72
  112. data/lib/nvoi/providers/aws.rb +0 -403
  113. data/lib/nvoi/providers/base.rb +0 -111
  114. data/lib/nvoi/providers/hetzner.rb +0 -288
  115. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  116. data/lib/nvoi/remote/docker_manager.rb +0 -203
  117. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  118. data/lib/nvoi/remote/volume_manager.rb +0 -103
  119. data/lib/nvoi/service/delete.rb +0 -234
  120. data/lib/nvoi/service/deploy.rb +0 -80
  121. data/lib/nvoi/service/exec.rb +0 -144
  122. data/lib/nvoi/service/provider.rb +0 -36
  123. data/lib/nvoi/steps/application_deployer.rb +0 -26
  124. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  125. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  126. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  127. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  128. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  129. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  130. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Exec
6
+ # Command handles remote command execution on servers
7
+ class Command
8
+ def initialize(options)
9
+ @options = options
10
+ @log = Nvoi.logger
11
+ end
12
+
13
+ def run(args)
14
+ @log.info "Exec CLI %s", VERSION
15
+
16
+ # Load configuration
17
+ config_path = resolve_config_path
18
+ @config = Utils::ConfigLoader.load(config_path)
19
+
20
+ # Apply branch override if specified
21
+ apply_branch_override if @options[:branch]
22
+
23
+ # Initialize cloud provider
24
+ @provider = External::Cloud.for(@config)
25
+
26
+ if @options[:interactive]
27
+ @log.warning "Ignoring command arguments in interactive mode" unless args.empty?
28
+ @log.warning "Ignoring --all flag in interactive mode" if @options[:all]
29
+ open_shell(@options[:server])
30
+ else
31
+ raise ArgumentError, "command required (use --interactive/-i for shell)" if args.empty?
32
+
33
+ command = args.join(" ")
34
+
35
+ if @options[:all]
36
+ run_all(command)
37
+ else
38
+ run_on_server(command, @options[:server])
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_config_path
46
+ config_path = @options[:config] || "deploy.enc"
47
+ working_dir = @options[:dir]
48
+
49
+ if config_path == "deploy.enc" && working_dir && working_dir != "."
50
+ File.join(working_dir, "deploy.enc")
51
+ else
52
+ config_path
53
+ end
54
+ end
55
+
56
+ def apply_branch_override
57
+ branch = @options[:branch]
58
+ return if branch.nil? || branch.empty?
59
+
60
+ override = Objects::ConfigOverride.new(branch:)
61
+ override.apply(@config)
62
+ end
63
+
64
+ def run_on_server(command, server_name)
65
+ actual_server_name = resolve_server_name(server_name)
66
+ server = find_server(actual_server_name)
67
+
68
+ @log.info "Connecting to %s (%s)", actual_server_name, server.public_ipv4
69
+
70
+ ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
71
+
72
+ @log.info "Executing: %s", command
73
+ output = ssh.execute(command, stream: true)
74
+
75
+ puts output if !output.empty? && !output.include?("\n")
76
+
77
+ @log.success "Command completed successfully"
78
+ end
79
+
80
+ def run_all(command)
81
+ server_names = get_all_server_names
82
+
83
+ raise Errors::ServiceError, "no servers found in configuration" if server_names.empty?
84
+
85
+ @log.info "Executing on %d server(s): %s", server_names.size, server_names.join(", ")
86
+ @log.separator
87
+
88
+ results = {}
89
+ mutex = Mutex.new
90
+ threads = server_names.map do |name|
91
+ Thread.new do
92
+ begin
93
+ server = find_server(name)
94
+ ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
95
+
96
+ @log.info "[%s] Executing...", name
97
+ output = ssh.execute(command)
98
+
99
+ output.strip.split("\n").each do |line|
100
+ puts "[#{name}] #{line}"
101
+ end
102
+
103
+ mutex.synchronize { results[name] = nil }
104
+ rescue StandardError => e
105
+ @log.error "[%s] Failed: %s", name, e.message
106
+ mutex.synchronize { results[name] = e }
107
+ end
108
+ end
109
+ end
110
+
111
+ threads.each(&:join)
112
+
113
+ @log.separator
114
+
115
+ failures = results.select { |_, err| err }.keys
116
+ if failures.any?
117
+ @log.warning "Command failed on %d server(s): %s", failures.size, failures.join(", ")
118
+ raise Errors::ServiceError, "command failed on some servers"
119
+ end
120
+
121
+ @log.success "Command completed successfully on all servers"
122
+ end
123
+
124
+ def open_shell(server_name)
125
+ actual_server_name = resolve_server_name(server_name)
126
+ server = find_server(actual_server_name)
127
+
128
+ @log.info "Opening SSH shell to %s (%s)", actual_server_name, server.public_ipv4
129
+
130
+ ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
131
+ ssh.open_shell
132
+ end
133
+
134
+ def resolve_server_name(name)
135
+ return @config.server_name if name.nil? || name.empty? || name == "main"
136
+
137
+ parts = name.split("-")
138
+ if parts.length >= 2
139
+ num_str = parts.last
140
+ if num_str.match?(/^\d+$/)
141
+ group_name = parts[0...-1].join("-")
142
+ return @config.namer.server_name(group_name, num_str.to_i)
143
+ end
144
+ end
145
+
146
+ @config.namer.server_name(name, 1)
147
+ end
148
+
149
+ def get_all_server_names
150
+ names = []
151
+
152
+ @config.deploy.application.servers.each do |group_name, group_config|
153
+ next unless group_config
154
+
155
+ count = group_config.count.positive? ? group_config.count : 1
156
+ (1..count).each do |i|
157
+ names << @config.namer.server_name(group_name, i)
158
+ end
159
+ end
160
+
161
+ names
162
+ end
163
+
164
+ def find_server(server_name)
165
+ server = @provider.find_server(server_name)
166
+ raise Errors::ServiceError, "server not found: #{server_name}" unless server
167
+
168
+ server
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
data/lib/nvoi/cli.rb CHANGED
@@ -3,85 +3,14 @@
3
3
  require "thor"
4
4
 
5
5
  module Nvoi
6
- # CredentialsCLI handles encrypted credential management
7
- class CredentialsCLI < Thor
8
- class_option :credentials, desc: "Path to encrypted credentials file (default: deploy.enc)"
9
- class_option :master_key, desc: "Path to master key file (default: deploy.key or $NVOI_MASTER_KEY)"
10
- class_option :dir, aliases: "-d", default: ".", desc: "Working directory"
11
-
12
- def self.exit_on_failure?
13
- true
14
- end
15
-
16
- desc "edit", "Edit encrypted credentials"
17
- long_desc <<~DESC
18
- Decrypt credentials, open in $EDITOR, validate, and re-encrypt.
19
-
20
- On first run, generates a new master key and creates deploy.key (git-ignored).
21
- The master key can also be provided via $NVOI_MASTER_KEY environment variable.
22
- DESC
23
- def edit
24
- log = Nvoi.logger
25
- log.info "Credentials Editor"
26
-
27
- working_dir = resolve_working_dir
28
-
29
- enc_path = options[:credentials]
30
- enc_path = File.join(working_dir, Credentials::DEFAULT_ENCRYPTED_FILE) if enc_path.nil? || enc_path.empty?
31
-
32
- if File.exist?(enc_path)
33
- # Existing file: load manager
34
- manager = Credentials::Manager.new(working_dir, options[:credentials], options[:master_key])
35
- else
36
- # First time: initialize
37
- log.info "Creating new encrypted credentials file"
38
- manager = Credentials::Manager.for_init(working_dir)
39
- end
40
-
41
- editor = Credentials::Editor.new(manager)
42
- editor.edit
43
-
44
- # Update .gitignore on first run
45
- if manager.key_path
46
- begin
47
- manager.update_gitignore
48
- log.info "Added %s to .gitignore", Credentials::DEFAULT_KEY_FILE
49
- rescue StandardError => e
50
- log.warning "Failed to update .gitignore: %s", e.message
51
- end
52
-
53
- log.success "Master key saved to: %s", manager.key_path
54
- log.warning "Keep this key safe! You cannot decrypt credentials without it."
55
- end
56
- end
57
-
58
- desc "show", "Display decrypted credentials"
59
- long_desc "Decrypt and print credentials to stdout. Useful for debugging or piping to other tools."
60
- def show
61
- working_dir = resolve_working_dir
62
- manager = Credentials::Manager.new(working_dir, options[:credentials], options[:master_key])
63
- editor = Credentials::Editor.new(manager)
64
- editor.show
65
- end
66
-
67
- private
68
-
69
- def resolve_working_dir
70
- wd = options[:dir]
71
- if wd.nil? || wd.empty? || wd == "."
72
- Dir.pwd
73
- else
74
- File.expand_path(wd)
75
- end
76
- end
77
- end
78
-
79
- # Main CLI for nvoi commands
80
- class CLI < Thor
81
- class_option :config, aliases: "-c", default: Constants::DEFAULT_CONFIG_FILE,
6
+ # Main CLI for nvoi commands - Thor routing only
7
+ class Cli < Thor
8
+ class_option :config, aliases: "-c", default: "deploy.enc",
82
9
  desc: "Path to deployment configuration file"
83
10
  class_option :dir, aliases: "-d", default: ".",
84
11
  desc: "Working directory containing the application code"
12
+ class_option :branch, aliases: "-b",
13
+ desc: "Branch name for isolated deployments (prefixes app name and subdomains)"
85
14
 
86
15
  def self.exit_on_failure?
87
16
  true
@@ -96,95 +25,107 @@ module Nvoi
96
25
  option :dockerfile_path, desc: "Path to Dockerfile (optional, defaults to ./Dockerfile)"
97
26
  option :config_dir, desc: "Directory containing SSH keys (optional, defaults to ~/.ssh)"
98
27
  def deploy
99
- log = Nvoi.logger
100
- log.info "Deploy CLI %s", VERSION
101
-
102
- config_path = resolve_config_path
103
- working_dir = options[:dir]
104
- dockerfile_path = options[:dockerfile_path] || File.join(working_dir, "Dockerfile")
105
-
106
- begin
107
- svc = Service::DeployService.new(config_path, working_dir, log)
108
- svc.config_dir = options[:config_dir] if options[:config_dir]
109
- svc.dockerfile_path = dockerfile_path
110
- svc.run
111
- rescue StandardError => e
112
- log.error "Deployment failed: %s", e.message
113
- raise
114
- end
28
+ require_relative "cli/deploy/command"
29
+ Cli::Deploy::Command.new(options).run
115
30
  end
116
31
 
117
32
  desc "delete", "Delete server, firewall, and network"
118
33
  option :config_dir, desc: "Directory containing SSH keys (optional, defaults to ~/.ssh)"
119
34
  def delete
120
- log = Nvoi.logger
121
- log.info "Delete CLI %s", VERSION
122
-
123
- config_path = resolve_config_path
124
-
125
- begin
126
- svc = Service::DeleteService.new(config_path, log)
127
- svc.config_dir = options[:config_dir] if options[:config_dir]
128
- svc.run
129
- rescue StandardError => e
130
- log.error "Delete failed: %s", e.message
131
- raise
132
- end
35
+ require_relative "cli/delete/command"
36
+ Cli::Delete::Command.new(options).run
133
37
  end
134
38
 
135
39
  desc "exec [COMMAND...]", "Execute command on remote server or open interactive shell"
136
- long_desc <<~DESC
137
- Execute arbitrary bash commands on remote servers using existing configuration,
138
- or open an interactive SSH shell with --interactive flag.
139
- DESC
140
40
  option :server, default: "main", desc: "Server to execute on (main, worker-1, worker-2, etc.)"
141
41
  option :all, type: :boolean, default: false, desc: "Execute on all servers"
142
42
  option :interactive, aliases: "-i", type: :boolean, default: false,
143
43
  desc: "Open interactive SSH shell instead of executing command"
144
44
  def exec(*args)
145
- log = Nvoi.logger
146
- log.info "Exec CLI %s", VERSION
45
+ require_relative "cli/exec/command"
46
+ Cli::Exec::Command.new(options).run(args)
47
+ end
147
48
 
148
- config_path = resolve_config_path
49
+ desc "credentials SUBCOMMAND", "Manage encrypted deployment credentials"
50
+ subcommand "credentials", Class.new(Thor) {
51
+ def self.exit_on_failure?
52
+ true
53
+ end
149
54
 
150
- begin
151
- svc = Service::ExecService.new(config_path, log)
55
+ class_option :credentials, desc: "Path to encrypted credentials file (default: deploy.enc)"
56
+ class_option :master_key, desc: "Path to master key file (default: deploy.key or $NVOI_MASTER_KEY)"
57
+ class_option :dir, aliases: "-d", default: ".", desc: "Working directory"
152
58
 
153
- if options[:interactive]
154
- log.warning "Ignoring command arguments in interactive mode" unless args.empty?
155
- log.warning "Ignoring --all flag in interactive mode" if options[:all]
156
- svc.open_shell(options[:server])
157
- else
158
- raise ArgumentError, "command required (use --interactive/-i for shell)" if args.empty?
59
+ desc "edit", "Edit encrypted credentials"
60
+ def edit
61
+ require_relative "cli/credentials/edit/command"
62
+ Nvoi::Cli::Credentials::Edit::Command.new(options).run
63
+ end
159
64
 
160
- command = args.join(" ")
65
+ desc "show", "Show decrypted credentials"
66
+ def show
67
+ require_relative "cli/credentials/show/command"
68
+ Nvoi::Cli::Credentials::Show::Command.new(options).run
69
+ end
161
70
 
162
- if options[:all]
163
- svc.run_all(command)
164
- else
165
- svc.run(command, options[:server])
166
- end
167
- end
168
- rescue StandardError => e
169
- log.error "Exec failed: %s", e.message
170
- raise
71
+ desc "set PATH VALUE", "Set a value at a dot-notation path"
72
+ def set(path, value)
73
+ require_relative "cli/credentials/edit/command"
74
+ Nvoi::Cli::Credentials::Edit::Command.new(options).set(path, value)
171
75
  end
172
- end
76
+ }
173
77
 
174
- desc "credentials SUBCOMMAND", "Manage encrypted deployment credentials"
175
- subcommand "credentials", CredentialsCLI
78
+ desc "db SUBCOMMAND", "Database operations"
79
+ subcommand "db", Class.new(Thor) {
80
+ def self.exit_on_failure?
81
+ true
82
+ end
176
83
 
177
- private
84
+ class_option :config, aliases: "-c", default: "deploy.enc",
85
+ desc: "Path to deployment configuration file"
86
+ class_option :dir, aliases: "-d", default: ".",
87
+ desc: "Working directory"
88
+ class_option :branch, aliases: "-b",
89
+ desc: "Branch name for isolated deployments"
90
+
91
+ desc "branch SUBCOMMAND", "Database branch operations"
92
+ subcommand "branch", Class.new(Thor) {
93
+ def self.exit_on_failure?
94
+ true
95
+ end
178
96
 
179
- def resolve_config_path
180
- config_path = options[:config]
181
- working_dir = options[:dir]
97
+ class_option :config, aliases: "-c", default: "deploy.enc",
98
+ desc: "Path to deployment configuration file"
99
+ class_option :dir, aliases: "-d", default: ".",
100
+ desc: "Working directory"
101
+ class_option :branch, aliases: "-b",
102
+ desc: "Branch name for isolated deployments"
103
+
104
+ desc "create [NAME]", "Create a new database branch (snapshot)"
105
+ def create(name = nil)
106
+ require_relative "cli/db/command"
107
+ Nvoi::Cli::Db::Command.new(options).branch_create(name)
108
+ end
182
109
 
183
- if config_path == Constants::DEFAULT_CONFIG_FILE && working_dir && working_dir != "."
184
- File.join(working_dir, Constants::DEFAULT_CONFIG_FILE)
185
- else
186
- config_path
110
+ desc "list", "List all database branches"
111
+ def list
112
+ require_relative "cli/db/command"
113
+ Nvoi::Cli::Db::Command.new(options).branch_list
187
114
  end
188
- end
115
+
116
+ desc "restore ID [NEW_DB_NAME]", "Restore a database branch to a new database"
117
+ def restore(branch_id, new_db_name = nil)
118
+ require_relative "cli/db/command"
119
+ Nvoi::Cli::Db::Command.new(options).branch_restore(branch_id, new_db_name)
120
+ end
121
+
122
+ desc "download ID", "Download a database branch dump"
123
+ option :path, aliases: "-p", desc: "Output file path (default: {branch_id}.sql)"
124
+ def download(branch_id)
125
+ require_relative "cli/db/command"
126
+ Nvoi::Cli::Db::Command.new(options).branch_download(branch_id)
127
+ end
128
+ }
129
+ }
189
130
  end
190
131
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module ConfigApi
5
+ module Actions
6
+ class SetApp < Base
7
+ protected
8
+
9
+ def mutate(data, name:, servers:, domain: nil, subdomain: nil, port: nil, command: nil, pre_run_command: nil, env: nil, mounts: nil)
10
+ raise ArgumentError, "name is required" if name.nil? || name.to_s.empty?
11
+ raise ArgumentError, "servers is required" if servers.nil? || servers.empty?
12
+ raise ArgumentError, "servers must be an array" unless servers.is_a?(Array)
13
+
14
+ validate_server_refs(data, servers)
15
+
16
+ app(data)["app"] ||= {}
17
+ app(data)["app"][name.to_s] = {
18
+ "servers" => servers.map(&:to_s),
19
+ "domain" => domain,
20
+ "subdomain" => subdomain,
21
+ "port" => port,
22
+ "command" => command,
23
+ "pre_run_command" => pre_run_command,
24
+ "env" => env,
25
+ "mounts" => mounts
26
+ }.compact
27
+ end
28
+
29
+ private
30
+
31
+ def validate_server_refs(data, servers)
32
+ defined = (app(data)["servers"] || {}).keys
33
+ servers.each do |ref|
34
+ raise Errors::ConfigValidationError, "server '#{ref}' not found" unless defined.include?(ref.to_s)
35
+ end
36
+ end
37
+ end
38
+
39
+ class DeleteApp < Base
40
+ protected
41
+
42
+ def mutate(data, name:)
43
+ raise ArgumentError, "name is required" if name.nil? || name.to_s.empty?
44
+
45
+ apps = app(data)["app"] || {}
46
+ raise Errors::ConfigValidationError, "app '#{name}' not found" unless apps.key?(name.to_s)
47
+
48
+ apps.delete(name.to_s)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module ConfigApi
5
+ module Actions
6
+ class SetComputeProvider < Base
7
+ PROVIDERS = %w[hetzner aws scaleway].freeze
8
+
9
+ protected
10
+
11
+ def mutate(data, provider:, **opts)
12
+ raise ArgumentError, "provider is required" if provider.nil? || provider.to_s.empty?
13
+ raise ArgumentError, "provider must be one of: #{PROVIDERS.join(', ')}" unless PROVIDERS.include?(provider.to_s)
14
+
15
+ app(data)["compute_provider"] = { provider.to_s => build_config(provider.to_s, opts) }
16
+ end
17
+
18
+ private
19
+
20
+ def build_config(provider, opts)
21
+ case provider
22
+ when "hetzner"
23
+ {
24
+ "api_token" => opts[:api_token],
25
+ "server_type" => opts[:server_type],
26
+ "server_location" => opts[:server_location]
27
+ }.compact
28
+ when "aws"
29
+ {
30
+ "access_key_id" => opts[:access_key_id],
31
+ "secret_access_key" => opts[:secret_access_key],
32
+ "region" => opts[:region],
33
+ "instance_type" => opts[:instance_type]
34
+ }.compact
35
+ when "scaleway"
36
+ {
37
+ "secret_key" => opts[:secret_key],
38
+ "project_id" => opts[:project_id],
39
+ "zone" => opts[:zone],
40
+ "server_type" => opts[:server_type]
41
+ }.compact
42
+ end
43
+ end
44
+ end
45
+
46
+ class DeleteComputeProvider < Base
47
+ protected
48
+
49
+ def mutate(data, **)
50
+ app(data)["compute_provider"] = {}
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module ConfigApi
5
+ module Actions
6
+ class SetDatabase < Base
7
+ ADAPTERS = %w[postgres postgresql mysql sqlite sqlite3].freeze
8
+
9
+ protected
10
+
11
+ def mutate(data, servers:, adapter:, image: nil, url: nil, user: nil, password: nil, database: nil, mount: nil, path: nil)
12
+ raise ArgumentError, "servers is required" if servers.nil? || servers.empty?
13
+ raise ArgumentError, "servers must be an array" unless servers.is_a?(Array)
14
+ raise ArgumentError, "adapter is required" if adapter.nil? || adapter.to_s.empty?
15
+ raise ArgumentError, "adapter must be one of: #{ADAPTERS.join(', ')}" unless ADAPTERS.include?(adapter.to_s.downcase)
16
+
17
+ validate_server_refs(data, servers)
18
+
19
+ secrets = build_secrets(adapter, user, password, database)
20
+
21
+ app(data)["database"] = {
22
+ "servers" => servers.map(&:to_s),
23
+ "adapter" => adapter.to_s,
24
+ "image" => image,
25
+ "url" => url,
26
+ "secrets" => secrets.empty? ? nil : secrets,
27
+ "mount" => mount,
28
+ "path" => path
29
+ }.compact
30
+ end
31
+
32
+ private
33
+
34
+ def validate_server_refs(data, servers)
35
+ defined = (app(data)["servers"] || {}).keys
36
+ servers.each do |ref|
37
+ raise Errors::ConfigValidationError, "server '#{ref}' not found" unless defined.include?(ref.to_s)
38
+ end
39
+ end
40
+
41
+ def build_secrets(adapter, user, password, database)
42
+ case adapter.to_s.downcase
43
+ when "postgres", "postgresql"
44
+ {
45
+ "POSTGRES_USER" => user,
46
+ "POSTGRES_PASSWORD" => password,
47
+ "POSTGRES_DB" => database
48
+ }.compact
49
+ when "mysql"
50
+ {
51
+ "MYSQL_USER" => user,
52
+ "MYSQL_PASSWORD" => password,
53
+ "MYSQL_DATABASE" => database
54
+ }.compact
55
+ else
56
+ {}
57
+ end
58
+ end
59
+ end
60
+
61
+ class DeleteDatabase < Base
62
+ protected
63
+
64
+ def mutate(data, **)
65
+ app(data).delete("database")
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module ConfigApi
5
+ module Actions
6
+ class SetEnv < Base
7
+ protected
8
+
9
+ def mutate(data, key:, value:)
10
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
11
+ raise ArgumentError, "value is required" if value.nil?
12
+
13
+ app(data)["env"] ||= {}
14
+ app(data)["env"][key.to_s] = value.to_s
15
+ end
16
+ end
17
+
18
+ class DeleteEnv < Base
19
+ protected
20
+
21
+ def mutate(data, key:)
22
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
23
+
24
+ env = app(data)["env"] || {}
25
+ raise Errors::ConfigValidationError, "env '#{key}' not found" unless env.key?(key.to_s)
26
+
27
+ env.delete(key.to_s)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module ConfigApi
5
+ module Actions
6
+ class SetSecret < Base
7
+ protected
8
+
9
+ def mutate(data, key:, value:)
10
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
11
+ raise ArgumentError, "value is required" if value.nil?
12
+
13
+ app(data)["secrets"] ||= {}
14
+ app(data)["secrets"][key.to_s] = value.to_s
15
+ end
16
+ end
17
+
18
+ class DeleteSecret < Base
19
+ protected
20
+
21
+ def mutate(data, key:)
22
+ raise ArgumentError, "key is required" if key.nil? || key.to_s.empty?
23
+
24
+ secrets = app(data)["secrets"] || {}
25
+ raise Errors::ConfigValidationError, "secret '#{key}' not found" unless secrets.key?(key.to_s)
26
+
27
+ secrets.delete(key.to_s)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end