tayo 0.2.2 → 0.3.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,90 @@
1
+ # Solid Cable + SQLite 최적화 설정
2
+
3
+ > 이 문서는 `tayo sqlite` 명령어로 자동 생성되었습니다.
4
+
5
+ ## 개요
6
+
7
+ 이 프로젝트는 Redis 없이 SQLite만으로 Action Cable 실시간 기능을 구현합니다.
8
+ Rails 8에서 도입된 Solid Cable을 사용하여 WebSocket 브로드캐스트를 처리합니다.
9
+
10
+ ## 왜 Solid Cable인가?
11
+
12
+ ### Redis vs Solid Cable
13
+
14
+ | 항목 | Redis | Solid Cable |
15
+ |------|-------|-------------|
16
+ | 의존성 | Redis 서버 필요 | DB만 사용 |
17
+ | 방식 | Pub/Sub (푸시) | Polling (폴링) |
18
+ | 지연시간 | ~1ms | ~25ms (설정값) |
19
+ | 운영 복잡도 | Redis 관리 필요 | 단순 |
20
+
21
+ ### 체감 성능
22
+
23
+ - **25ms 폴링**: 사용자가 지연을 체감하기 어려움 (200ms 이하는 "즉각적"으로 느껴짐)
24
+ - **RTT 비교**: Redis ~80ms vs Solid Cable ~85ms (전체 왕복 시간 기준, 거의 동일)
25
+
26
+ ## 적용된 설정
27
+
28
+ ### 1. 데이터베이스 분리 (database.yml)
29
+
30
+ ```yaml
31
+ production:
32
+ primary:
33
+ database: storage/production.sqlite3
34
+ cable:
35
+ database: storage/production_cable.sqlite3 # 별도 DB
36
+ ```
37
+
38
+ **이유**: Cable 폴링이 메인 DB의 쓰기 작업과 락 경합하지 않도록 분리
39
+
40
+ ### 2. Cable 설정 (cable.yml)
41
+
42
+ ```yaml
43
+ development:
44
+ adapter: async # 단일 프로세스, 콘솔 디버깅 용이
45
+
46
+ production:
47
+ adapter: solid_cable
48
+ polling_interval: 0.025.seconds # 25ms
49
+ message_retention: 1.hour
50
+ ```
51
+
52
+ **폴링 간격 선택 기준**:
53
+ - 100ms: 기본값, 대부분 충분
54
+ - 25ms: 채팅/실시간 앱에 적합
55
+ - 10ms: Redis 수준, 부하 증가
56
+
57
+ ### 3. SQLite WAL 모드 최적화 (initializer)
58
+
59
+ ```ruby
60
+ # config/initializers/solid_cable_sqlite.rb
61
+ PRAGMA journal_mode=WAL # 읽기/쓰기 동시 처리
62
+ PRAGMA synchronous=NORMAL # 쓰기 성능 향상
63
+ PRAGMA cache_size=4000 # 캐시 증가
64
+ ```
65
+
66
+ **WAL 모드 장점**:
67
+ - 읽기(폴링)와 쓰기(브로드캐스트)가 동시에 가능
68
+ - Solid Cable의 폴링 패턴에 최적화
69
+
70
+ ## 성능 가이드
71
+
72
+ ### 적정 사용 범위
73
+
74
+ | 동시 접속 | 메시지/초 | 예상 지연 |
75
+ |----------|----------|----------|
76
+ | ~50명 | ~10 | 거의 없음 |
77
+ | ~100명 | ~20 | ~50ms |
78
+ | ~500명 | ~100 | 검토 필요 |
79
+
80
+ ### 스케일 아웃이 필요한 경우
81
+
82
+ - 초당 200개 이상 메시지
83
+ - 1000명 이상 동시 접속
84
+ - → Redis 또는 PostgreSQL NOTIFY 고려
85
+
86
+ ## 참고 자료
87
+
88
+ - [Solid Cable GitHub](https://github.com/rails/solid_cable)
89
+ - [SQLite WAL Mode](https://sqlite.org/wal.html)
90
+ - [Rails 8 Release Notes](https://rubyonrails.org/2024/11/7/rails-8-no-paas-required)
data/lib/tayo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tayo
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tayo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 이원섭wonsup Lee/Alfonso
@@ -92,22 +92,17 @@ files:
92
92
  - CHANGELOG.md
93
93
  - README.md
94
94
  - Rakefile
95
+ - docs/2025-11-29-cf-command-refactor.md
95
96
  - exe/tayo
96
97
  - lib/tayo.rb
97
98
  - lib/tayo/cli.rb
98
99
  - lib/tayo/commands/cf.rb
99
100
  - lib/tayo/commands/gh.rb
100
101
  - lib/tayo/commands/init.rb
101
- - lib/tayo/commands/proxy.rb
102
+ - lib/tayo/commands/sqlite.rb
102
103
  - lib/tayo/dockerfile_modifier.rb
103
- - lib/tayo/proxy/cloudflare_client.rb
104
- - lib/tayo/proxy/docker_manager.rb
105
- - lib/tayo/proxy/network_config.rb
106
- - lib/tayo/proxy/traefik_config.rb
107
- - lib/tayo/proxy/welcome_service.rb
104
+ - lib/tayo/templates/solid-cable-sqlite-setup.md
108
105
  - lib/tayo/version.rb
109
- - lib/templates/welcome/Dockerfile
110
- - lib/templates/welcome/index.html
111
106
  - pkg/homebody-0.1.0.gem
112
107
  - repomix-output.xml
113
108
  - sig/tayo.rbs
@@ -131,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
126
  - !ruby/object:Gem::Version
132
127
  version: '0'
133
128
  requirements: []
134
- rubygems_version: 3.7.1
129
+ rubygems_version: 4.0.3
135
130
  specification_version: 4
136
131
  summary: Rails deployment tool for home servers
137
132
  test_files: []
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "colorize"
4
- require "tty-prompt"
5
- require_relative "../proxy/cloudflare_client"
6
- require_relative "../proxy/docker_manager"
7
- require_relative "../proxy/network_config"
8
- require_relative "../proxy/traefik_config"
9
- require_relative "../proxy/welcome_service"
10
-
11
- module Tayo
12
- module Commands
13
- class Proxy
14
- def execute
15
- puts "🚀 Kamal Proxy와 Caddy 설정을 시작합니다...".colorize(:green)
16
- puts ""
17
-
18
- # 1. Cloudflare 설정
19
- cloudflare = Tayo::Proxy::CloudflareClient.new
20
- cloudflare.ensure_token
21
-
22
- # 2. 네트워크 설정
23
- network = Tayo::Proxy::NetworkConfig.new
24
- network.detect_ips
25
- network.configure_ports
26
-
27
- # 3. Docker 확인
28
- docker = Tayo::Proxy::DockerManager.new
29
- docker.check_containers
30
-
31
- # 4. 도메인 선택
32
- selected_domains = cloudflare.select_domains
33
-
34
- if selected_domains.empty?
35
- puts "❌ 도메인이 선택되지 않았습니다. 종료합니다.".colorize(:red)
36
- return
37
- end
38
-
39
- # 5. DNS 설정
40
- puts "\n📝 DNS 레코드를 설정합니다...".colorize(:yellow)
41
- cloudflare.setup_dns_records(selected_domains, network.public_ip)
42
-
43
- # 6. Welcome 서비스 확인
44
- welcome = Tayo::Proxy::WelcomeService.new
45
- welcome.ensure_running
46
-
47
- # 7. Traefik 설정
48
- traefik = Tayo::Proxy::TraefikConfig.new
49
- traefik.setup(selected_domains)
50
-
51
- # 8. 최종 안내
52
- show_summary(selected_domains, network)
53
-
54
- rescue => e
55
- puts "❌ 오류가 발생했습니다: #{e.message}".colorize(:red)
56
- puts e.backtrace.join("\n") if ENV["DEBUG"]
57
- exit 1
58
- end
59
-
60
- private
61
-
62
- def show_summary(domains, network)
63
- puts "\n" + "="*60
64
- puts "✅ Proxy 설정이 완료되었습니다!".colorize(:green)
65
- puts "="*60
66
-
67
- puts "\n📋 설정 요약:".colorize(:yellow)
68
- puts "━"*40
69
- puts "공인 IP: #{network.public_ip}".colorize(:white)
70
- puts "내부 IP: #{network.internal_ip}".colorize(:white)
71
- puts "Traefik: 80, 443 포트 사용 중".colorize(:white)
72
- puts "대시보드: http://localhost:8080".colorize(:white)
73
- puts "━"*40
74
-
75
- puts "\n🌐 활성화된 도메인:".colorize(:yellow)
76
- domains.each do |domain|
77
- if network.use_custom_ports?
78
- puts "• #{domain}".colorize(:cyan)
79
- puts " HTTP: http://#{domain}:#{network.external_http}".colorize(:gray)
80
- puts " HTTPS: https://#{domain}:#{network.external_https}".colorize(:gray)
81
- else
82
- puts "• #{domain}".colorize(:cyan)
83
- puts " HTTP: http://#{domain}".colorize(:gray)
84
- puts " HTTPS: https://#{domain}".colorize(:gray)
85
- end
86
- end
87
-
88
- if network.use_custom_ports?
89
- puts "\n💡 공유기 포트포워딩을 설정하세요!".colorize(:yellow)
90
- network.show_port_forwarding_guide
91
- end
92
-
93
- puts "\n🎉 모든 설정이 완료되었습니다!".colorize(:green)
94
- end
95
- end
96
- end
97
- end
@@ -1,323 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "colorize"
4
- require "tty-prompt"
5
- require "net/http"
6
- require "json"
7
- require "uri"
8
- require "fileutils"
9
-
10
- module Tayo
11
- module Proxy
12
- class CloudflareClient
13
- TOKEN_FILE = File.expand_path("~/.tayo/cloudflare_token")
14
-
15
- def initialize
16
- @prompt = TTY::Prompt.new
17
- end
18
-
19
- def ensure_token
20
- @token = load_saved_token
21
-
22
- if @token
23
- puts "🔑 저장된 Cloudflare 토큰을 확인합니다...".colorize(:yellow)
24
-
25
- if test_token(@token)
26
- puts "✅ 저장된 Cloudflare 토큰이 유효합니다.".colorize(:green)
27
- else
28
- puts "⚠️ 저장된 토큰이 만료되었거나 유효하지 않습니다.".colorize(:yellow)
29
- puts "새 토큰을 입력해주세요.".colorize(:cyan)
30
- open_token_creation_page
31
- @token = get_cloudflare_token
32
- save_token(@token)
33
- end
34
- else
35
- puts "\n🔑 Cloudflare API 토큰이 필요합니다.".colorize(:yellow)
36
- open_token_creation_page
37
- @token = get_cloudflare_token
38
- save_token(@token)
39
- end
40
- end
41
-
42
- def select_domains
43
- puts "\n🌐 Cloudflare 도메인 목록을 조회합니다...".colorize(:yellow)
44
-
45
- zones = get_cloudflare_zones
46
-
47
- if zones.empty?
48
- puts "❌ Cloudflare에 등록된 도메인이 없습니다.".colorize(:red)
49
- puts "먼저 https://dash.cloudflare.com 에서 도메인을 추가해주세요.".colorize(:cyan)
50
- return []
51
- end
52
-
53
- zone_choices = zones.map { |zone| "#{zone['name']} (#{zone['status']})" }
54
-
55
- selected_zones = @prompt.multi_select(
56
- "Caddy로 라우팅할 도메인을 선택하세요 (Space로 선택, Enter로 완료):",
57
- zone_choices,
58
- per_page: 10,
59
- min: 1
60
- )
61
-
62
- # 선택된 항목이 없으면 빈 배열 반환
63
- return [] if selected_zones.nil? || selected_zones.empty?
64
-
65
- puts "DEBUG: selected_zones = #{selected_zones.inspect}" if ENV["DEBUG"]
66
-
67
- selected_domains = selected_zones.map do |selection|
68
- zone_name = selection.split(' ').first
69
- zone = zones.find { |z| z['name'] == zone_name }
70
-
71
- # 서브도메인 추가 여부 확인
72
- if @prompt.yes?("#{zone_name}에 서브도메인을 추가하시겠습니까?")
73
- subdomain = @prompt.ask("서브도메인을 입력하세요 (예: app, api):")
74
- if subdomain && !subdomain.empty?
75
- {
76
- domain: "#{subdomain}.#{zone_name}",
77
- zone_id: zone['id'],
78
- zone_name: zone_name
79
- }
80
- else
81
- {
82
- domain: zone_name,
83
- zone_id: zone['id'],
84
- zone_name: zone_name
85
- }
86
- end
87
- else
88
- {
89
- domain: zone_name,
90
- zone_id: zone['id'],
91
- zone_name: zone_name
92
- }
93
- end
94
- end
95
-
96
- puts "\n✅ 선택된 도메인:".colorize(:green)
97
- selected_domains.each do |d|
98
- puts " • #{d[:domain]}".colorize(:cyan)
99
- end
100
-
101
- puts "DEBUG: returning selected_domains = #{selected_domains.inspect}" if ENV["DEBUG"]
102
-
103
- selected_domains
104
- end
105
-
106
- def setup_dns_records(domains, public_ip)
107
- domains.each do |domain_info|
108
- domain = domain_info[:domain]
109
- zone_id = domain_info[:zone_id]
110
-
111
- puts "\n⚙️ #{domain}의 DNS 레코드를 설정합니다...".colorize(:yellow)
112
-
113
- # 기존 A 레코드 확인
114
- existing_records = get_dns_records(zone_id, domain, ['A'])
115
-
116
- if existing_records.any?
117
- existing_record = existing_records.first
118
- if existing_record['content'] == public_ip
119
- puts "✅ #{domain} → #{public_ip} (이미 설정됨)".colorize(:green)
120
- else
121
- puts "⚠️ 기존 레코드를 업데이트합니다.".colorize(:yellow)
122
- update_dns_record(zone_id, existing_record['id'], public_ip)
123
- end
124
- else
125
- # 새 A 레코드 생성
126
- create_dns_record(zone_id, domain, 'A', public_ip)
127
- end
128
- end
129
- end
130
-
131
- private
132
-
133
- def load_saved_token
134
- return nil unless File.exist?(TOKEN_FILE)
135
- File.read(TOKEN_FILE).strip
136
- rescue
137
- nil
138
- end
139
-
140
- def save_token(token)
141
- dir = File.dirname(TOKEN_FILE)
142
-
143
- # 디렉토리가 파일로 존재하는 경우 삭제
144
- if File.exist?(dir) && !File.directory?(dir)
145
- FileUtils.rm(dir)
146
- end
147
-
148
- # 디렉토리가 없을 때만 생성
149
- FileUtils.mkdir_p(dir) unless File.directory?(dir)
150
-
151
- File.write(TOKEN_FILE, token)
152
- File.chmod(0600, TOKEN_FILE)
153
- puts "✅ API 토큰이 저장되었습니다.".colorize(:green)
154
- end
155
-
156
- def open_token_creation_page
157
- puts "토큰 생성 페이지를 엽니다...".colorize(:cyan)
158
- system("open 'https://dash.cloudflare.com/profile/api-tokens' 2>/dev/null || xdg-open 'https://dash.cloudflare.com/profile/api-tokens' 2>/dev/null")
159
-
160
- puts "\n다음 권한으로 토큰을 생성해주세요:".colorize(:yellow)
161
- puts ""
162
- puts "한국어 화면:".colorize(:gray)
163
- puts "• 영역 → 영역 → 읽기".colorize(:white)
164
- puts "• 영역 → DNS → 편집".colorize(:white)
165
- puts " (영역 리소스는 '모든 영역' 선택)".colorize(:gray)
166
- puts ""
167
- puts "English:".colorize(:gray)
168
- puts "• Zone → Zone → Read".colorize(:white)
169
- puts "• Zone → DNS → Edit".colorize(:white)
170
- puts " (Zone Resources: Select 'All zones')".colorize(:gray)
171
- puts ""
172
- end
173
-
174
- def get_cloudflare_token
175
- token = @prompt.mask("생성된 Cloudflare API 토큰을 붙여넣으세요:")
176
-
177
- if token.nil? || token.strip.empty?
178
- puts "❌ 토큰이 입력되지 않았습니다.".colorize(:red)
179
- exit 1
180
- end
181
-
182
- if test_token(token.strip)
183
- puts "✅ 토큰이 확인되었습니다.".colorize(:green)
184
- return token.strip
185
- else
186
- puts "❌ 토큰이 올바르지 않거나 권한이 부족합니다.".colorize(:red)
187
- exit 1
188
- end
189
- end
190
-
191
- def test_token(token)
192
- uri = URI('https://api.cloudflare.com/client/v4/user/tokens/verify')
193
- http = Net::HTTP.new(uri.host, uri.port)
194
- http.use_ssl = true
195
-
196
- request = Net::HTTP::Get.new(uri)
197
- request['Authorization'] = "Bearer #{token}"
198
- request['Content-Type'] = 'application/json'
199
-
200
- response = http.request(request)
201
-
202
- if response.code == '200'
203
- data = JSON.parse(response.body)
204
- return data['success'] == true
205
- else
206
- return false
207
- end
208
- rescue => e
209
- puts "⚠️ 토큰 검증 중 오류: #{e.message}".colorize(:yellow) if ENV["DEBUG"]
210
- false
211
- end
212
-
213
- def get_cloudflare_zones
214
- uri = URI('https://api.cloudflare.com/client/v4/zones')
215
- http = Net::HTTP.new(uri.host, uri.port)
216
- http.use_ssl = true
217
-
218
- request = Net::HTTP::Get.new(uri)
219
- request['Authorization'] = "Bearer #{@token}"
220
- request['Content-Type'] = 'application/json'
221
-
222
- response = http.request(request)
223
-
224
- if response.code == '200'
225
- data = JSON.parse(response.body)
226
- return data['result'] || []
227
- else
228
- puts "❌ 도메인 목록 조회에 실패했습니다: #{response.code}".colorize(:red)
229
- []
230
- end
231
- rescue => e
232
- puts "❌ API 요청 중 오류가 발생했습니다: #{e.message}".colorize(:red)
233
- []
234
- end
235
-
236
- def get_dns_records(zone_id, name, types)
237
- records = []
238
-
239
- types.each do |type|
240
- uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
241
- uri.query = URI.encode_www_form({
242
- type: type,
243
- name: name
244
- })
245
-
246
- http = Net::HTTP.new(uri.host, uri.port)
247
- http.use_ssl = true
248
-
249
- request = Net::HTTP::Get.new(uri)
250
- request['Authorization'] = "Bearer #{@token}"
251
- request['Content-Type'] = 'application/json'
252
-
253
- response = http.request(request)
254
-
255
- if response.code == '200'
256
- data = JSON.parse(response.body)
257
- records.concat(data['result'] || [])
258
- end
259
- end
260
-
261
- records
262
- rescue => e
263
- puts "⚠️ DNS 레코드 조회 중 오류: #{e.message}".colorize(:yellow)
264
- []
265
- end
266
-
267
- def create_dns_record(zone_id, name, type, content)
268
- uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
269
- http = Net::HTTP.new(uri.host, uri.port)
270
- http.use_ssl = true
271
-
272
- request = Net::HTTP::Post.new(uri)
273
- request['Authorization'] = "Bearer #{@token}"
274
- request['Content-Type'] = 'application/json'
275
-
276
- data = {
277
- type: type,
278
- name: name,
279
- content: content,
280
- ttl: 300,
281
- proxied: false
282
- }
283
-
284
- request.body = data.to_json
285
- response = http.request(request)
286
-
287
- if response.code == '200'
288
- puts "✅ #{name} → #{content} (A 레코드 생성됨)".colorize(:green)
289
- else
290
- puts "❌ DNS 레코드 생성 실패: #{response.code}".colorize(:red)
291
- puts response.body if ENV["DEBUG"]
292
- end
293
- rescue => e
294
- puts "❌ DNS 레코드 생성 중 오류: #{e.message}".colorize(:red)
295
- end
296
-
297
- def update_dns_record(zone_id, record_id, new_content)
298
- uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records/#{record_id}")
299
- http = Net::HTTP.new(uri.host, uri.port)
300
- http.use_ssl = true
301
-
302
- request = Net::HTTP::Patch.new(uri)
303
- request['Authorization'] = "Bearer #{@token}"
304
- request['Content-Type'] = 'application/json'
305
-
306
- data = {
307
- content: new_content
308
- }
309
-
310
- request.body = data.to_json
311
- response = http.request(request)
312
-
313
- if response.code == '200'
314
- puts "✅ DNS 레코드가 업데이트되었습니다.".colorize(:green)
315
- else
316
- puts "❌ DNS 레코드 업데이트 실패: #{response.code}".colorize(:red)
317
- end
318
- rescue => e
319
- puts "❌ DNS 레코드 업데이트 중 오류: #{e.message}".colorize(:red)
320
- end
321
- end
322
- end
323
- end