nvoi 0.1.6 → 0.1.8
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/12-cli-db-command.md +128 -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/todos/buckets.md +41 -0
- data/.claude/todos.md +550 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +35 -4
- data/Rakefile +1 -1
- data/ingest +0 -0
- data/lib/nvoi/cli/config/command.rb +219 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +12 -11
- data/lib/nvoi/cli/deploy/command.rb +27 -11
- data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +15 -13
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +8 -15
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +10 -1
- 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 +257 -0
- data/lib/nvoi/config_api/actions/app.rb +30 -30
- data/lib/nvoi/config_api/actions/compute_provider.rb +31 -31
- data/lib/nvoi/config_api/actions/database.rb +42 -42
- data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
- data/lib/nvoi/config_api/actions/env.rb +12 -12
- data/lib/nvoi/config_api/actions/init.rb +67 -0
- data/lib/nvoi/config_api/actions/secret.rb +12 -12
- data/lib/nvoi/config_api/actions/server.rb +35 -35
- data/lib/nvoi/config_api/actions/service.rb +52 -0
- data/lib/nvoi/config_api/actions/volume.rb +18 -18
- data/lib/nvoi/config_api/base.rb +15 -21
- data/lib/nvoi/config_api/result.rb +5 -5
- data/lib/nvoi/config_api.rb +51 -28
- data/lib/nvoi/external/cloud/aws.rb +26 -1
- data/lib/nvoi/external/cloud/hetzner.rb +27 -1
- data/lib/nvoi/external/cloud/scaleway.rb +32 -6
- data/lib/nvoi/external/containerd.rb +1 -44
- data/lib/nvoi/external/dns/cloudflare.rb +34 -16
- data/lib/nvoi/external/ssh.rb +0 -12
- data/lib/nvoi/external/ssh_tunnel.rb +100 -0
- data/lib/nvoi/objects/configuration.rb +20 -0
- data/lib/nvoi/utils/namer.rb +9 -0
- data/lib/nvoi/utils/retry.rb +1 -1
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +16 -0
- data/templates/app-ingress.yaml.erb +3 -1
- metadata +27 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Config
|
|
6
|
+
# Command helper for all config operations
|
|
7
|
+
# Uses CredentialStore for crypto, ConfigApi for transformations
|
|
8
|
+
class Command
|
|
9
|
+
def initialize(options)
|
|
10
|
+
@options = options
|
|
11
|
+
@working_dir = options[:dir] || "."
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Initialize new config
|
|
15
|
+
def init(name, environment)
|
|
16
|
+
result = ConfigApi.init(name:, environment:)
|
|
17
|
+
|
|
18
|
+
if result.failure?
|
|
19
|
+
error("Failed to initialize: #{result.error_message}")
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Write encrypted config
|
|
24
|
+
config_path = File.join(@working_dir, Utils::DEFAULT_ENCRYPTED_FILE)
|
|
25
|
+
key_path = File.join(@working_dir, Utils::DEFAULT_KEY_FILE)
|
|
26
|
+
|
|
27
|
+
File.binwrite(config_path, result.config)
|
|
28
|
+
File.write(key_path, "#{result.master_key}\n", perm: 0o600)
|
|
29
|
+
|
|
30
|
+
update_gitignore
|
|
31
|
+
|
|
32
|
+
success("Created #{Utils::DEFAULT_ENCRYPTED_FILE}")
|
|
33
|
+
success("Created #{Utils::DEFAULT_KEY_FILE} (keep safe, never commit)")
|
|
34
|
+
puts
|
|
35
|
+
puts "Next steps:"
|
|
36
|
+
puts " nvoi config domain set cloudflare --api-token=TOKEN --account-id=ID"
|
|
37
|
+
puts " nvoi config provider set hetzner --api-token=TOKEN --server-type=cx22 --location=fsn1"
|
|
38
|
+
puts " nvoi config server set web --master"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Domain provider
|
|
42
|
+
def domain_set(provider, api_token:, account_id:)
|
|
43
|
+
with_config do |data|
|
|
44
|
+
ConfigApi.set_domain_provider(data, provider:, api_token:, account_id:)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def domain_rm
|
|
49
|
+
with_config do |data|
|
|
50
|
+
ConfigApi.delete_domain_provider(data)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compute provider
|
|
55
|
+
def provider_set(provider, **opts)
|
|
56
|
+
with_config do |data|
|
|
57
|
+
ConfigApi.set_compute_provider(data, provider:, **opts)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def provider_rm
|
|
62
|
+
with_config do |data|
|
|
63
|
+
ConfigApi.delete_compute_provider(data)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Server
|
|
68
|
+
def server_set(name, master: false, type: nil, location: nil, count: 1)
|
|
69
|
+
with_config do |data|
|
|
70
|
+
ConfigApi.set_server(data, name:, master:, type:, location:, count:)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def server_rm(name)
|
|
75
|
+
with_config do |data|
|
|
76
|
+
ConfigApi.delete_server(data, name:)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Volume
|
|
81
|
+
def volume_set(server, name, size: 10)
|
|
82
|
+
with_config do |data|
|
|
83
|
+
ConfigApi.set_volume(data, server:, name:, size:)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def volume_rm(server, name)
|
|
88
|
+
with_config do |data|
|
|
89
|
+
ConfigApi.delete_volume(data, server:, name:)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# App
|
|
94
|
+
def app_set(name, servers:, **opts)
|
|
95
|
+
with_config do |data|
|
|
96
|
+
ConfigApi.set_app(data, name:, servers:, **opts)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def app_rm(name)
|
|
101
|
+
with_config do |data|
|
|
102
|
+
ConfigApi.delete_app(data, name:)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Database
|
|
107
|
+
def database_set(servers:, adapter:, **opts)
|
|
108
|
+
with_config do |data|
|
|
109
|
+
ConfigApi.set_database(data, servers:, adapter:, **opts)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def database_rm
|
|
114
|
+
with_config do |data|
|
|
115
|
+
ConfigApi.delete_database(data)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Service
|
|
120
|
+
def service_set(name, servers:, image:, **opts)
|
|
121
|
+
with_config do |data|
|
|
122
|
+
ConfigApi.set_service(data, name:, servers:, image:, **opts)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def service_rm(name)
|
|
127
|
+
with_config do |data|
|
|
128
|
+
ConfigApi.delete_service(data, name:)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Secret
|
|
133
|
+
def secret_set(key_name, value)
|
|
134
|
+
with_config do |data|
|
|
135
|
+
ConfigApi.set_secret(data, key: key_name, value:)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def secret_rm(key_name)
|
|
140
|
+
with_config do |data|
|
|
141
|
+
ConfigApi.delete_secret(data, key: key_name)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Env
|
|
146
|
+
def env_set(key_name, value)
|
|
147
|
+
with_config do |data|
|
|
148
|
+
ConfigApi.set_env(data, key: key_name, value:)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def env_rm(key_name)
|
|
153
|
+
with_config do |data|
|
|
154
|
+
ConfigApi.delete_env(data, key: key_name)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def with_config
|
|
161
|
+
store = Utils::CredentialStore.new(
|
|
162
|
+
@working_dir,
|
|
163
|
+
@options[:credentials],
|
|
164
|
+
@options[:master_key]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
unless store.exists?
|
|
168
|
+
error("Config not found: #{store.encrypted_path}")
|
|
169
|
+
error("Run 'nvoi config init --name=myapp' first")
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Read and parse
|
|
174
|
+
yaml = store.read
|
|
175
|
+
data = YAML.safe_load(yaml, permitted_classes: [Symbol])
|
|
176
|
+
|
|
177
|
+
# Transform
|
|
178
|
+
result = yield(data)
|
|
179
|
+
|
|
180
|
+
if result.failure?
|
|
181
|
+
error("#{result.error_type}: #{result.error_message}")
|
|
182
|
+
else
|
|
183
|
+
# Serialize and write
|
|
184
|
+
store.write(YAML.dump(result.data))
|
|
185
|
+
success("Config updated")
|
|
186
|
+
end
|
|
187
|
+
rescue Errors::CredentialError => e
|
|
188
|
+
error(e.message)
|
|
189
|
+
rescue Errors::DecryptionError => e
|
|
190
|
+
error("Decryption failed: #{e.message}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def update_gitignore
|
|
194
|
+
gitignore_path = File.join(@working_dir, ".gitignore")
|
|
195
|
+
entries = ["deploy.key", ".env", ".env.*", "!.env.example", "!.env.*.example"]
|
|
196
|
+
|
|
197
|
+
existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
|
198
|
+
additions = entries.reject { |e| existing.include?(e) }
|
|
199
|
+
|
|
200
|
+
return if additions.empty?
|
|
201
|
+
|
|
202
|
+
File.open(gitignore_path, "a") do |f|
|
|
203
|
+
f.puts "" unless existing.end_with?("\n") || existing.empty?
|
|
204
|
+
f.puts "# NVOI - sensitive files"
|
|
205
|
+
additions.each { |e| f.puts e }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def success(msg)
|
|
210
|
+
puts "\e[32m✓\e[0m #{msg}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def error(msg)
|
|
214
|
+
warn "\e[31m✗\e[0m #{msg}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -15,18 +15,15 @@ module Nvoi
|
|
|
15
15
|
def run
|
|
16
16
|
@config.deploy.application.app.each do |_service_name, service|
|
|
17
17
|
next unless service&.domain && !service.domain.empty?
|
|
18
|
-
next if service.subdomain.nil?
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
delete_dns_records(service.domain, service.subdomain)
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
private
|
|
25
24
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@log.info "Deleting DNS record: %s", hostname
|
|
25
|
+
def delete_dns_records(domain, subdomain)
|
|
26
|
+
hostnames = Utils::Namer.build_hostnames(subdomain, domain)
|
|
30
27
|
|
|
31
28
|
zone = @cf_client.find_zone(domain)
|
|
32
29
|
unless zone
|
|
@@ -34,13 +31,17 @@ module Nvoi
|
|
|
34
31
|
return
|
|
35
32
|
end
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
hostnames.each do |hostname|
|
|
35
|
+
@log.info "Deleting DNS record: %s", hostname
|
|
36
|
+
|
|
37
|
+
record = @cf_client.find_dns_record(zone.id, hostname, "CNAME")
|
|
38
|
+
if record
|
|
39
|
+
@cf_client.delete_dns_record(zone.id, record.id)
|
|
40
|
+
@log.success "DNS record deleted: %s", hostname
|
|
41
|
+
end
|
|
41
42
|
end
|
|
42
43
|
rescue StandardError => e
|
|
43
|
-
@log.warning "Failed to delete DNS
|
|
44
|
+
@log.warning "Failed to delete DNS records: %s", e.message
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
end
|
|
@@ -123,24 +123,40 @@ module Nvoi
|
|
|
123
123
|
require_relative "steps/cleanup_images"
|
|
124
124
|
|
|
125
125
|
ssh = External::Ssh.new(server_ip, @config.ssh_key_path)
|
|
126
|
+
registry_port = Utils::Constants::REGISTRY_PORT
|
|
126
127
|
|
|
127
|
-
#
|
|
128
|
-
|
|
128
|
+
# Start SSH tunnel to registry
|
|
129
|
+
registry_tunnel = External::SshTunnel.new(
|
|
130
|
+
ip: server_ip,
|
|
131
|
+
user: "deploy",
|
|
132
|
+
key_path: @config.ssh_key_path,
|
|
133
|
+
local_port: registry_port,
|
|
134
|
+
remote_port: registry_port
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
registry_tunnel.start
|
|
129
138
|
|
|
130
139
|
begin
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
# Acquire deployment lock
|
|
141
|
+
acquire_lock(ssh)
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
# Build and push image via tunnel
|
|
145
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
146
|
+
image_tag = @config.namer.image_tag(timestamp)
|
|
134
147
|
|
|
135
|
-
|
|
148
|
+
registry_tag = Steps::BuildImage.new(@config, @log).run(working_dir, image_tag)
|
|
136
149
|
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
# Deploy all services (image already in registry)
|
|
151
|
+
Steps::DeployService.new(@config, ssh, tunnels, @log).run(registry_tag, timestamp)
|
|
139
152
|
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
# Cleanup old images
|
|
154
|
+
Steps::CleanupImages.new(@config, ssh, @log).run(timestamp)
|
|
155
|
+
ensure
|
|
156
|
+
release_lock(ssh)
|
|
157
|
+
end
|
|
142
158
|
ensure
|
|
143
|
-
|
|
159
|
+
registry_tunnel.stop
|
|
144
160
|
end
|
|
145
161
|
end
|
|
146
162
|
|
|
@@ -4,22 +4,64 @@ module Nvoi
|
|
|
4
4
|
class Cli
|
|
5
5
|
module Deploy
|
|
6
6
|
module Steps
|
|
7
|
-
# BuildImage handles Docker image building and pushing to
|
|
7
|
+
# BuildImage handles Docker image building and pushing to registry
|
|
8
8
|
class BuildImage
|
|
9
|
-
def initialize(config,
|
|
9
|
+
def initialize(config, log)
|
|
10
10
|
@config = config
|
|
11
|
-
@ssh = ssh
|
|
12
11
|
@log = log
|
|
13
12
|
end
|
|
14
13
|
|
|
14
|
+
# Build locally and push to registry via SSH tunnel
|
|
15
|
+
# Returns the registry tag for use in k8s deployments
|
|
15
16
|
def run(working_dir, image_tag)
|
|
16
17
|
@log.info "Building Docker image: %s", image_tag
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
build_image(working_dir, image_tag)
|
|
20
|
+
registry_tag = push_to_registry(image_tag)
|
|
20
21
|
|
|
21
|
-
@log.success "Image built: %s",
|
|
22
|
+
@log.success "Image built and pushed: %s", registry_tag
|
|
23
|
+
registry_tag
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_image(working_dir, tag)
|
|
29
|
+
cache_from = @config.namer.latest_image_tag
|
|
30
|
+
cache_args = "--cache-from #{cache_from}"
|
|
31
|
+
|
|
32
|
+
build_cmd = [
|
|
33
|
+
"cd #{working_dir} &&",
|
|
34
|
+
"DOCKER_BUILDKIT=1 docker build",
|
|
35
|
+
"--platform linux/amd64",
|
|
36
|
+
cache_args,
|
|
37
|
+
"--build-arg BUILDKIT_INLINE_CACHE=1",
|
|
38
|
+
"-t #{tag} ."
|
|
39
|
+
].join(" ")
|
|
40
|
+
|
|
41
|
+
unless system("bash", "-c", build_cmd)
|
|
42
|
+
raise Errors::SshError, "docker build failed"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Tag as :latest for next build's cache
|
|
46
|
+
system("docker", "tag", tag, cache_from)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def push_to_registry(tag)
|
|
50
|
+
registry_port = Utils::Constants::REGISTRY_PORT
|
|
51
|
+
registry_tag = "localhost:#{registry_port}/#{@config.container_prefix}:#{tag.split(':').last}"
|
|
52
|
+
|
|
53
|
+
@log.info "Tagging for registry: %s", registry_tag
|
|
54
|
+
unless system("docker", "tag", tag, registry_tag)
|
|
55
|
+
raise Errors::SshError, "docker tag failed"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@log.info "Pushing to registry via SSH tunnel..."
|
|
59
|
+
unless system("docker", "push", registry_tag)
|
|
60
|
+
raise Errors::SshError, "docker push failed - is the SSH tunnel active?"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
registry_tag
|
|
64
|
+
end
|
|
23
65
|
end
|
|
24
66
|
end
|
|
25
67
|
end
|
|
@@ -22,7 +22,6 @@ module Nvoi
|
|
|
22
22
|
@config.deploy.application.app.each do |service_name, service_config|
|
|
23
23
|
next unless service_config.domain && !service_config.domain.empty?
|
|
24
24
|
next unless service_config.port && service_config.port.positive?
|
|
25
|
-
next if service_config.subdomain.nil?
|
|
26
25
|
|
|
27
26
|
tunnel_info = configure_service_tunnel(service_name, service_config)
|
|
28
27
|
tunnels << tunnel_info
|
|
@@ -36,23 +35,24 @@ module Nvoi
|
|
|
36
35
|
|
|
37
36
|
def configure_service_tunnel(service_name, service_config)
|
|
38
37
|
tunnel_name = @config.namer.tunnel_name(service_name)
|
|
39
|
-
|
|
38
|
+
hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
|
|
39
|
+
primary_hostname = hostnames.first
|
|
40
40
|
|
|
41
41
|
# Service URL points to the NGINX Ingress Controller
|
|
42
42
|
service_url = "http://ingress-nginx-controller.ingress-nginx.svc.cluster.local:80"
|
|
43
43
|
|
|
44
|
-
tunnel = setup_tunnel(tunnel_name,
|
|
44
|
+
tunnel = setup_tunnel(tunnel_name, hostnames, service_url, service_config.domain)
|
|
45
45
|
|
|
46
46
|
Objects::Tunnel::Info.new(
|
|
47
47
|
service_name:,
|
|
48
|
-
hostname
|
|
48
|
+
hostname: primary_hostname,
|
|
49
49
|
tunnel_id: tunnel.tunnel_id,
|
|
50
50
|
tunnel_token: tunnel.tunnel_token
|
|
51
51
|
)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def setup_tunnel(tunnel_name,
|
|
55
|
-
@log.info "Setting up tunnel: %s -> %s", tunnel_name,
|
|
54
|
+
def setup_tunnel(tunnel_name, hostnames, service_url, domain)
|
|
55
|
+
@log.info "Setting up tunnel: %s -> %s", tunnel_name, hostnames.join(", ")
|
|
56
56
|
|
|
57
57
|
# Find or create tunnel
|
|
58
58
|
tunnel = @cf_client.find_tunnel(tunnel_name)
|
|
@@ -70,21 +70,23 @@ module Nvoi
|
|
|
70
70
|
token = @cf_client.get_tunnel_token(tunnel.id)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Configure tunnel ingress
|
|
74
|
-
@log.info "Configuring tunnel ingress: %s -> %s",
|
|
75
|
-
@cf_client.update_tunnel_configuration(tunnel.id,
|
|
73
|
+
# Configure tunnel ingress for all hostnames
|
|
74
|
+
@log.info "Configuring tunnel ingress: %s -> %s", hostnames.join(", "), service_url
|
|
75
|
+
@cf_client.update_tunnel_configuration(tunnel.id, hostnames, service_url)
|
|
76
76
|
|
|
77
77
|
# Verify configuration propagated
|
|
78
78
|
@log.info "Verifying tunnel configuration..."
|
|
79
|
-
@cf_client.verify_tunnel_configuration(tunnel.id,
|
|
79
|
+
@cf_client.verify_tunnel_configuration(tunnel.id, hostnames, service_url, Utils::Constants::TUNNEL_CONFIG_VERIFY_ATTEMPTS)
|
|
80
80
|
|
|
81
|
-
# Create DNS
|
|
82
|
-
@log.info "Creating DNS CNAME record: %s", hostname
|
|
81
|
+
# Create DNS records for all hostnames
|
|
83
82
|
zone = @cf_client.find_zone(domain)
|
|
84
83
|
raise Errors::CloudflareError, "zone not found: #{domain}" unless zone
|
|
85
84
|
|
|
86
85
|
tunnel_cname = "#{tunnel.id}.cfargotunnel.com"
|
|
87
|
-
|
|
86
|
+
hostnames.each do |hostname|
|
|
87
|
+
@log.info "Creating DNS CNAME record: %s", hostname
|
|
88
|
+
@cf_client.create_or_update_dns_record(zone.id, hostname, "CNAME", tunnel_cname, proxied: true)
|
|
89
|
+
end
|
|
88
90
|
|
|
89
91
|
@log.success "Tunnel configured: %s", tunnel_name
|
|
90
92
|
|
|
@@ -22,10 +22,9 @@ module Nvoi
|
|
|
22
22
|
@kubectl = External::Kubectl.new(ssh)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def run(
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
push_to_registry(image_tag, registry_tag)
|
|
25
|
+
def run(registry_tag, timestamp)
|
|
26
|
+
# Image is already in registry (pushed via SSH tunnel in BuildImage step)
|
|
27
|
+
@log.info "Using image from registry: %s", registry_tag
|
|
29
28
|
|
|
30
29
|
# Gather env vars
|
|
31
30
|
first_service = @config.deploy.application.app.keys.first
|
|
@@ -65,15 +64,6 @@ module Nvoi
|
|
|
65
64
|
|
|
66
65
|
private
|
|
67
66
|
|
|
68
|
-
def push_to_registry(local_tag, registry_tag)
|
|
69
|
-
@log.info "Pushing to in-cluster registry: %s", registry_tag
|
|
70
|
-
|
|
71
|
-
@ssh.execute("sudo ctr -n k8s.io images tag #{local_tag} #{registry_tag}")
|
|
72
|
-
@ssh.execute("sudo ctr -n k8s.io images push --plain-http #{registry_tag}")
|
|
73
|
-
|
|
74
|
-
@log.success "Image pushed to registry"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
67
|
def deploy_app_secret(env_vars)
|
|
78
68
|
secret_name = @namer.app_secret_name
|
|
79
69
|
|
|
@@ -247,11 +237,11 @@ module Nvoi
|
|
|
247
237
|
|
|
248
238
|
# Deploy ingress if domain is specified
|
|
249
239
|
if service_config.domain && !service_config.domain.empty?
|
|
250
|
-
|
|
240
|
+
hostnames = Utils::Namer.build_hostnames(service_config.subdomain, service_config.domain)
|
|
251
241
|
|
|
252
242
|
Utils::Templates.apply_manifest(@ssh, "app-ingress.yaml", {
|
|
253
243
|
name: deployment_name,
|
|
254
|
-
|
|
244
|
+
domains: hostnames,
|
|
255
245
|
port: service_config.port
|
|
256
246
|
})
|
|
257
247
|
end
|
|
@@ -323,6 +313,9 @@ module Nvoi
|
|
|
323
313
|
result = check_public_url(public_url)
|
|
324
314
|
|
|
325
315
|
if result[:success]
|
|
316
|
+
if consecutive_success == 0
|
|
317
|
+
@log.info "[%d/%d] App responding, verifying stability...", attempt + 1, max_attempts
|
|
318
|
+
end
|
|
326
319
|
consecutive_success += 1
|
|
327
320
|
@log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, result[:http_code]
|
|
328
321
|
|
|
@@ -467,7 +467,16 @@ module Nvoi
|
|
|
467
467
|
ssh.execute(patch_deployment)
|
|
468
468
|
|
|
469
469
|
@log.info "Waiting for ingress controller to restart..."
|
|
470
|
-
|
|
470
|
+
ready = Utils::Retry.poll(max_attempts: 60, interval: 2) do
|
|
471
|
+
begin
|
|
472
|
+
ready_replicas = ssh.execute("kubectl get deployment ingress-nginx-controller -n ingress-nginx -o jsonpath='{.status.readyReplicas}'").strip
|
|
473
|
+
desired_replicas = ssh.execute("kubectl get deployment ingress-nginx-controller -n ingress-nginx -o jsonpath='{.spec.replicas}'").strip
|
|
474
|
+
!ready_replicas.empty? && !desired_replicas.empty? && ready_replicas == desired_replicas
|
|
475
|
+
rescue Errors::SshCommandError
|
|
476
|
+
false
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
raise Errors::K8sError, "Ingress controller failed to restart" unless ready
|
|
471
480
|
else
|
|
472
481
|
@log.info "Custom error backend already configured"
|
|
473
482
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Logs
|
|
6
|
+
# Command streams logs from app pods
|
|
7
|
+
class Command
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
@log = Nvoi.logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(app_name)
|
|
14
|
+
config_path = resolve_config_path
|
|
15
|
+
@config = Utils::ConfigLoader.load(config_path)
|
|
16
|
+
|
|
17
|
+
# Apply branch override if specified
|
|
18
|
+
apply_branch_override if @options[:branch]
|
|
19
|
+
|
|
20
|
+
# Initialize cloud provider
|
|
21
|
+
@provider = External::Cloud.for(@config)
|
|
22
|
+
|
|
23
|
+
# Find main server
|
|
24
|
+
server = @provider.find_server(@config.server_name)
|
|
25
|
+
raise Errors::ServiceError, "server not found: #{@config.server_name}" unless server
|
|
26
|
+
|
|
27
|
+
# Build deployment name from app name
|
|
28
|
+
deployment_name = @config.namer.app_deployment_name(app_name)
|
|
29
|
+
|
|
30
|
+
# Build kubectl logs command
|
|
31
|
+
# --prefix shows pod name, --all-containers handles multi-container pods
|
|
32
|
+
follow_flag = @options[:follow] ? "-f" : ""
|
|
33
|
+
tail_flag = "--tail=#{@options[:tail]}"
|
|
34
|
+
|
|
35
|
+
kubectl_cmd = "kubectl logs -l app=#{deployment_name} --prefix --all-containers #{follow_flag} #{tail_flag}".strip.squeeze(" ")
|
|
36
|
+
|
|
37
|
+
@log.info "Streaming logs for %s", deployment_name
|
|
38
|
+
|
|
39
|
+
ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
|
|
40
|
+
ssh.execute(kubectl_cmd, stream: true)
|
|
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
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|