nvoi 0.1.5 → 0.1.7
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/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/12-cli-db-command.md +128 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
- data/.claude/todo/refactor-execution/01-objects.md +42 -0
- data/.claude/todo/refactor-execution/02-utils.md +41 -0
- data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
- data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
- data/.claude/todo/refactor-execution/05-external-other.md +46 -0
- data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
- data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
- data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
- data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
- data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
- data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
- data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/.claude/todos.md +550 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +46 -5
- data/Rakefile +1 -1
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/ingest +0 -0
- data/lib/nvoi/cli/config/command.rb +219 -0
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli/logs/command.rb +66 -0
- data/lib/nvoi/cli/onboard/command.rb +761 -0
- data/lib/nvoi/cli/unlock/command.rb +72 -0
- data/lib/nvoi/cli.rb +339 -141
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/init.rb +67 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/service.rb +52 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +38 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +93 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +450 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +402 -0
- data/lib/nvoi/external/cloud/scaleway.rb +559 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +86 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +310 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +483 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +27 -55
- data/templates/app-ingress.yaml.erb +3 -1
- data/templates/error-backend.yaml.erb +134 -0
- metadata +121 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- data/lib/nvoi/steps/volume_provisioner.rb +0 -154
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
module Database
|
|
6
|
+
# MySQL provider using mysqldump/mysql via kubectl exec
|
|
7
|
+
class Mysql < Provider
|
|
8
|
+
def default_port
|
|
9
|
+
"3306"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse_url(url)
|
|
13
|
+
parse_standard_url(url, default_port)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build_url(creds, host: nil)
|
|
17
|
+
h = host || creds.host
|
|
18
|
+
"mysql://#{creds.user}:#{creds.password}@#{h}:#{creds.port}/#{creds.database}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def container_env(creds)
|
|
22
|
+
{
|
|
23
|
+
"MYSQL_USER" => creds.user,
|
|
24
|
+
"MYSQL_PASSWORD" => creds.password,
|
|
25
|
+
"MYSQL_DATABASE" => creds.database,
|
|
26
|
+
"MYSQL_ROOT_PASSWORD" => creds.password
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def app_env(creds, host:)
|
|
31
|
+
{
|
|
32
|
+
"DATABASE_URL" => build_url(creds, host:),
|
|
33
|
+
"MYSQL_HOST" => host,
|
|
34
|
+
"MYSQL_PORT" => creds.port,
|
|
35
|
+
"MYSQL_USER" => creds.user,
|
|
36
|
+
"MYSQL_PASSWORD" => creds.password,
|
|
37
|
+
"MYSQL_DATABASE" => creds.database
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dump(ssh, opts)
|
|
42
|
+
cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
43
|
+
"mysqldump -u #{opts.user} -p#{opts.password} #{opts.database} " \
|
|
44
|
+
"--single-transaction --routines --triggers"
|
|
45
|
+
ssh.execute(cmd)
|
|
46
|
+
rescue Errors::SshCommandError => e
|
|
47
|
+
raise Errors::DatabaseError.new("dump", "mysqldump failed: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def restore(ssh, data, opts)
|
|
51
|
+
create_database(ssh, Objects::Database::CreateOptions.new(
|
|
52
|
+
pod_name: opts.pod_name,
|
|
53
|
+
database: opts.database,
|
|
54
|
+
user: opts.user,
|
|
55
|
+
password: opts.password
|
|
56
|
+
))
|
|
57
|
+
|
|
58
|
+
temp_file = "/tmp/restore_#{opts.database}.sql"
|
|
59
|
+
write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
|
|
60
|
+
ssh.execute(write_cmd)
|
|
61
|
+
|
|
62
|
+
ssh.execute("kubectl cp #{temp_file} default/#{opts.pod_name}:#{temp_file}")
|
|
63
|
+
|
|
64
|
+
restore_cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
65
|
+
"sh -c 'mysql -u #{opts.user} -p#{opts.password} #{opts.database} < #{temp_file}'"
|
|
66
|
+
ssh.execute(restore_cmd)
|
|
67
|
+
|
|
68
|
+
ssh.execute_ignore_errors("rm -f #{temp_file}")
|
|
69
|
+
ssh.execute_ignore_errors("kubectl exec -n default #{opts.pod_name} -- rm -f #{temp_file}")
|
|
70
|
+
rescue Errors::SshCommandError => e
|
|
71
|
+
raise Errors::DatabaseError.new("restore", "mysql restore failed: #{e.message}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_database(ssh, opts)
|
|
75
|
+
cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
76
|
+
"mysql -u #{opts.user} -p#{opts.password} -e \"CREATE DATABASE #{opts.database}\""
|
|
77
|
+
ssh.execute(cmd)
|
|
78
|
+
rescue Errors::SshCommandError => e
|
|
79
|
+
raise Errors::DatabaseError.new("create_database", "failed to create database: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
module Database
|
|
6
|
+
# PostgreSQL provider using pg_dump/psql via kubectl exec
|
|
7
|
+
class Postgres < Provider
|
|
8
|
+
def default_port
|
|
9
|
+
"5432"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse_url(url)
|
|
13
|
+
parse_standard_url(url, default_port)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build_url(creds, host: nil)
|
|
17
|
+
h = host || creds.host
|
|
18
|
+
"postgresql://#{creds.user}:#{creds.password}@#{h}:#{creds.port}/#{creds.database}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def container_env(creds)
|
|
22
|
+
{
|
|
23
|
+
"POSTGRES_USER" => creds.user,
|
|
24
|
+
"POSTGRES_PASSWORD" => creds.password,
|
|
25
|
+
"POSTGRES_DB" => creds.database
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def app_env(creds, host:)
|
|
30
|
+
{
|
|
31
|
+
"DATABASE_URL" => build_url(creds, host:),
|
|
32
|
+
"POSTGRES_HOST" => host,
|
|
33
|
+
"POSTGRES_PORT" => creds.port,
|
|
34
|
+
"POSTGRES_USER" => creds.user,
|
|
35
|
+
"POSTGRES_PASSWORD" => creds.password,
|
|
36
|
+
"POSTGRES_DB" => creds.database
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dump(ssh, opts)
|
|
41
|
+
cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
42
|
+
"pg_dump -U #{opts.user} -d #{opts.database} --no-owner --no-acl"
|
|
43
|
+
ssh.execute(cmd)
|
|
44
|
+
rescue Errors::SshCommandError => e
|
|
45
|
+
raise Errors::DatabaseError.new("dump", "pg_dump failed: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def restore(ssh, data, opts)
|
|
49
|
+
create_database(ssh, Objects::Database::CreateOptions.new(
|
|
50
|
+
pod_name: opts.pod_name,
|
|
51
|
+
database: opts.database,
|
|
52
|
+
user: opts.user,
|
|
53
|
+
password: opts.password
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
temp_file = "/tmp/restore_#{opts.database}.sql"
|
|
57
|
+
write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
|
|
58
|
+
ssh.execute(write_cmd)
|
|
59
|
+
|
|
60
|
+
ssh.execute("kubectl cp #{temp_file} default/#{opts.pod_name}:#{temp_file}")
|
|
61
|
+
|
|
62
|
+
restore_cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
63
|
+
"psql -U #{opts.user} -d #{opts.database} -f #{temp_file}"
|
|
64
|
+
ssh.execute(restore_cmd)
|
|
65
|
+
|
|
66
|
+
ssh.execute_ignore_errors("rm -f #{temp_file}")
|
|
67
|
+
ssh.execute_ignore_errors("kubectl exec -n default #{opts.pod_name} -- rm -f #{temp_file}")
|
|
68
|
+
rescue Errors::SshCommandError => e
|
|
69
|
+
raise Errors::DatabaseError.new("restore", "psql restore failed: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_database(ssh, opts)
|
|
73
|
+
cmd = "kubectl exec -n default #{opts.pod_name} -- " \
|
|
74
|
+
"psql -U #{opts.user} -c \"CREATE DATABASE #{opts.database}\""
|
|
75
|
+
ssh.execute(cmd)
|
|
76
|
+
rescue Errors::SshCommandError => e
|
|
77
|
+
raise Errors::DatabaseError.new("create_database", "failed to create database: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
module Database
|
|
6
|
+
# Base provider interface for database backup/restore operations
|
|
7
|
+
class Provider
|
|
8
|
+
def parse_url(url)
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build_url(creds, host: nil)
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def container_env(creds)
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def app_env(creds, host:)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dump(ssh, opts)
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def restore(ssh, data, opts)
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_database(ssh, opts)
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extension
|
|
37
|
+
"sql"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def needs_container?
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_port
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
protected
|
|
49
|
+
|
|
50
|
+
def parse_standard_url(url, default_port)
|
|
51
|
+
uri = URI.parse(url)
|
|
52
|
+
Objects::Database::Credentials.new(
|
|
53
|
+
user: uri.user,
|
|
54
|
+
password: uri.password,
|
|
55
|
+
host: uri.host,
|
|
56
|
+
port: (uri.port || default_port).to_s,
|
|
57
|
+
database: uri.path&.sub(%r{^/}, "")
|
|
58
|
+
)
|
|
59
|
+
rescue URI::InvalidURIError => e
|
|
60
|
+
raise Errors::DatabaseError.new("parse_url", "invalid URL format: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
module Database
|
|
6
|
+
# SQLite provider using direct SSH file access on hostPath volume
|
|
7
|
+
class Sqlite < Provider
|
|
8
|
+
def default_port
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def needs_container?
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse_url(url)
|
|
17
|
+
path = url.sub(%r{^sqlite3?:///?}, "")
|
|
18
|
+
Objects::Database::Credentials.new(
|
|
19
|
+
path:,
|
|
20
|
+
database: File.basename(path)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_url(creds, host: nil)
|
|
25
|
+
"sqlite://#{creds.path}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def container_env(_creds)
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def app_env(creds, host: nil)
|
|
33
|
+
{
|
|
34
|
+
"DATABASE_URL" => build_url(creds)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dump(ssh, opts)
|
|
39
|
+
db_path = opts.host_path
|
|
40
|
+
raise Errors::DatabaseError.new("dump", "host_path required for SQLite dump") unless db_path
|
|
41
|
+
|
|
42
|
+
ssh.execute("sqlite3 #{db_path} .dump")
|
|
43
|
+
rescue Errors::SshCommandError => e
|
|
44
|
+
raise Errors::DatabaseError.new("dump", "sqlite3 dump failed: #{e.message}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def restore(ssh, data, opts)
|
|
48
|
+
db_path = opts.host_path
|
|
49
|
+
raise Errors::DatabaseError.new("restore", "host_path required for SQLite restore") unless db_path
|
|
50
|
+
|
|
51
|
+
dir = File.dirname(db_path)
|
|
52
|
+
new_db_path = "#{dir}/#{opts.database}.sqlite3"
|
|
53
|
+
|
|
54
|
+
temp_file = "/tmp/restore_#{opts.database}.sql"
|
|
55
|
+
write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
|
|
56
|
+
ssh.execute(write_cmd)
|
|
57
|
+
|
|
58
|
+
ssh.execute("sqlite3 #{new_db_path} < #{temp_file}")
|
|
59
|
+
ssh.execute_ignore_errors("rm -f #{temp_file}")
|
|
60
|
+
|
|
61
|
+
new_db_path
|
|
62
|
+
rescue Errors::SshCommandError => e
|
|
63
|
+
raise Errors::DatabaseError.new("restore", "sqlite3 restore failed: #{e.message}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def create_database(_ssh, _opts)
|
|
67
|
+
# No-op for SQLite
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
# Database module provides database backup/restore operations
|
|
6
|
+
module Database
|
|
7
|
+
# Factory method to create provider by adapter name
|
|
8
|
+
def self.provider_for(adapter)
|
|
9
|
+
case adapter&.downcase
|
|
10
|
+
when "postgres", "postgresql"
|
|
11
|
+
Postgres.new
|
|
12
|
+
when "mysql", "mysql2"
|
|
13
|
+
Mysql.new
|
|
14
|
+
when "sqlite", "sqlite3"
|
|
15
|
+
Sqlite.new
|
|
16
|
+
else
|
|
17
|
+
raise ArgumentError, "Unsupported database adapter: #{adapter}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Nvoi
|
|
8
|
+
module External
|
|
9
|
+
module Dns
|
|
10
|
+
# Cloudflare handles Cloudflare API operations for DNS and tunnels
|
|
11
|
+
class Cloudflare
|
|
12
|
+
BASE_URL = "https://api.cloudflare.com/client/v4"
|
|
13
|
+
|
|
14
|
+
attr_reader :account_id
|
|
15
|
+
|
|
16
|
+
def initialize(token, account_id)
|
|
17
|
+
@token = token
|
|
18
|
+
@account_id = account_id
|
|
19
|
+
@conn = Faraday.new(url: BASE_URL) do |f|
|
|
20
|
+
f.request :json
|
|
21
|
+
f.response :json
|
|
22
|
+
f.adapter Faraday.default_adapter
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Tunnel operations
|
|
27
|
+
|
|
28
|
+
def create_tunnel(name)
|
|
29
|
+
url = "accounts/#{@account_id}/cfd_tunnel"
|
|
30
|
+
tunnel_secret = generate_tunnel_secret
|
|
31
|
+
|
|
32
|
+
response = post(url, {
|
|
33
|
+
name:,
|
|
34
|
+
tunnel_secret:,
|
|
35
|
+
config_src: "cloudflare"
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
result = response["result"]
|
|
39
|
+
Objects::Tunnel::Record.new(
|
|
40
|
+
id: result["id"],
|
|
41
|
+
name: result["name"],
|
|
42
|
+
token: result["token"]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find_tunnel(name)
|
|
47
|
+
url = "accounts/#{@account_id}/cfd_tunnel"
|
|
48
|
+
response = get(url, { name:, is_deleted: false })
|
|
49
|
+
|
|
50
|
+
results = response["result"]
|
|
51
|
+
return nil if results.nil? || results.empty?
|
|
52
|
+
|
|
53
|
+
result = results[0]
|
|
54
|
+
Objects::Tunnel::Record.new(
|
|
55
|
+
id: result["id"],
|
|
56
|
+
name: result["name"],
|
|
57
|
+
token: result["token"]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_tunnel_token(tunnel_id)
|
|
62
|
+
url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/token"
|
|
63
|
+
response = get(url)
|
|
64
|
+
response["result"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_tunnel_configuration(tunnel_id, hostnames, service_url)
|
|
68
|
+
hostnames = Array(hostnames)
|
|
69
|
+
url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
|
|
70
|
+
|
|
71
|
+
ingress_rules = hostnames.map do |hostname|
|
|
72
|
+
{
|
|
73
|
+
hostname:,
|
|
74
|
+
service: service_url,
|
|
75
|
+
originRequest: { httpHostHeader: hostname.sub(/^\*\./, "") } # Use apex for wildcard
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
ingress_rules << { service: "http_status:404" }
|
|
79
|
+
|
|
80
|
+
config = { ingress: ingress_rules }
|
|
81
|
+
put(url, { config: })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verify_tunnel_configuration(tunnel_id, expected_hostnames, expected_service, max_attempts)
|
|
85
|
+
expected_hostnames = Array(expected_hostnames)
|
|
86
|
+
url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
|
|
87
|
+
|
|
88
|
+
max_attempts.times do
|
|
89
|
+
begin
|
|
90
|
+
response = get(url)
|
|
91
|
+
|
|
92
|
+
if response["success"]
|
|
93
|
+
config = response.dig("result", "config")
|
|
94
|
+
ingress = config&.dig("ingress") || []
|
|
95
|
+
configured_hostnames = ingress.map { |r| r["hostname"] }.compact
|
|
96
|
+
|
|
97
|
+
if expected_hostnames.all? { |h| configured_hostnames.include?(h) }
|
|
98
|
+
return true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError
|
|
102
|
+
# Continue retrying
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sleep(2)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise Errors::TunnelError, "tunnel configuration not propagated after #{max_attempts} attempts"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def delete_tunnel(tunnel_id)
|
|
112
|
+
# Clean up all connections first
|
|
113
|
+
connections_url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/connections"
|
|
114
|
+
begin
|
|
115
|
+
delete(connections_url)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
# Ignore connection cleanup errors
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Now delete the tunnel
|
|
121
|
+
url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}"
|
|
122
|
+
delete(url)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# DNS operations
|
|
126
|
+
|
|
127
|
+
def list_zones
|
|
128
|
+
url = "zones"
|
|
129
|
+
response = get(url)
|
|
130
|
+
|
|
131
|
+
results = response["result"] || []
|
|
132
|
+
results.map do |z|
|
|
133
|
+
{ id: z["id"], name: z["name"], status: z["status"] }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_zone(domain)
|
|
138
|
+
url = "zones"
|
|
139
|
+
response = get(url)
|
|
140
|
+
|
|
141
|
+
results = response["result"]
|
|
142
|
+
return nil unless results
|
|
143
|
+
|
|
144
|
+
zone_data = results.find { |z| z["name"] == domain }
|
|
145
|
+
return nil unless zone_data
|
|
146
|
+
|
|
147
|
+
Objects::Dns::Zone.new(id: zone_data["id"], name: zone_data["name"])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def subdomain_available?(zone_id, subdomain, domain)
|
|
151
|
+
fqdn = subdomain.empty? ? domain : "#{subdomain}.#{domain}"
|
|
152
|
+
# Check for CNAME or A record
|
|
153
|
+
!find_dns_record(zone_id, fqdn, "CNAME") && !find_dns_record(zone_id, fqdn, "A")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def find_dns_record(zone_id, name, record_type)
|
|
157
|
+
url = "zones/#{zone_id}/dns_records"
|
|
158
|
+
response = get(url)
|
|
159
|
+
|
|
160
|
+
results = response["result"]
|
|
161
|
+
return nil unless results
|
|
162
|
+
|
|
163
|
+
record_data = results.find { |r| r["name"] == name && r["type"] == record_type }
|
|
164
|
+
return nil unless record_data
|
|
165
|
+
|
|
166
|
+
Objects::Dns::Record.new(
|
|
167
|
+
id: record_data["id"],
|
|
168
|
+
type: record_data["type"],
|
|
169
|
+
name: record_data["name"],
|
|
170
|
+
content: record_data["content"],
|
|
171
|
+
proxied: record_data["proxied"],
|
|
172
|
+
ttl: record_data["ttl"]
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def create_dns_record(zone_id, name, record_type, content, proxied: true)
|
|
177
|
+
url = "zones/#{zone_id}/dns_records"
|
|
178
|
+
|
|
179
|
+
response = post(url, {
|
|
180
|
+
type: record_type,
|
|
181
|
+
name:,
|
|
182
|
+
content:,
|
|
183
|
+
proxied:,
|
|
184
|
+
ttl: 1
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
result = response["result"]
|
|
188
|
+
Objects::Dns::Record.new(
|
|
189
|
+
id: result["id"],
|
|
190
|
+
type: result["type"],
|
|
191
|
+
name: result["name"],
|
|
192
|
+
content: result["content"],
|
|
193
|
+
proxied: result["proxied"],
|
|
194
|
+
ttl: result["ttl"]
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def update_dns_record(zone_id, record_id, name, record_type, content, proxied: true)
|
|
199
|
+
url = "zones/#{zone_id}/dns_records/#{record_id}"
|
|
200
|
+
|
|
201
|
+
response = patch(url, {
|
|
202
|
+
type: record_type,
|
|
203
|
+
name:,
|
|
204
|
+
content:,
|
|
205
|
+
proxied:,
|
|
206
|
+
ttl: 1
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
result = response["result"]
|
|
210
|
+
Objects::Dns::Record.new(
|
|
211
|
+
id: result["id"],
|
|
212
|
+
type: result["type"],
|
|
213
|
+
name: result["name"],
|
|
214
|
+
content: result["content"],
|
|
215
|
+
proxied: result["proxied"],
|
|
216
|
+
ttl: result["ttl"]
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def create_or_update_dns_record(zone_id, name, record_type, content, proxied: true)
|
|
221
|
+
existing = find_dns_record(zone_id, name, record_type)
|
|
222
|
+
|
|
223
|
+
if existing
|
|
224
|
+
update_dns_record(zone_id, existing.id, name, record_type, content, proxied:)
|
|
225
|
+
else
|
|
226
|
+
create_dns_record(zone_id, name, record_type, content, proxied:)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def delete_dns_record(zone_id, record_id)
|
|
231
|
+
url = "zones/#{zone_id}/dns_records/#{record_id}"
|
|
232
|
+
delete(url)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Validation
|
|
236
|
+
|
|
237
|
+
def validate_credentials
|
|
238
|
+
get("user/tokens/verify")
|
|
239
|
+
true
|
|
240
|
+
rescue Errors::CloudflareError => e
|
|
241
|
+
raise Errors::ValidationError, "cloudflare credentials invalid: #{e.message}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def get(url, params = {})
|
|
247
|
+
response = @conn.get(url) do |req|
|
|
248
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
249
|
+
req.params = params unless params.empty?
|
|
250
|
+
end
|
|
251
|
+
handle_response(response)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def post(url, body)
|
|
255
|
+
response = @conn.post(url) do |req|
|
|
256
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
257
|
+
req.body = body
|
|
258
|
+
end
|
|
259
|
+
handle_response(response)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def put(url, body)
|
|
263
|
+
response = @conn.put(url) do |req|
|
|
264
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
265
|
+
req.body = body
|
|
266
|
+
end
|
|
267
|
+
handle_response(response)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def patch(url, body)
|
|
271
|
+
response = @conn.patch(url) do |req|
|
|
272
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
273
|
+
req.body = body
|
|
274
|
+
end
|
|
275
|
+
handle_response(response)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def delete(url)
|
|
279
|
+
response = @conn.delete(url) do |req|
|
|
280
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# 404 is ok for idempotent delete
|
|
284
|
+
return { "success" => true } if response.status == 404
|
|
285
|
+
|
|
286
|
+
handle_response(response)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def handle_response(response)
|
|
290
|
+
body = response.body
|
|
291
|
+
|
|
292
|
+
unless body.is_a?(Hash)
|
|
293
|
+
raise Errors::CloudflareError, "unexpected response format"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
unless body["success"]
|
|
297
|
+
errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "unknown error"
|
|
298
|
+
raise Errors::CloudflareError, "API error: #{errors}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
body
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def generate_tunnel_secret
|
|
305
|
+
Base64.strict_encode64(SecureRandom.random_bytes(32))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module External
|
|
5
|
+
# Kubectl handles kubernetes operations via kubectl on remote servers
|
|
6
|
+
class Kubectl
|
|
7
|
+
attr_reader :ssh
|
|
8
|
+
|
|
9
|
+
def initialize(ssh)
|
|
10
|
+
@ssh = ssh
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(manifest)
|
|
14
|
+
cmd = "cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF"
|
|
15
|
+
@ssh.execute(cmd)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete(resource_type, name, namespace: "default")
|
|
19
|
+
@ssh.execute("kubectl delete #{resource_type} #{name} -n #{namespace} --ignore-not-found")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get(resource_type, name, namespace: "default", jsonpath: nil)
|
|
23
|
+
cmd = "kubectl get #{resource_type} #{name} -n #{namespace}"
|
|
24
|
+
cmd += " -o jsonpath='#{jsonpath}'" if jsonpath
|
|
25
|
+
@ssh.execute(cmd)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exec(pod_name, command, namespace: "default")
|
|
29
|
+
@ssh.execute("kubectl exec -n #{namespace} #{pod_name} -- #{command}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def logs(pod_name, namespace: "default", tail: nil)
|
|
33
|
+
cmd = "kubectl logs #{pod_name} -n #{namespace}"
|
|
34
|
+
cmd += " --tail=#{tail}" if tail
|
|
35
|
+
@ssh.execute(cmd)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rollout_status(resource_type, name, namespace: "default", timeout: 300)
|
|
39
|
+
@ssh.execute("kubectl rollout status #{resource_type}/#{name} -n #{namespace} --timeout=#{timeout}s")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def wait_for_deployment(name, namespace: "default", timeout: 300)
|
|
43
|
+
rollout_status("deployment", name, namespace:, timeout:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def wait_for_statefulset(name, namespace: "default", timeout: 300)
|
|
47
|
+
rollout_status("statefulset", name, namespace:, timeout:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def label_node(node_name, labels)
|
|
51
|
+
labels.each do |key, value|
|
|
52
|
+
@ssh.execute("kubectl label node #{node_name} #{key}=#{value} --overwrite")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_nodes
|
|
57
|
+
@ssh.execute("kubectl get nodes -o name")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cp(local_path, pod_path, namespace: "default")
|
|
61
|
+
@ssh.execute("kubectl cp #{local_path} #{namespace}/#{pod_path}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|