kitsune-kit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd66d70ec00aa522857e3ac981a13ada8e49aa5723e1a1fc577bc05b7642e250
4
- data.tar.gz: 81121119b1abe6a5b70d9fb59efc96994af2ac144b899e96bb5a0098743ee995
3
+ metadata.gz: 2860b56f9307be49ac06572e05cdf79e02bf76946262e64651d36c729505c8a8
4
+ data.tar.gz: c38a154d06774e6755dfec97f35a75d4ad2208b262ac37bb3ce20a25612836a7
5
5
  SHA512:
6
- metadata.gz: 2a49fd2827e0edc77b9a40a8a7b42fef05cb9ba4c6a78655de2419022ec9ad98b703073620643dca76024184557d146e5c67a8fc6536c7596a52a2b13379d5a5
7
- data.tar.gz: 8f2d905fd886ba016229e13762e63b9c1997d9289619bf9d3a3793bd222e8a330231884c64d9dba9571a1ebe32fe7f421c84da29e3c064ebce2eb24faa639931
6
+ metadata.gz: 45ca3cd5d65502bb6c4e2fbdfa965152426d19bb732812651aee0957988d05eff5fe93432758825120e9ac2f20d8041afee8f7d34ac80aab438931c6948c15ad
7
+ data.tar.gz: f53ede98dd9cbf482e42ed91530cd07ddba5ed1744df1fcc46a92e6e5797f9629180057ff1a5e53cd80987f2ef0892d41400caff2b3a51273310b1676e9f93c5
data/README.md CHANGED
@@ -18,8 +18,10 @@
18
18
  - ♻️ Enables automatic security updates (unattended-upgrades)
19
19
  - 💾 Configures swap space for better performance
