kitsune-kit 0.1.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.
@@ -0,0 +1,132 @@
1
+ require "thor"
2
+ require "net/ssh"
3
+ require_relative "../defaults"
4
+ require_relative "../options_builder"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Commands
9
+ class SetupFirewall < Thor
10
+ namespace "setup_firewall"
11
+
12
+ class_option :server_ip, aliases: "-s", required: true, desc: "Server IP address or hostname"
13
+ class_option :ssh_port, aliases: "-p", desc: "SSH port"
14
+ class_option :ssh_key_path, aliases: "-k", desc: "Path to your private SSH key"
15
+
16
+ desc "create", "Setup UFW firewall rules on the remote server"
17
+ def create
18
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
19
+ options,
20
+ required: [:server_ip],
21
+ defaults: Kitsune::Kit::Defaults.ssh
22
+ )
23
+
24
+ with_ssh_connection(filled_options) do |ssh|
25
+ perform_setup(ssh, filled_options)
26
+ end
27
+ end
28
+
29
+ desc "rollback", "Remove UFW firewall rules and disable UFW on the remote server"
30
+ def rollback
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_rollback(ssh, filled_options)
39
+ end
40
+ end
41
+
42
+ no_commands do
43
+ def with_ssh_connection(filled_options)
44
+ server = filled_options[:server_ip]
45
+ port = filled_options[:ssh_port]
46
+ key = File.expand_path(filled_options[:ssh_key_path])
47
+
48
+ say "🔑 Connecting as deploy@#{server}:#{port}", :green
49
+ Net::SSH.start(server, "deploy", port: port, keys: [key], non_interactive: true, timeout: 5) do |ssh|
50
+ yield ssh
51
+ end
52
+ end
53
+
54
+ def perform_setup(ssh, filled_options)
55
+ ssh_port = filled_options[:ssh_port]
56
+
57
+ output = ssh.exec! <<~EOH
58
+ set -e
59
+
60
+ echo "✍🏻 Updating repositories and ensuring UFW is installed…"
61
+ if ! dpkg -l | grep -q ufw; then
62
+ sudo apt-get update -y
63
+ sudo apt-get install -y ufw && echo " - ufw installed"
64
+ else
65
+ echo " - ufw is already installed"
66
+ fi
67
+
68
+ echo "✍🏻 Configuring UFW rules…"
69
+ add_rule() {
70
+ local rule="$1"
71
+ if ! sudo ufw status | grep -q "$rule"; then
72
+ sudo ufw allow "$rule" >/dev/null 2>&1 && echo " - rule '$rule' added"
73
+ else
74
+ echo " - rule '$rule' already exists"
75
+ fi
76
+ }
77
+ add_rule "#{ssh_port}/tcp"
78
+ add_rule "80/tcp"
79
+ add_rule "443/tcp"
80
+
81
+ echo "✍🏻 Enabling UFW logging…"
82
+ if ! sudo ufw status verbose | grep -q "Logging: on"; then
83
+ sudo ufw logging on >/dev/null 2>&1 && echo " - logging enabled"
84
+ else
85
+ echo " - logging was already enabled"
86
+ fi
87
+
88
+ echo "✍🏻 Enabling UFW…"
89
+ if sudo ufw status | grep -q "Status: inactive"; then
90
+ sudo ufw --force enable >/dev/null 2>&1 && echo " - UFW enabled"
91
+ else
92
+ echo " - UFW is already enabled"
93
+ fi
94
+ EOH
95
+ say output
96
+ say "✅ Firewall setup completed", :green
97
+ end
98
+
99
+ def perform_rollback(ssh, filled_options)
100
+ ssh_port = filled_options[:ssh_port]
101
+
102
+ output = ssh.exec! <<~EOH
103
+ set -e
104
+
105
+ echo "🔁 Removing UFW rules…"
106
+ delete_rule() {
107
+ local rule="$1"
108
+ if sudo ufw status | grep -q "$rule"; then
109
+ sudo ufw delete allow "$rule" >/dev/null 2>&1 && echo " - rule '$rule' removed"
110
+ else
111
+ echo " - rule '$rule' does not exist"
112
+ fi
113
+ }
114
+ delete_rule "#{ssh_port}/tcp"
115
+ delete_rule "80/tcp"
116
+ delete_rule "443/tcp"
117
+
118
+ echo "✍🏻 Disabling UFW if active…"
119
+ if sudo ufw status | grep -q "Status: inactive"; then
120
+ echo " - UFW is already inactive"
121
+ else
122
+ sudo ufw --force disable >/dev/null 2>&1 && echo " - UFW disabled"
123
+ fi
124
+ EOH
125
+ say output
126
+ say "✅ Firewall rollback completed", :green
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,246 @@
1
+ require "thor"
2
+ require "net/ssh"
3
+ require "tempfile"
4
+ require "fileutils"
5
+ require "shellwords"
6
+ require_relative "../defaults"
7
+ require_relative "../options_builder"
8
+
9
+ module Kitsune
10
+ module Kit
11
+ module Commands
12
+ class SetupPostgresDocker < Thor
13
+ namespace "setup_postgres_docker"
14
+
15
+ class_option :server_ip, aliases: "-s", required: true, desc: "Server IP address or hostname"
16
+ class_option :ssh_port, aliases: "-p", desc: "SSH port"
17
+ class_option :ssh_key_path, aliases: "-k", desc: "Path to SSH private key"
18
+
19
+ desc "create", "Setup PostgreSQL using Docker Compose on remote server"
20
+ def create
21
+ postgres_defaults = Kitsune::Kit::Defaults.postgres
22
+
23
+ if postgres_defaults[:postgres_password] == "secret"
24
+ say "⚠️ Warning: You are using the default PostgreSQL password ('secret').", :yellow
25
+ if ENV.fetch("KIT_ENV", "development") == "production"
26
+ abort "❌ Production environment requires a secure PostgreSQL password!"
27
+ else
28
+ say "🔒 Please change POSTGRES_PASSWORD in your .env if needed.", :yellow
29
+ end
30
+ end
31
+
32
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
33
+ options,
34
+ required: [:server_ip],
35
+ defaults: Kitsune::Kit::Defaults.ssh
36
+ )
37
+
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
44
+ end
45
+ end
46
+
47
+ desc "rollback", "Remove PostgreSQL Docker setup from remote server"
48
+ def rollback
49
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
50
+ options,
51
+ required: [:server_ip],
52
+ defaults: Kitsune::Kit::Defaults.ssh
53
+ )
54
+
55
+ with_ssh_connection(filled_options) do |ssh|
56
+ perform_rollback(ssh)
57
+ end
58
+ end
59
+
60
+ no_commands do
61
+ def with_ssh_connection(filled_options)
62
+ server = filled_options[:server_ip]
63
+ port = filled_options[:ssh_port]
64
+ key = File.expand_path(filled_options[:ssh_key_path])
65
+
66
+ say "🔑 Connecting as deploy@#{server}:#{port}", :green
67
+ Net::SSH.start(server, "deploy", port: port, keys: [key], non_interactive: true, timeout: 5) do |ssh|
68
+ yield ssh
69
+ end
70
+ end
71
+
72
+ def perform_setup(ssh, postgres_defaults)
73
+ docker_compose_local = ".kitsune/docker/postgres.yml"
74
+ unless File.exist?(docker_compose_local)
75
+ say "❌ Docker compose file not found at #{docker_compose_local}.", :red
76
+ exit(1)
77
+ end
78
+
79
+ docker_dir_remote = "$HOME/docker/postgres"
80
+ docker_compose_remote = "#{docker_dir_remote}/docker-compose.yml"
81
+ docker_env_remote = "#{docker_dir_remote}/.env"
82
+ backup_marker = "/usr/local/backups/setup_postgres_docker.after"
83
+
84
+ # 1. Create base directory securely
85
+ ssh.exec!("mkdir -p #{docker_dir_remote}")
86
+ ssh.exec!("chmod 700 #{docker_dir_remote}")
87
+
88
+ # 2. Upload docker-compose.yml
89
+ say "📦 Uploading docker-compose.yml to remote server...", :cyan
90
+ content_compose = File.read(docker_compose_local)
91
+ upload_file(ssh, content_compose, docker_compose_remote)
92
+
93
+ # 3. Create .env file for docker-compose based on postgres_defaults
94
+ say "📦 Creating .env file for Docker Compose...", :cyan
95
+ env_content = <<~ENVFILE
96
+ POSTGRES_DB=#{postgres_defaults[:postgres_db]}
97
+ POSTGRES_USER=#{postgres_defaults[:postgres_user]}
98
+ POSTGRES_PASSWORD=#{postgres_defaults[:postgres_password]}
99
+ POSTGRES_PORT=#{postgres_defaults[:postgres_port]}
100
+ POSTGRES_IMAGE=#{postgres_defaults[:postgres_image]}
101
+ ENVFILE
102
+ upload_file(ssh, env_content, docker_env_remote)
103
+
104
+ # 4. Secure file permissions
105
+ ssh.exec!("chmod 600 #{docker_compose_remote} #{docker_env_remote}")
106
+
107
+ # 5. Create backup marker
108
+ ssh.exec!("sudo mkdir -p /usr/local/backups && sudo touch #{backup_marker}")
109
+
110
+ # 6. Validate docker-compose.yml
111
+ say "🔍 Validating docker-compose.yml...", :cyan
112
+ validation_output = ssh.exec!("cd #{docker_dir_remote} && docker compose config")
113
+ say validation_output, :cyan
114
+
115
+ # 7. Check if container is running
116
+ container_status = ssh.exec!("docker ps --filter 'name=postgres' --format '{{.Status}}'").strip
117
+
118
+ if container_status.empty?
119
+ say "▶️ No running container. Running docker compose up...", :cyan
120
+ ssh.exec!("cd #{docker_dir_remote} && docker compose up -d")
121
+ else
122
+ say "⚠️ PostgreSQL container is already running.", :yellow
123
+ if yes?("🔁 Recreate the container with updated configuration? [y/N]", :yellow)
124
+ say "🔄 Recreating container...", :cyan
125
+ ssh.exec!("cd #{docker_dir_remote} && docker compose down -v && docker compose up -d")
126
+ else
127
+ say "⏩ Keeping existing container.", :cyan
128
+ end
129
+ end
130
+
131
+ say "📋 Final container status (docker compose ps):", :cyan
132
+ docker_ps_output = ssh.exec!("cd #{docker_dir_remote} && docker compose ps --format json")
133
+
134
+ if docker_ps_output.nil? || docker_ps_output.strip.empty? || docker_ps_output.include?("no configuration file")
135
+ say "⚠️ docker compose ps returned no valid output.", :yellow
136
+ else
137
+ begin
138
+ services = JSON.parse(docker_ps_output)
139
+ services = [services] if services.is_a?(Hash)
140
+
141
+ postgres = services.find { |svc| svc["Service"] == "postgres" }
142
+ status = postgres && postgres["State"]
143
+ health = postgres && postgres["Health"]
144
+
145
+ if (status == "running" && health == "healthy") || (health == "healthy")
146
+ say "✅ PostgreSQL container is running and healthy.", :green
147
+ else
148
+ say "⚠️ PostgreSQL container is not healthy yet.", :yellow
149
+ end
150
+ rescue JSON::ParserError => e
151
+ say "🚨 Failed to parse docker compose ps output as JSON: #{e.message}", :red
152
+ end
153
+ end
154
+
155
+ # 9. Check PostgreSQL readiness with retries
156
+ say "🔍 Checking PostgreSQL health with retries...", :cyan
157
+
158
+ max_attempts = 10
159
+ attempt = 0
160
+ success = false
161
+
162
+ while attempt < max_attempts
163
+ attempt += 1
164
+ healthcheck = ssh.exec!("docker exec $(docker ps -qf name=postgres) pg_isready -U #{postgres_defaults[:postgres_user]} -d #{postgres_defaults[:postgres_db]} -h localhost")
165
+
166
+ if healthcheck.include?("accepting connections")
167
+ say "✅ PostgreSQL is up and accepting connections! (attempt #{attempt})", :green
168
+ success = true
169
+ break
170
+ else
171
+ say "⏳ Attempt #{attempt}/#{max_attempts}: PostgreSQL not ready yet, retrying in 5 seconds...", :yellow
172
+ sleep 5
173
+ end
174
+ end
175
+
176
+ unless success
177
+ say "❌ PostgreSQL did not become ready after #{max_attempts} attempts.", :red
178
+ end
179
+
180
+ # 10. Allow PostgreSQL port through firewall (ufw)
181
+ say "🛡️ Configuring firewall to allow PostgreSQL (port #{postgres_defaults[:postgres_port]})...", :cyan
182
+ firewall = <<~EOH
183
+ if command -v ufw >/dev/null; then
184
+ if ! sudo ufw status | grep -q "#{postgres_defaults[:postgres_port]}"; then
185
+ sudo ufw allow #{postgres_defaults[:postgres_port]}
186
+ else
187
+ echo "🔸 Port #{postgres_defaults[:postgres_port]} is already allowed in ufw."
188
+ fi
189
+ else
190
+ echo "⚠️ ufw not found. Skipping firewall configuration."
191
+ fi
192
+ EOH
193
+ ssh.exec!(firewall)
194
+ end
195
+
196
+ def perform_rollback(ssh)
197
+ output = ssh.exec! <<~EOH
198
+ set -e
199
+
200
+ BASE_DIR="$HOME/docker/postgres"
201
+ BACKUP_DIR="/usr/local/backups"
202
+ SCRIPT_ID="setup_postgres_docker"
203
+ AFTER_FILE="${BACKUP_DIR}/${SCRIPT_ID}.after"
204
+
205
+ if [ -f "$AFTER_FILE" ]; then
206
+ echo "🔁 Stopping and removing docker containers..."
207
+ cd "$BASE_DIR"
208
+ docker compose down -v || true
209
+
210
+ echo "🧹 Cleaning up files..."
211
+ rm -rf "$BASE_DIR"
212
+ sudo rm -f "$AFTER_FILE"
213
+
214
+ if command -v ufw >/dev/null; then
215
+ echo "🛡️ Removing PostgreSQL port from firewall..."
216
+ sudo ufw delete allow 5432 || true
217
+ fi
218
+ else
219
+ echo "🔸 Nothing to rollback"
220
+ fi
221
+
222
+ echo "✅ Rollback completed"
223
+ EOH
224
+ say output
225
+ end
226
+
227
+ def upload_file(ssh, content, remote_path)
228
+ escaped_content = Shellwords.escape(content)
229
+ ssh.exec!("mkdir -p #{File.dirname(remote_path)}")
230
+ ssh.exec!("echo #{escaped_content} > #{remote_path}")
231
+ end
232
+
233
+ def build_database_url(filled_options, postgres_defaults)
234
+ user = postgres_defaults[:postgres_user]
235
+ password = postgres_defaults[:postgres_password]
236
+ host = filled_options[:server_ip]
237
+ port = postgres_defaults[:postgres_port]
238
+ db = postgres_defaults[:postgres_db]
239
+
240
+ "postgres://#{user}:#{password}@#{host}:#{port}/#{db}"
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,132 @@
1
+ require "thor"
2
+ require "net/ssh"
3
+ require_relative "../defaults"
4
+ require_relative "../options_builder"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Commands
9
+ class SetupUnattended < Thor
10
+ namespace "setup_unattended"
11
+
12
+ class_option :server_ip, aliases: "-s", required: true, desc: "Server IP address or hostname"
13
+ class_option :ssh_port, aliases: "-p", desc: "SSH port"
14
+ class_option :ssh_key_path, aliases: "-k", desc: "Path to your private SSH key"
15
+
16
+ desc "create", "Configure unattended-upgrades on the remote server"
17
+ def create
18
+ filled_options = Kitsune::Kit::OptionsBuilder.build(
19
+ options,
20
+ required: [:server_ip],
21
+ defaults: Kitsune::Kit::Defaults.ssh
22
+ )
23
+
24
+ with_ssh_connection(filled_options) do |ssh|
25
+ perform_setup(ssh)
26
+ end
27
+ end
28
+
29
+ desc "rollback", "Revert unattended-upgrades configuration on the remote server"
30
+ def rollback
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_rollback(ssh)
39
+ end
40
+ end
41
+
42
+ no_commands do
43
+ def with_ssh_connection(filled_options)
44
+ server = filled_options[:server_ip]
45
+ port = filled_options[:ssh_port]
46
+ key = File.expand_path(filled_options[:ssh_key_path])
47
+
48
+ say "🔑 Connecting as deploy@#{server}:#{port}", :green
49
+ Net::SSH.start(server, "deploy", port: port, keys: [key], non_interactive: true, timeout: 5) do |ssh|
50
+ yield ssh
51
+ end
52
+ end
53
+
54
+ def perform_setup(ssh)
55
+ output = ssh.exec! <<~EOH
56
+ set -e
57
+
58
+ sudo mkdir -p /usr/local/backups
59
+ sudo chown deploy:deploy /usr/local/backups
60
+
61
+ RESOURCE="/etc/apt/apt.conf.d/20auto-upgrades"
62
+ BACKUP_DIR="/usr/local/backups"
63
+ SCRIPT_ID="setup_unattended"
64
+ BACKUP_FILE="${BACKUP_DIR}/${SCRIPT_ID}.before"
65
+ MARKER_FILE="${BACKUP_DIR}/${SCRIPT_ID}.after"
66
+
67
+ echo "✍🏻 Installing required packages…"
68
+ if ! dpkg -l | grep -q "^ii\\s*unattended-upgrades"; then
69
+ sudo apt-get update -y
70
+ sudo apt-get install -y unattended-upgrades apt-listchanges && echo " - packages installed"
71
+ else
72
+ echo " - unattended-upgrades already installed"
73
+ fi
74
+
75
+ if [ ! -f "$MARKER_FILE" ]; then
76
+ echo "✍🏻 Backing up existing config…"
77
+ sudo cp "$RESOURCE" "$BACKUP_FILE" && echo " - backup saved to $BACKUP_FILE"
78
+ sudo touch "$MARKER_FILE" && echo " - marker created at $MARKER_FILE"
79
+ else
80
+ echo " - backup & marker already exist"
81
+ fi
82
+
83
+ echo "✍🏻 Applying new auto-upgrades config…"
84
+ sudo tee "$RESOURCE" > /dev/null <<UPGR
85
+ APT::Periodic::Update-Package-Lists "1";
86
+ APT::Periodic::Download-Upgradeable-Packages "1";
87
+ APT::Periodic::AutocleanInterval "7";
88
+ APT::Periodic::Unattended-Upgrade "1";
89
+ UPGR
90
+ echo " - config applied"
91
+
92
+ echo "✍🏻 Enabling & restarting unattended-upgrades…"
93
+ sudo systemctl --quiet enable unattended-upgrades.service >/dev/null 2>&1 && echo " - service enabled"
94
+ sudo systemctl --quiet restart unattended-upgrades.service && echo " - service restarted"
95
+ EOH
96
+ say output
97
+ say "✅ Unattended-upgrades setup completed", :green
98
+ end
99
+
100
+ def perform_rollback(ssh)
101
+ output = ssh.exec! <<~EOH
102
+ set -e
103
+
104
+ sudo mkdir -p /usr/local/backups
105
+ sudo chown deploy:deploy /usr/local/backups
106
+
107
+ RESOURCE="/etc/apt/apt.conf.d/20auto-upgrades"
108
+ BACKUP_DIR="/usr/local/backups"
109
+ SCRIPT_ID="setup_unattended"
110
+ BACKUP_FILE="${BACKUP_DIR}/${SCRIPT_ID}.before"
111
+ MARKER_FILE="${BACKUP_DIR}/${SCRIPT_ID}.after"
112
+
113
+ if [ -f "$MARKER_FILE" ]; then
114
+ echo "🔁 Restoring original auto-upgrades config…"
115
+ sudo mv "$BACKUP_FILE" "$RESOURCE" && echo " - config restored from $BACKUP_FILE"
116
+ sudo rm -f "$MARKER_FILE" && echo " - marker $MARKER_FILE removed"
117
+ else
118
+ echo " - no marker for $SCRIPT_ID, skipping restore"
119
+ fi
120
+
121
+ echo "✍🏻 Stopping & disabling unattended-upgrades…"
122
+ sudo systemctl --quiet stop unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer && echo " - services stopped"
123
+ sudo systemctl --quiet disable unattended-upgrades.service && echo " - service disabled"
124
+ EOH
125
+ say output
126
+ say "✅ Unattended-upgrades rollback completed", :green
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end