20
20
  - 📊 Installs [DigitalOcean monitoring agent](https://docs.digitalocean.com/products/monitoring/how-to/install-agent/)
21
+ - 🌐 **Automatically links** domains or subdomains (A records) to your server using DigitalOcean DNS
21
22
  - 🐳 Installs and configures Docker Engine and private networking
22
- - 🐘 Deploys PostgreSQL via Docker Compose with healthcheck and `.env`
23
+ - 🐘 Deploys PostgreSQL via Docker Compose with healthcheck
24
+ - 🗄️ Deploys Redis via Docker Compose with healthcheck
23
25
  - 🔄 All steps can be rolled back (`--rollback`)
24
26
  - ⚡ Fast, reproducible and without relying on YAML or complex external tools
25
27
 
@@ -7,6 +7,11 @@ IMAGE=
7
7
  SSH_KEY_ID=
8
8
  TAG_NAME=
9
9
  ENABLE_DO_METRICS=true
10
+ # If you want DNS records to be created automatically, set the following variables:
11
+ # DOMAIN_NAMES=example.com,test.example.org # (optional, comma-separated list of domain names to add to the droplet)
12
+ # DNS_TTL=3600 # (optional, time-to-live for DNS records in seconds, default is 3600)
13
+ # Check the DigitalOcean documentation for more information on domain names:
14
+ # https://docs.digitalocean.com/products/networking/dns/getting-started/dns-registrars/
10
15
 
11
16
  # SSH
12
17
  SSH_KEY_PATH=
@@ -17,6 +22,10 @@ SWAP_SIZE_GB=2
17
22
  DISABLE_SWAP=false
18
23
 
19
24
  # PostgreSQL Docker
20
- POSTGRES_DB=
21
- POSTGRES_USER=
22
- POSTGRES_PASSWORD=
25
+ POSTGRES_DB=myapp_db
26
+ POSTGRES_USER=postgres
27
+ POSTGRES_PASSWORD=secret
28
+
29
+ # Redis Docker
30
+ REDIS_PORT=6379
31
+ REDIS_PASSWORD=secret
@@ -0,0 +1,23 @@
1
+ services:
2
+ redis:
3
+ image: redis:7.2
4
+ restart: always
5
+ ports:
6
+ - "${REDIS_PORT:-6379}:6379"
7
+ command: redis-server --requirepass ${REDIS_PASSWORD}
8
+ networks:
9
+ - private
10
+ volumes:
11
+ - redisdata:/data
12
+ healthcheck:
13
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
14
+ interval: 30s
15
+ timeout: 10s
16
+ retries: 5
17
+
18
+ volumes:
19
+ redisdata:
20
+
21
+ networks:
22
+ private:
23
+ driver: bridge
@@ -0,0 +1,78 @@
1
+ module AnsiColor
2
+ COLORS = {
3
+ black: 30,
4
+ red: 31,
5
+ green: 32,
6
+ yellow: 33,
7
+ blue: 34,
8
+ magenta: 35,
9
+ cyan: 36,
10
+ white: 37,
11
+
12
+ light_black: 90,
13
+ light_red: 91,
14
+ light_green: 92,
15
+ light_yellow: 93,
16
+ light_blue: 94,
17
+ light_magenta: 95,
18
+ light_cyan: 96,
19
+ light_white: 97
20
+ }
21
+
22
+ BACKGROUNDS = {
23
+ black: 40,
24
+ red: 41,
25
+ green: 42,
26
+ yellow: 43,
27
+ blue: 44,
28
+ magenta: 45,
29
+ cyan: 46,
30
+ white: 47,
31
+
32
+ light_black: 100,
33
+ light_red: 101,
34
+ light_green: 102,
35
+ light_yellow: 103,
36
+ light_blue: 104,
37
+ light_magenta: 105,
38
+ light_cyan: 106,
39
+ light_white: 107
40
+ }
41
+
42
+ STYLES = {
43
+ bold: 1,
44
+ italic: 3,
45
+ underline: 4
46
+ }
47
+
48
+ def self.colorize(text, color: nil, background: nil, style: nil)
49
+ codes = []
50
+ codes << COLORS[color] if color
51
+ codes << BACKGROUNDS[background] if background
52
+ codes << STYLES[style] if style
53
+
54
+ raise ArgumentError, "No valid formatting options given" if codes.empty?
55
+
56
+ "\e[#{codes.join(';')}m#{text}\e[0m"
57
+ end
58
+
59
+ def self.demo
60
+ puts "Text Colors:"
61
+ COLORS.each_key do |color|
62
+ puts colorize(" #{color.to_s.ljust(15)}", color: color)
63
+ end
64
+
65
+ puts "\nBackground Colors:"
66
+ BACKGROUNDS.each_key do |bg|
67
+ puts colorize(" #{bg.to_s.ljust(15)}", background: bg)
68
+ end
69
+
70
+ puts "\nStyles:"
71
+ STYLES.each_key do |style|
72
+ puts colorize(" #{style.to_s.ljust(15)}", color: :white, style: style)
73
+ end
74
+
75
+ puts "\nCombined Example:"
76
+ puts colorize(" bold green on light_black ", color: :green, background: :light_black, style: :bold)
77
+ end
78
+ end
@@ -1,9 +1,10 @@
1
1
  require "thor"
2
- require "colorize"
2
+ require_relative "ansi_color"
3
3
  require_relative "env_loader"
4
4
  require_relative "commands/init"
5
5
  require_relative "commands/switch_env"
6
6
  require_relative "commands/provision"
7
+ require_relative "commands/dns"
7
8
  require_relative "commands/setup_user"
8
9
  require_relative "commands/setup_firewall"
9
10
  require_relative "commands/setup_unattended"
@@ -15,18 +16,30 @@ require_relative "commands/install_docker_engine"
15
16
  require_relative "commands/postinstall_docker"
16
17
  require_relative "commands/bootstrap_docker"
17
18
  require_relative "commands/setup_postgres_docker"
19
+ require_relative "commands/setup_redis_docker"
20
+ require_relative "commands/ssh"
18
21
 
19
22
  module Kitsune
20
23
  module Kit
21
24
  class CLI < Thor
22
25
  def self.dispatch(m, args, options, config)
23
- unless ["init", "switch_env", "help", nil].include?(args.first)
26
+ if args.include?("-v") || args.include?("--version")
27
+ puts "Kitsune Kit v#{Kitsune::Kit::VERSION}"
28
+ exit(0)
29
+ end
30
+
31
+ unless ["version", "init", "switch_env", "help", nil].include?(args.first)
24
32
  Kitsune::Kit::EnvLoader.load!
25
33
  end
26
34
 
27
35
  super
28
36
  end
29
37
 
38
+ desc "version", "Show Kitsune Kit version"
39
+ def version
40
+ say "Kitsune Kit v#{Kitsune::Kit::VERSION}", :green
41
+ end
42
+
30
43
  desc "init", "Initialize Kitsune Kit project structure"
31
44
  subcommand "init", Kitsune::Kit::Commands::Init
32
45
 
@@ -36,6 +49,9 @@ module Kitsune
36
49
  desc "provision SUBCOMMAND", "Provisioning tasks"
37
50
  subcommand "provision", Kitsune::Kit::Commands::Provision
38
51
 
52
+ desc "dns SUBCOMMAND", "Manage DNS"
53
+ subcommand "dns", Kitsune::Kit::Commands::Dns
54
+
39
55
  desc "setup_user SUBCOMMAND", "Create or rollback deploy user on remote server"
40
56
  subcommand "setup_user", Kitsune::Kit::Commands::SetupUser
41
57
 
@@ -68,6 +84,12 @@ module Kitsune
68
84
 
69
85
  desc "setup_postgres_docker SUBCOMMAND", "Setup PostgreSQL via Docker Compose on remote server"
70
86
  subcommand "setup_postgres_docker", Kitsune::Kit::Commands::SetupPostgresDocker
87
+
88
+ desc "setup_redis_docker SUBCOMMAND", "Setup Redis via Docker Compose on remote server"
89
+ subcommand "setup_redis_docker", Kitsune::Kit::Commands::SetupRedisDocker
90
+
91
+ desc "ssh connect", "SSH into the server"
92
+ subcommand "ssh", Kitsune::Kit::Commands::Ssh
71
93
  end
72
94
  end
73
95
  end
@@ -44,6 +44,7 @@ module Kitsune
44
44
  run_cli("setup_unattended create", droplet_ip, filled_options)
45
45
  run_cli("setup_swap create", droplet_ip, filled_options)
46
46
  run_cli("setup_do_metrics create", droplet_ip, filled_options)
47
+ run_cli("dns link", droplet_ip, filled_options.merge(domains: ENV["DOMAIN_NAMES"]))
47
48
  end
48
49
 
49
50
  def rollback_sequence(filled_options)
@@ -75,10 +76,11 @@ module Kitsune
75
76
  run_cli("setup_user rollback", droplet_ip, filled_options)
76
77
 
77
78
  unless filled_options[:keep_server]
79
+ run_cli("dns rollback", droplet_ip, filled_options.merge(domains: ENV["DOMAIN_NAMES"]))
78
80
  say "▶️ Running: kitsune kit provision rollback", :blue
79
81
  Kitsune::Kit::CLI.start(%w[provision rollback])
80
82
  else
81
- say "⏭️ Skipping droplet deletion (--keep-server enabled)", :yellow
83
+ say "⏭️ Skipping droplet deletion (--keep-server enabled), DNS rollback won't be executed", :yellow
82
84
  end
83
85
  end
84
86
 
@@ -99,12 +101,18 @@ module Kitsune
99
101
  def run_cli(command, droplet_ip, filled_options)
100
102
  say "\n▶️ Running: kitsune kit #{command} --server-ip #{droplet_ip}", :blue
101
103
  subcommand, action = command.split(" ", 2)
102
- Kitsune::Kit::CLI.start([
103
- subcommand, action,
104
- "--server-ip", droplet_ip,
105
- "--ssh-port", filled_options[:ssh_port],
106
- "--ssh-key-path", filled_options[:ssh_key_path]
107
- ])
104
+
105
+ args = [subcommand, action, "--server-ip", droplet_ip]
106
+
107
+ if subcommand != "dns"
108
+ args += ["--ssh-port", filled_options[:ssh_port], "--ssh-key-path", filled_options[:ssh_key_path]]
109
+ end
110
+
111
+ if subcommand == "dns" && ENV["DOMAIN_NAMES"]
112
+ args += ["--domains", ENV["DOMAIN_NAMES"]]
113
+ end
114
+
115
+ Kitsune::Kit::CLI.start(args)
108
116
  rescue SystemExit => e
109
117
  abort "❌ Command failed: #{command} (exit #{e.status})"
110
118
  end
@@ -0,0 +1,112 @@
1
+ require "droplet_kit"
2
+ require "thor"
3
+
4
+ module Kitsune
5
+ module Kit
6
+ module Commands
7
+ class Dns < Thor
8
+ namespace "dns"
9
+
10
+ class_option :domains, type: :string, desc: "Comma-separated domain list (or from ENV['DOMAIN_NAMES'])"
11
+ class_option :server_ip, type: :string, required: true, desc: "IPv4 to assign to domain(s)"
12
+ class_option :ttl, type: :numeric, default: 3600, desc: "TTL in seconds for DNS records"
13
+
14
+ desc "link", "Link domains to a given IP using A records"
15
+ def link
16
+ validate_ip!
17
+
18
+ domains = resolve_domains
19
+ return if domains.empty?
20
+
21
+ ip = options[:server_ip]
22
+ ttl = ENV.fetch("DNS_TTL", options[:ttl]).to_i
23
+
24
+ client = DropletKit::Client.new(access_token: ENV.fetch("DO_API_TOKEN"))
25
+
26
+ domains.each do |fqdn|
27
+ parts = fqdn.split('.')
28
+ next if parts.size < 2
29
+
30
+ root_domain = parts[-2..].join('.')
31
+ subdomain = parts[0..-3].join('.')
32
+ name_for_a = subdomain.empty? ? "@" : subdomain
33
+
34
+ puts "\n🌐 Linking '#{fqdn}' to IP #{ip} (domain: #{root_domain}, record: #{name_for_a})"
35
+
36
+ records = client.domain_records.all(for_domain: root_domain)
37
+ existing = records.find { |r| r.type == "A" && r.name == name_for_a }
38
+
39
+ domain_record = DropletKit::DomainRecord.new(
40
+ type: "A",
41
+ name: name_for_a,
42
+ data: ip,
43
+ ttl: ttl
44
+ )
45
+
46
+ msg = "'#{AnsiColor.colorize(name_for_a, color: :green)}.#{AnsiColor.colorize(root_domain, color: :green)}' → #{AnsiColor.colorize(ip, color: :light_cyan)}"
47
+ if existing
48
+ client.domain_records.update(
49
+ domain_record,
50
+ for_domain: root_domain,
51
+ id: existing.id
52
+ )
53
+ puts "✅ Updated A record #{msg}"
54
+ else
55
+ client.domain_records.create(
56
+ domain_record,
57
+ for_domain: root_domain
58
+ )
59
+ puts "✅ Created A record #{msg}"
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "rollback", "Remove A records for the specified domains"
65
+ def rollback
66
+ domains = resolve_domains
67
+ return if domains.empty?
68
+
69
+ client = DropletKit::Client.new(access_token: ENV.fetch("DO_API_TOKEN"))
70
+
71
+ domains.each do |fqdn|
72
+ parts = fqdn.split('.')
73
+ next if parts.size < 2
74
+
75
+ root_domain = parts[-2..].join('.')
76
+ subdomain = parts[0..-3].join('.')
77
+ name_for_a = subdomain.empty? ? "@" : subdomain
78
+
79
+ puts "\n🗑️ Attempting to delete A record for '#{fqdn}' (domain: #{root_domain}, record: #{name_for_a})"
80
+
81
+ records = client.domain_records.all(for_domain: root_domain)
82
+ existing = records.find { |r| r.type == "A" && r.name == name_for_a }
83
+
84
+ if existing
85
+ client.domain_records.delete(for_domain: root_domain, id: existing.id)
86
+ puts "✅ Deleted A record '#{name_for_a}.#{root_domain}'"
87
+ else
88
+ puts "💡 No A record found for '#{name_for_a}.#{root_domain}', nothing to delete"
89
+ end
90
+ end
91
+ end
92
+
93
+ no_commands do
94
+ def validate_ip!
95
+ if options[:server_ip].nil? || options[:server_ip].empty?
96
+ abort "❌ Missing required option: --server-ip"
97
+ end
98
+ end
99
+
100
+ def resolve_domains
101
+ raw = options[:domains] || ENV["DOMAIN_NAMES"]
102
+ if raw.nil? || raw.strip.empty?
103
+ puts "⏭️ No domains provided. Skipping DNS operation."
104
+ return []
105
+ end
106
+ raw.split(',').map(&:strip).reject(&:empty?)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -124,8 +124,8 @@ module Kitsune
124
124
  end
125
125
 
126
126
  def copy_docker_templates
127
- dest_path = ".kitsune/docker/postgres.yml"
128
- copy_with_prompt(blueprint_path("docker/postgres.yml"), dest_path)
127
+ copy_with_prompt(blueprint_path("docker/postgres.yml"), ".kitsune/docker/postgres.yml")
128
+ copy_with_prompt(blueprint_path("docker/redis.yml"), ".kitsune/docker/redis.yml")
129
129
  end
130
130
 
131
131
  def copy_with_prompt(source, destination)
@@ -26,7 +26,7 @@ module Kitsune
26
26
  defaults: Kitsune::Kit::Defaults.ssh
27
27
  )
28
28
 
29
- with_ssh(filled_options) do |ssh|
29
+ with_ssh_connection(filled_options) do |ssh|
30
30
  install_agent(ssh)
31
31
  end
32
32
  end
@@ -39,13 +39,13 @@ module Kitsune
39
39
  defaults: Kitsune::Kit::Defaults.ssh
40
40
  )
41
41
 
42
- with_ssh(filled_options) do |ssh|
42
+ with_ssh_connection(filled_options) do |ssh|
43
43
  uninstall_agent(ssh)
44
44
  end
45
45
  end
46
46
 
47
47
  no_commands do
48
- def with_ssh(filled_options)
48
+ def with_ssh_connection(filled_options)
49
49
  Net::SSH.start(
50
50
  filled_options[:server_ip],
51
51
  "deploy",
@@ -36,11 +36,7 @@ module Kitsune
36
36
  )
37
37
 
38
38
  with_ssh_connection(filled_options) do |ssh|
39
- perform_setup(ssh, postgres_defaults)
40
-
41
- database_url = build_database_url(filled_options, postgres_defaults)
42
- say "🔗 Your DATABASE_URL is:\t", :cyan
43
- say database_url, :green
39
+ perform_setup(ssh, postgres_defaults, filled_options)
44
40
  end
45
41
  end
46
42
 
@@ -71,26 +67,21 @@ module Kitsune
71
67
  end
72
68
  end
73
69
 
74
- def perform_setup(ssh, postgres_defaults)
75
- docker_compose_local = ".kitsune/docker/postgres.yml"
76
- unless File.exist?(docker_compose_local)
77
- say "❌ Docker compose file not found at #{docker_compose_local}.", :red
78
- exit(1)
79
- end
70
+ def perform_setup(ssh, postgres_defaults, filled_options)
71
+ local_compose = ".kitsune/docker/postgres.yml"
72
+ remote_dir = "$HOME/docker/postgres"
73
+ compose_remote = "#{remote_dir}/docker-compose.yml"
74
+ env_remote = "#{remote_dir}/.env"
75
+ marker = "/usr/local/backups/setup_postgres_docker.after"
80
76
 
81
- docker_dir_remote = "$HOME/docker/postgres"
82
- docker_compose_remote = "#{docker_dir_remote}/docker-compose.yml"
83
- docker_env_remote = "#{docker_dir_remote}/.env"
84
- backup_marker = "/usr/local/backups/setup_postgres_docker.after"
77
+ abort "❌ Missing #{local_compose}" unless File.exist?(local_compose)
85
78
 
86
79
  # 1. Create base directory securely
87
- ssh.exec!("mkdir -p #{docker_dir_remote}")
88
- ssh.exec!("chmod 700 #{docker_dir_remote}")
80
+ ssh.exec!("mkdir -p #{remote_dir} && chmod 700 #{remote_dir}")
89
81
 
90
82
  # 2. Upload docker-compose.yml
91
- say "📦 Uploading docker-compose.yml to remote server...", :cyan
92
- content_compose = File.read(docker_compose_local)
93
- upload_file(ssh, content_compose, docker_compose_remote)
83
+ say "📦 Uploading docker-compose.yml to #{remote_dir}", :cyan
84
+ upload_file(ssh, File.read(local_compose), compose_remote)
94
85
 
95
86
  # 3. Create .env file for docker-compose based on postgres_defaults
96
87
  say "📦 Creating .env file for Docker Compose...", :cyan
@@ -101,17 +92,17 @@ module Kitsune
101
92
  POSTGRES_PORT=#{postgres_defaults[:postgres_port]}
102
93
  POSTGRES_IMAGE=#{postgres_defaults[:postgres_image]}
103
94
  ENVFILE
104
- upload_file(ssh, env_content, docker_env_remote)
95
+ upload_file(ssh, env_content, env_remote)
105
96
 
106
97
  # 4. Secure file permissions
107
- ssh.exec!("chmod 600 #{docker_compose_remote} #{docker_env_remote}")
98
+ ssh.exec!("chmod 600 #{compose_remote} #{env_remote}")
108
99
 
109
100
  # 5. Create backup marker
110
- ssh.exec!("sudo mkdir -p /usr/local/backups && sudo touch #{backup_marker}")
101
+ ssh.exec!("sudo mkdir -p /usr/local/backups && sudo touch #{marker}")
111
102
 
112
103
  # 6. Validate docker-compose.yml
113
104
  say "🔍 Validating docker-compose.yml...", :cyan
114
- validation_output = ssh.exec!("cd #{docker_dir_remote} && docker compose config")
105
+ validation_output = ssh.exec!("cd #{remote_dir} && docker compose config")
115
106
  say validation_output, :cyan
116
107
 
117
108
  # 7. Check if container is running
@@ -119,19 +110,20 @@ module Kitsune
119
110
 
120
111
  if container_status.empty?
121
112
  say "▶️ No running container. Running docker compose up...", :cyan
122
- ssh.exec!("cd #{docker_dir_remote} && docker compose up -d")
113
+ ssh.exec!("cd #{remote_dir} && docker compose up -d")
123
114
  else
124
115
  say "⚠️ PostgreSQL container is already running.", :yellow
125
116
  if yes?("🔁 Recreate the container with updated configuration? [y/N]", :yellow)
126
117
  say "🔄 Recreating container...", :cyan
127
- ssh.exec!("cd #{docker_dir_remote} && docker compose down -v && docker compose up -d")
118
+ ssh.exec!("cd #{remote_dir} && docker compose down -v && docker compose up -d")
128
119
  else
129
120
  say "⏩ Keeping existing container.", :cyan
130
121
  end
131
122
  end
132
123
 
124
+ # 8. Check container status
133
125
  say "📋 Final container status (docker compose ps):", :cyan
134
- docker_ps_output = ssh.exec!("cd #{docker_dir_remote} && docker compose ps --format json")
126
+ docker_ps_output = ssh.exec!("cd #{remote_dir} && docker compose ps --format json")
135
127
 
136
128
  if docker_ps_output.nil? || docker_ps_output.strip.empty? || docker_ps_output.include?("no configuration file")
137
129
  say "⚠️ docker compose ps returned no valid output.", :yellow
@@ -168,9 +160,13 @@ module Kitsune
168
160
  if healthcheck.include?("accepting connections")
169
161
  say "✅ PostgreSQL is up and accepting connections! (attempt #{attempt})", :green
170
162
  success = true
163
+
164
+ database_url = build_database_url(filled_options, postgres_defaults)
165
+ say "🔗 Your DATABASE_URL is:\t", :cyan
166
+ say database_url, :green
171
167
  break
172
168
  else
173
- say "⏳ PostgreSQL not ready yet, retrying in 5 seconds... (#{attempt + 1}/#{max_attempts})", :yellow
169
+ say "⏳ PostgreSQL not ready yet, retrying in 5 seconds... (#{attempt}/#{max_attempts})", :yellow
174
170
  sleep 5
175
171
  end
176
172
  end
@@ -181,7 +177,7 @@ module Kitsune
181
177
 
182
178
  # 10. Allow PostgreSQL port through firewall (ufw)
183
179
  say "🛡️ Configuring firewall to allow PostgreSQL (port #{postgres_defaults[:postgres_port]})...", :cyan
184
- firewall = <<~EOH
180
+ output = ssh.exec! <<~EOH
185
181
  if command -v ufw >/dev/null; then
186
182
  if ! sudo ufw status | grep -q "#{postgres_defaults[:postgres_port]}"; then
187
183
  sudo ufw allow #{postgres_defaults[:postgres_port]}
@@ -192,7 +188,9 @@ module Kitsune
192
188
  echo "⚠️ ufw not found. Skipping firewall configuration."
193
189
  fi
194
190
  EOH
195
- ssh.exec!(firewall)
191
+ say output
192
+
193
+ say "✅ PostgreSQL setup completed successfully!", :green
196
194
  end
197
195
 
198
196
  def perform_rollback(ssh, postgres_defaults)
@@ -227,9 +225,9 @@ module Kitsune
227
225
  end
228
226
 
229
227
  def upload_file(ssh, content, remote_path)
230
- escaped_content = Shellwords.escape(content)
228
+ escaped = Shellwords.escape(content)
231
229
  ssh.exec!("mkdir -p #{File.dirname(remote_path)}")
232
- ssh.exec!("echo #{escaped_content} > #{remote_path}")
230
+ ssh.exec!("echo #{escaped} > #{remote_path}")
233
231
  end
234
232
 
235
233
  def build_database_url(filled_options, postgres_defaults)
@@ -0,0 +1,241 @@
1
+ require "thor"
2
+ require "net/ssh"
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "../defaults"
6
+ require_relative "../options_builder"
7
+ require "pry"
8
+ module Kitsune
9
+ module Kit
10
+ module Commands
11
+ class SetupRedisDocker < Thor
12
+ namespace "setup_redis_docker"
13
+
14
+ class_option :server_ip, aliases: "-s", required: true, desc: "Server IP address or hostname"
15
+ class_option :ssh_port, aliases: "-p", desc: "SSH port"
16
+ class_option :ssh_key_path, aliases: "-k", desc: "Path to SSH private key"
17
+
18
+ desc "create", "Setup Redis using Docker Compose on remote server"
19
+ def create
20
+ redis_defaults = Kitsune::Kit::Defaults.redis
21
+
22
+ if redis_defaults[:redis_password] == "secret"
23
+ say "⚠️ Warning: You are using the default Redis password ('secret').", :yellow
24
+ if ENV.fetch("KIT_ENV", "development") == "production"
25
+ abort "❌ Production environment requires a secure Redis password!"
26
+ else
27
+ say "🔒 Please change REDIS_PASSWORD in your .env if needed.", :yellow
28
+ end
29
+ end
30
+
31
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
32
+ options,
33
+ required: [:server_ip],
34
+ defaults: Kitsune::Kit::Defaults.ssh
35
+ )
36
+
37
+ with_ssh_connection(filled_options) do |ssh|
38
+ perform_setup(ssh, redis_defaults, filled_options)
39
+ end
40
+ end
41
+
42
+ desc "rollback", "Remove Redis Docker setup from remote server"
43
+ def rollback
44
+ redis_defaults = Kitsune::Kit::Defaults.redis
45
+
46
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
47
+ options,
48
+ required: [:server_ip],
49
+ defaults: Kitsune::Kit::Defaults.ssh
50
+ )
51
+
52
+ with_ssh_connection(filled_options) do |ssh|
53
+ perform_rollback(ssh, redis_defaults)
54
+ end
55
+ end
56
+
57
+ no_commands do
58
+ def with_ssh_connection(filled_options)
59
+ server = filled_options[:server_ip]
60
+ port = filled_options[:ssh_port]
61
+ key = File.expand_path(filled_options[:ssh_key_path])
62
+
63
+ say "🔑 Connecting as deploy@#{server}:#{port}", :green
64
+ Net::SSH.start(server, "deploy", port: port, keys: [key], non_interactive: true, timeout: 5) do |ssh|
65
+ yield ssh
66
+ end
67
+ end
68
+
69
+ def perform_setup(ssh, redis_defaults, filled_options)
70
+ local_compose = ".kitsune/docker/redis.yml"
71
+ remote_dir = "$HOME/docker/redis"
72
+ compose_remote = "#{remote_dir}/docker-compose.yml"
73
+ env_remote = "#{remote_dir}/.env"
74
+ marker = "/usr/local/backups/setup_redis_docker.after"
75
+
76
+ abort "❌ Missing #{local_compose}" unless File.exist?(local_compose)
77
+
78
+ # 1. Create base directory securely
79
+ ssh.exec!("mkdir -p #{remote_dir} && chmod 700 #{remote_dir}")
80
+
81
+ # 2. Upload docker-compose.yml
82
+ say "📦 Uploading docker-compose.yml to #{remote_dir}", :cyan
83
+ upload_file(ssh, File.read(local_compose), compose_remote)
84
+
85
+ # 3. Create .env file for docker-compose based on redis_defaults
86
+ env_content = <<~ENVFILE
87
+ REDIS_PORT=#{redis_defaults[:redis_port]}
88
+ REDIS_PASSWORD=#{redis_defaults[:redis_password]}
89
+ ENVFILE
90
+ upload_file(ssh, env_content, env_remote)
91
+
92
+ # 4. Secure file permissions
93
+ ssh.exec!("chmod 600 #{compose_remote} #{env_remote}")
94
+
95
+ # 5. Create a backup marker
96
+ ssh.exec!("sudo mkdir -p /usr/local/backups && sudo touch #{marker}")
97
+
98
+ # 6. Validate docker-compose.yml
99
+ say "🔍 Validating docker-compose.yml...", :cyan
100
+ validation_output = ssh.exec!("cd #{remote_dir} && docker compose config")
101
+ say validation_output, :cyan
102
+
103
+ # 7. Check if container is running
104
+ container_status = ssh.exec!("docker ps --filter 'name=redis' --format '{{.Status}}'").strip
105
+
106
+ if container_status.empty?
107
+ say "▶️ No running container. Running docker compose up...", :cyan
108
+ ssh.exec!("cd #{remote_dir} && docker compose up -d")
109
+ else
110
+ say "⚠️ Redis container is already running.", :yellow
111
+ if yes?("🔁 Recreate the container with updated configuration? [y/N]", :yellow)
112
+ say "🔄 Recreating container...", :cyan
113
+ ssh.exec!("cd #{remote_dir} && docker compose down -v && docker compose up -d")
114
+ else
115
+ say "⏩ Keeping existing container.", :cyan
116
+ end
117
+ end
118
+
119
+ # 8. Check container status
120
+ say "📋 Final container status (docker compose ps):", :cyan
121
+ docker_ps_output = ssh.exec!("cd #{remote_dir} && docker compose ps --format json")
122
+
123
+ if docker_ps_output.nil? || docker_ps_output.strip.empty? || docker_ps_output.include?("no configuration file")
124
+ say "⚠️ docker compose ps returned no valid output.", :yellow
125
+ else
126
+ begin
127
+ services = JSON.parse(docker_ps_output)
128
+ services = [services] if services.is_a?(Hash)
129
+
130
+ redis = services.find { |svc| svc["Service"] == "redis" }
131
+ status = redis && redis["State"]
132
+ health = redis && redis["Health"]
133
+
134
+ if (status == "running" && health == "healthy") || (health == "healthy")
135
+ say "✅ Redis container is running and healthy.", :green
136
+ else
137
+ say "⚠️ Redis container is not healthy yet.", :yellow
138
+ end
139
+ rescue JSON::ParserError => e
140
+ say "🚨 Failed to parse docker compose ps output as JSON: #{e.message}", :red
141
+ end
142
+ end
143
+
144
+ # 9. Check Redis readiness with retries
145
+ say "🔍 Checking Redis health with retries...", :cyan
146
+
147
+ max_attempts = 10
148
+ attempt = 0
149
+ success = false
150
+
151
+ while attempt < max_attempts
152
+ attempt += 1
153
+ healthcheck = ssh.exec!("docker exec $(docker ps -qf name=redis) redis-cli --no-auth-warning -a #{redis_defaults[:redis_password]} PING")
154
+
155
+ if healthcheck.strip == "PONG"
156
+ say "✅ Redis is up and responding to PING! (attempt #{attempt})", :green
157
+ success = true
158
+
159
+ redis_url = build_redis_url(filled_options, redis_defaults)
160
+ say "🔗 Your REDIS_URL is:\t", :cyan
161
+ say redis_url, :green
162
+ break
163
+ else
164
+ say "⏳ Redis not ready yet, retrying in 5 seconds... (#{attempt}/#{max_attempts})", :yellow
165
+ sleep 5
166
+ end
167
+ end
168
+
169
+ unless success
170
+ say "❌ Redis did not become ready after #{max_attempts} attempts.", :red
171
+ end
172
+
173
+ # 10. Allow Redis port through firewall (ufw)
174
+ say "🛡️ Configuring firewall to allow Redis (port #{redis_defaults[:redis_port]})...", :cyan
175
+ output = ssh.exec! <<~EOH
176
+ if command -v ufw >/dev/null; then
177
+ if ! sudo ufw status | grep -q "#{redis_defaults[:redis_port]}"; then
178
+ sudo ufw allow #{redis_defaults[:redis_port]}
179
+ else
180
+ echo "💡 Port #{redis_defaults[:redis_port]} is already allowed in ufw."
181
+ fi
182
+ else
183
+ echo "⚠️ ufw not found. Skipping firewall configuration."
184
+ fi
185
+ EOH
186
+ say output
187
+
188
+ say "✅ Redis setup completed successfully!", :green
189
+ end
190
+
191
+ def perform_rollback(ssh, defaults)
192
+ output = ssh.exec! <<~EOH
193
+ set -e
194
+
195
+ BASE_DIR="$HOME/docker/redis"
196
+ BACKUP_DIR="/usr/local/backups"
197
+ SCRIPT_ID="setup_redis_docker"
198
+ AFTER_FILE="${BACKUP_DIR}/${SCRIPT_ID}.after"
199
+
200
+ if [ -f "$AFTER_FILE" ]; then
201
+ echo "🔁 Stopping and removing docker containers..."
202
+ cd "$BASE_DIR"
203
+ docker compose down -v || true
204
+
205
+ echo "🧹 Cleaning up files..."
206
+ rm -rf "$BASE_DIR"
207
+ sudo rm -f "$AFTER_FILE"
208
+
209
+ if command -v ufw >/dev/null; then
210
+ echo "🛡️ Removing Redis port from firewall..."
211
+ sudo ufw delete allow #{defaults[:redis_port]} || true
212
+ fi
213
+ else
214
+ echo "💡 Nothing to rollback"
215
+ fi
216
+
217
+ echo "✅ Rollback completed"
218
+ EOH
219
+
220
+ say output
221
+ end
222
+
223
+
224
+ def upload_file(ssh, content, remote_path)
225
+ escaped = Shellwords.escape(content)
226
+ ssh.exec!("mkdir -p #{File.dirname(remote_path)}")
227
+ ssh.exec!("echo #{escaped} > #{remote_path}")
228
+ end
229
+
230
+ def build_redis_url(filled_options, redis_defaults)
231
+ password = redis_defaults[:redis_password]
232
+ host = filled_options[:server_ip]
233
+ port = redis_defaults[:redis_port]
234
+
235
+ "redis://:#{password}@#{host}:#{port}/0"
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "droplet_kit"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Commands
9
+ class Ssh < Thor
10
+ namespace "ssh"
11
+ default_task :connect
12
+
13
+ desc "connect", "Connect to a remote server via SSH"
14
+ option :ip, type: :string, desc: "Server IP address (optional)"
15
+ option :user, type: :string, default: "deploy", desc: "SSH user"
16
+ def connect
17
+ Kitsune::Kit::EnvLoader.load!
18
+
19
+ ip = options[:ip] || fetch_server_ip
20
+ user = options[:user]
21
+ key_path = Kitsune::Kit::Defaults.ssh[:ssh_key_path]
22
+
23
+ say "🔗 Connecting to #{user}@#{ip}...", :green
24
+ exec "ssh -i #{key_path} -o StrictHostKeyChecking=no #{user}@#{ip}"
25
+ end
26
+
27
+ no_commands do
28
+ def fetch_server_ip
29
+ token = ENV.fetch("DO_API_TOKEN") { abort "❌ DO_API_TOKEN is missing" }
30
+
31
+ client = DropletKit::Client.new(access_token: token)
32
+ name = Kitsune::Kit::Defaults.infra[:droplet_name]
33
+
34
+ droplet = client.droplets.all.find { |d| d.name == name }
35
+ abort "❌ Droplet '#{name}' not found on DigitalOcean" unless droplet
36
+
37
+ ip = droplet.networks.v4.find { |n| n.type == "public" }&.ip_address
38
+ abort "❌ No public IP found for droplet '#{name}'" unless ip
39
+
40
+ ip
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,11 +2,11 @@ module Kitsune
2
2
  module Kit
3
3
  module Defaults
4
4
  DROPLET = {
5
- droplet_name: "app-prod",
5
+ droplet_name: "app-dev",
6
6
  region: "sfo3",
7
7
  size: "s-1vcpu-1gb",
8
8
  image: "ubuntu-22-04-x64",
9
- tag: "rails-prod"
9
+ tag: "rails-dev"
10
10
  }.freeze
11
11
 
12
12
  SSH = {
@@ -15,11 +15,10 @@ module Kitsune
15
15
  }.freeze
16
16
 
17
17
  POSTGRES = {
18
- db_prefix: "myapp_db",
19
18
  user: "postgres",
20
19
  password: "secret",
21
20
  port: "5432",
22
- image: "postgres:15"
21
+ image: "postgres:17"
23
22
  }.freeze
24
23
 
25
24
  SYSTEM = {
@@ -32,6 +31,11 @@ module Kitsune
32
31
  enable_do_metrics: true
33
32
  }.freeze
34
33
 
34
+ REDIS = {
35
+ port: "6379",
36
+ password: "redis:7.2"
37
+ }.freeze
38
+
35
39
  def self.infra
36
40
  {
37
41
  droplet_name: ENV.fetch('DROPLET_NAME', DROPLET[:droplet_name]),
@@ -54,7 +58,7 @@ module Kitsune
54
58
  env = ENV.fetch('KIT_ENV', 'development')
55
59
 
56
60
  {
57
- postgres_db: ENV.fetch('POSTGRES_DB') { "#{POSTGRES[:db_prefix]}_#{env}" },
61
+ postgres_db: ENV.fetch('POSTGRES_DB') { "myapp_db_#{env}" },
58
62
  postgres_user: ENV.fetch('POSTGRES_USER', POSTGRES[:user]),
59
63
  postgres_password: ENV.fetch('POSTGRES_PASSWORD', POSTGRES[:password]),
60
64
  postgres_port: ENV.fetch('POSTGRES_PORT', POSTGRES[:port]),
@@ -62,6 +66,13 @@ module Kitsune
62
66
  }
63
67
  end
64
68
 
69
+ def self.redis
70
+ {
71
+ redis_port: ENV.fetch("REDIS_PORT", REDIS[:port]),
72
+ redis_password: ENV.fetch("REDIS_PASSWORD", REDIS[:password])
73
+ }
74
+ end
75
+
65
76
  def self.system
66
77
  {
67
78
  swap_size_gb: ENV.fetch("SWAP_SIZE_GB", SYSTEM[:swap_size_gb]).to_i,
@@ -18,8 +18,8 @@ module Kitsune
18
18
 
19
19
  if found
20
20
  Dotenv.load(found)
21
- puts "🧪 Loaded Kitsune environment from #{found.light_cyan}"
22
- puts "=======================================================================\n".light_cyan
21
+ puts AnsiColor.colorize("🧪 Loaded Kitsune environment from #{found}", color: :light_cyan)
22
+ puts AnsiColor.colorize("=======================================================================\n", color: :light_cyan)
23
23
  else
24
24
  puts "⚠️ No Kitsune infra config found for environment '#{env}' (looked for infra.#{env}.env and infra.env)"
25
25
  end
@@ -9,9 +9,7 @@ module Kitsune
9
9
  @size = opts[:size]
10
10
  @image = opts[:image]
11
11
  @tag = opts[:tag]
12
- @ssh_key_id = opts[:ssh_key_id] do
13
- abort "❌ You must export SSH_KEY_ID or use --ssh_key_id"
14
- end
12
+ @ssh_key_id = opts[:ssh_key_id] { abort "❌ You must export SSH_KEY_ID or use --ssh_key_id" }
15
13
 
16
14
  @client = DropletKit::Client.new(access_token: ENV.fetch("DO_API_TOKEN"))
17
15
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kitsune
4
4
  module Kit
5
- VERSION = "0.2.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitsune-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omar Herrera
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-02 00:00:00.000000000 Z
11
+ date: 2025-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-ssh
@@ -94,20 +94,6 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '1.3'
97
- - !ruby/object:Gem::Dependency
98
- name: colorize
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: 1.0.4
104
- type: :runtime
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - "~>"
109
- - !ruby/object:Gem::Version
110
- version: 1.0.4
111
97
  - !ruby/object:Gem::Dependency
112
98
  name: pry
113
99
  requirement: !ruby/object:Gem::Requirement
@@ -184,11 +170,14 @@ files:
184
170
  - kitsune-kit-logo.jpg
185
171
  - lib/kitsune/blueprints/.env.template
186
172
  - lib/kitsune/blueprints/docker/postgres.yml
173
+ - lib/kitsune/blueprints/docker/redis.yml
187
174
  - lib/kitsune/blueprints/kit.env.template
188
175
  - lib/kitsune/kit.rb
176
+ - lib/kitsune/kit/ansi_color.rb
189
177
  - lib/kitsune/kit/cli.rb
190
178
  - lib/kitsune/kit/commands/bootstrap.rb
191
179
  - lib/kitsune/kit/commands/bootstrap_docker.rb
180
+ - lib/kitsune/kit/commands/dns.rb
192
181
  - lib/kitsune/kit/commands/init.rb
193
182
  - lib/kitsune/kit/commands/install_docker_engine.rb
194
183
  - lib/kitsune/kit/commands/postinstall_docker.rb
@@ -197,9 +186,11 @@ files:
197
186
  - lib/kitsune/kit/commands/setup_docker_prereqs.rb
198
187
  - lib/kitsune/kit/commands/setup_firewall.rb
199
188
  - lib/kitsune/kit/commands/setup_postgres_docker.rb
189
+ - lib/kitsune/kit/commands/setup_redis_docker.rb
200
190
  - lib/kitsune/kit/commands/setup_swap.rb
201
191
  - lib/kitsune/kit/commands/setup_unattended.rb
202
192
  - lib/kitsune/kit/commands/setup_user.rb
193
+ - lib/kitsune/kit/commands/ssh.rb
203
194
  - lib/kitsune/kit/commands/switch_env.rb
204
195
  - lib/kitsune/kit/defaults.rb
205
196
  - lib/kitsune/kit/env_loader.rb