fly-rails 0.3.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,92 @@
1
+ require 'rake'
2
+
3
+ module Fly
4
+ module DSL
5
+ class Base
6
+ include Rake::DSL
7
+
8
+ @@blocks = {}
9
+
10
+ def initialize
11
+ @value = {}
12
+ end
13
+
14
+ def self.block name, kind
15
+ @@blocks[name] = kind
16
+
17
+ define_method name do |&block|
18
+ @config[name] ||= kind.new
19
+ @config[name].instance_eval(&block) if block
20
+ @config[name]
21
+ end
22
+ end
23
+
24
+ def self.blocks
25
+ @@blocks
26
+ end
27
+
28
+ def self.option name, default=nil
29
+ @options ||= {}
30
+ @options[name] = default
31
+
32
+ define_method name do |*args|
33
+ if args.length == 1
34
+ @value[name] = args.first
35
+ elsif args.length > 1
36
+ raise ArgumentError.new("wrong number of arguments (given #{args.length}, expected 0..1)")
37
+ end
38
+
39
+ @value.include?(name) ? @value[name] : default
40
+ end
41
+ end
42
+
43
+ def self.options
44
+ @options ||= {}
45
+ end
46
+ end
47
+
48
+ #############################################################
49
+
50
+ class Machine < Base
51
+ option :cpus, 1
52
+ option :cpu_kind, 'shared'
53
+ option :memory_mb, 256
54
+ end
55
+
56
+ class Postgres < Base
57
+ option :vm_size, 'shared-cpu-1x'
58
+ option :volume_size, 1
59
+ option :initial_cluster_size, 1
60
+ end
61
+
62
+ class Redis < Base
63
+ option :plan, "Free"
64
+ end
65
+
66
+ class Sqlite3 < Base
67
+ option :size, 3
68
+ end
69
+
70
+ class Deploy < Base
71
+ option :swap_mb, 0
72
+ end
73
+
74
+ #############################################################
75
+
76
+ class Config < Base
77
+
78
+ def initialize
79
+ @config = {}
80
+ super
81
+ end
82
+
83
+ option :image, nil
84
+
85
+ block :machine, Machine
86
+ block :postgres, Postgres
87
+ block :redis, Redis
88
+ block :sqlite3, Sqlite3
89
+ block :deploy, Deploy
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,3 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/channel/channel_generator'
3
+ require 'rails/generators/job/job_generator'
@@ -0,0 +1,99 @@
1
+ require 'strscan'
2
+
3
+ module Fly
4
+ # a very liberal HCL scanner
5
+ module HCL
6
+ def self.parse(string)
7
+ result = []
8
+ name = nil
9
+ stack = []
10
+ block = {}
11
+ cursor = block
12
+ result.push block
13
+
14
+ hcl = StringScanner.new(string)
15
+ until hcl.eos?
16
+ hcl.scan(%r{\s*(\#.*|//.*|/\*[\S\s]*?\*/)*})
17
+
18
+ if hcl.scan(/[a-zA-Z]\S*|\d[.\d]*|"[^"]*"/)
19
+ if cursor.is_a? Array
20
+ cursor.push token(hcl.matched)
21
+ elsif name == nil
22
+ name = token(hcl.matched)
23
+ else
24
+ hash = {}
25
+ cursor[name] = hash
26
+ name = token(hcl.matched)
27
+ cursor = hash
28
+ end
29
+ elsif hcl.scan(/=/)
30
+ hcl.scan(/\s*/)
31
+ if hcl.scan(/\[/)
32
+ list = []
33
+ cursor[name] = list
34
+ name = nil
35
+ stack.push cursor
36
+ cursor = list
37
+ elsif hcl.scan(/\{/)
38
+ hash = {}
39
+ cursor[name] = hash
40
+ name = nil
41
+ stack.push cursor
42
+ cursor = hash
43
+ elsif hcl.scan(/.*/)
44
+ cursor[name] = token(hcl.matched)
45
+ name = nil
46
+ end
47
+ elsif hcl.scan(/\{/)
48
+ hash = {}
49
+ if cursor.is_a? Array
50
+ cursor << hash
51
+ else
52
+ cursor[name] = hash
53
+ end
54
+ name = nil
55
+ stack.push cursor
56
+ cursor = hash
57
+ elsif hcl.scan(/\[/)
58
+ list = []
59
+ stack.push cursor
60
+ cursor = list
61
+ elsif hcl.scan(/\}|\]/)
62
+ cursor = stack.pop
63
+
64
+ if stack.empty?
65
+ block = {}
66
+ cursor = block
67
+ result.push block
68
+ end
69
+ elsif hcl.scan(/[,=:]/)
70
+ nil
71
+ elsif hcl.scan(/.*/)
72
+ unless hcl.matched.empty?
73
+ STDERR.puts "unexpected input: #{hcl.matched.inspect}"
74
+ end
75
+ end
76
+ end
77
+
78
+ result.pop if result.last.empty?
79
+ result
80
+ end
81
+
82
+ private
83
+ def self.token(match)
84
+ if match =~ /^\d/
85
+ if match =~ /^\d+$/
86
+ match.to_i
87
+ else
88
+ match.to_f
89
+ end
90
+ elsif match =~ /^\w+$/
91
+ match.to_sym
92
+ elsif match =~ /^"(.*)"$/
93
+ $1
94
+ else
95
+ match
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,209 @@
1
+ require 'net/http'
2
+ require 'json'
3
+
4
+ module Fly
5
+ # Thin wrapper over https://fly.io/docs/reference/machines/
6
+ #
7
+ # *** WARNING ***
8
+ #
9
+ # No validation or escaping is done by this code. It is assuming that
10
+ # the caller is trusted and does pass through unsanitized user input.
11
+ #
12
+ module Machines
13
+ @@api_token = ENV['FLY_API_TOKEN']
14
+ @@fly_api_hostname = nil
15
+
16
+ # determine fly api hostname. Returns nil if no proxy is running
17
+ def self.fly_api_hostname
18
+ return @@fly_api_hostname if @@fly_api_hostname
19
+
20
+ Net::HTTP.get URI('http://_api.internal:4280')
21
+ @@fly_api_hostname = '_api.internal:4280'
22
+ rescue
23
+ begin
24
+ Net::HTTP.get URI('http://127.0.0.1:4280')
25
+ @@fly_api_hostname = '127.0.0.1:4280'
26
+ rescue
27
+ nil
28
+ end
29
+ end
30
+
31
+ # determine application's organization
32
+ def self.org
33
+ org = 'personal'
34
+
35
+ if File.exist? 'fly.toml'
36
+ require 'toml'
37
+ app = TOML.load_file('fly.toml')['app']
38
+
39
+ apps = JSON.parse(`flyctl list apps --json`) rescue []
40
+
41
+ apps.each do |info|
42
+ org = info['Organization'] if info['ID'] == app
43
+ end
44
+ end
45
+
46
+ org
47
+ end
48
+
49
+ # determine fly api hostname. Starts proxy if necessary
50
+ def self.fly_api_hostname!
51
+ hostname = fly_api_hostname
52
+ return hostname if hostname
53
+
54
+ pid = fork { exec "flyctl machines api-proxy --org #{org}" }
55
+ at_exit { Process.kill "INT", pid }
56
+
57
+ # wait up to 12.7 seconds for the proxy to start
58
+ wait = 0.1
59
+ 6.times do
60
+ sleep wait
61
+ begin
62
+ Net::HTTP.get URI('http://127.0.0.1:4280')
63
+ @@fly_api_hostname = '127.0.0.1:4280'
64
+ break
65
+ rescue
66
+ wait *= 2
67
+ end
68
+ end
69
+
70
+ @@fly_api_hostname
71
+ end
72
+
73
+ # create_fly_application app_name: 'user-functions', org_slug: 'personal'
74
+ def self.create_fly_application options
75
+ post '/v1/apps', options
76
+ end
77
+
78
+ # get_application_details 'user-functions'
79
+ def self.get_application_details app
80
+ get "/v1/apps/#{app}"
81
+ end
82
+
83
+ # create_and_start_machine 'user-functions', name: 'quirky_machine', config: {
84
+ # image: 'flyio/fastify-functions',
85
+ # env: {'APP_ENV' => 'production'},
86
+ # services: [
87
+ # {
88
+ # ports: [
89
+ # {port: 443, handlers: ['tls', 'http']},
90
+ # {port: 80, handlers: ['http']}
91
+ # ],
92
+ # protocol: 'tcp',
93
+ # internal_protocol: 'tcp',
94
+ # }
95
+ # ]
96
+ # }
97
+ def self.create_and_start_machine app, options
98
+ post "/v1/apps/#{app}/machines", options
99
+ end
100
+
101
+ # wait_for_machine 'user-functions', '73d8d46dbee589'
102
+ def self.wait_for_machine app, machine, options = {timeout:60, status: 'started'}
103
+ get "/v1/apps/#{app}/machines/#{machine}/wait?#{options.to_query}"
104
+ end
105
+
106
+ # get_a_machine machine 'user-functions', '73d8d46dbee589'
107
+ def self.get_a_machine app, machine
108
+ get "/v1/apps/#{app}/machines/#{machine}"
109
+ end
110
+
111
+ # update_a_machine 'user-functions', '73d8d46dbee589', config: {
112
+ # image: 'flyio/fastify-functions',
113
+ # guest: { memory_mb: 512, cpus: 2 }
114
+ # }
115
+ def self.update_a_machine app, machine, options
116
+ post "/v1/apps/#{app}/machines/#{machine}", options
117
+ end
118
+
119
+ # stop_machine machine 'user-functions', '73d8d46dbee589'
120
+ def self.stop_machine app, machine
121
+ post "/v1/apps/#{app}/machines/#{machine}/stop"
122
+ end
123
+
124
+ # start_machine machine 'user-functions', '73d8d46dbee589'
125
+ def self.start_machine app, machine
126
+ post "/v1/apps/#{app}/machines/#{machine}/stop"
127
+ end
128
+
129
+ # delete_machine machine 'user-functions', '73d8d46dbee589'
130
+ def self.delete_machine app, machine
131
+ delete "/v1/apps/#{app}/machines/#{machine}"
132
+ end
133
+
134
+ # list_machines machine 'user-functions'
135
+ def self.list_machines app, machine
136
+ get "/v1/apps/#{app}/machines"
137
+ end
138
+
139
+ # delete_application 'user-functions'
140
+ def self.delete_application app, force=false
141
+ delete "/v1/apps/#{app}?force=#{force}"
142
+ end
143
+
144
+ # generic get
145
+ def self.get(path)
146
+ api(path) {|uri| request = Net::HTTP::Get.new(uri) }
147
+ end
148
+
149
+ # generic post
150
+ def self.post(path, hash=nil)
151
+ api(path) do |uri|
152
+ request = Net::HTTP::Post.new(uri)
153
+ request.body = hash.to_json if hash
154
+ request
155
+ end
156
+ end
157
+
158
+ # generic delete
159
+ def self.delete(path)
160
+ api(path) {|uri| request = Net::HTTP::Delete.new(uri) }
161
+ end
162
+
163
+ # graphql -- see https://til.simonwillison.net/fly/undocumented-graphql-api
164
+ def self.graphql(query)
165
+ api('/graphql', 'https://api.fly.io/') do |path|
166
+ request = Net::HTTP::Post.new(path)
167
+ request.body = { query: query }.to_json
168
+ request
169
+ end
170
+ end
171
+
172
+ # common processing for all APIs
173
+ def self.api(path, host=nil, &make_request)
174
+ host ||= "http://#{fly_api_hostname}"
175
+ uri = URI.join(host, path)
176
+ http = Net::HTTP.new(uri.host, uri.port)
177
+ http.set_debug_output $stderr if ENV['TRACE']
178
+ http.use_ssl = true if uri.instance_of? URI::HTTPS
179
+
180
+ request = make_request.call(uri.request_uri)
181
+
182
+ @@api_token ||= `flyctl auth token`.chomp
183
+ headers = {
184
+ "Authorization" => "Bearer #{@@api_token}",
185
+ "Content-Type" => "application/json",
186
+ "Accept" => "application/json"
187
+ }
188
+ headers.each {|header, value| request[header] = value}
189
+
190
+ response = http.request(request)
191
+
192
+ if response.is_a? Net::HTTPSuccess
193
+ JSON.parse response.body, symbolize_names: true
194
+ else
195
+ body = response.body
196
+ begin
197
+ error = JSON.parse(body)
198
+ rescue
199
+ error = {body: body}
200
+ end
201
+
202
+ error[:status] = response.code
203
+ error[:message] = response.message
204
+
205
+ error
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,10 @@
1
+ module Fly
2
+ PLATFORMS = {
3
+ 'Linux_arm64' => 'aarch64-linux',
4
+ 'Linux_x86_64' => 'x86-linux',
5
+ 'macOS_arm64' => 'arm64-darwin',
6
+ 'macOS_x86_64' => 'x86_64-darwin',
7
+ 'Windows_arm64' => nil, # Can't find a match
8
+ 'Windows_x86_64' => 'x64-mingw32'
9
+ }
10
+ end
@@ -0,0 +1,68 @@
1
+ module Fly
2
+ module Scanner
3
+ # scan for major features - things that if present will likely affect
4
+ # more than one artifact that is generated.
5
+ def scan_rails_app
6
+
7
+ ### database ###
8
+
9
+ database = YAML.load_file('config/database.yml').
10
+ dig('production', 'adapter') rescue nil
11
+
12
+ if database == 'sqlite3'
13
+ @sqlite3 = true
14
+ elsif database == 'postgresql'
15
+ @postgresql = true
16
+ elsif database == 'mysql' or database == 'mysql2'
17
+ @mysql = true
18
+ end
19
+
20
+ ### ruby gems ###
21
+
22
+ @gemfile = []
23
+
24
+ if File.exist? 'Gemfile.lock'
25
+ parser = Bundler::LockfileParser.new(Bundler.read_file('Gemfile.lock'))
26
+ @gemfile += parser.specs.map { |spec, version| spec.name }
27
+ end
28
+
29
+ if File.exist? 'Gemfile'
30
+ @gemfile += Bundler::Definition.build('Gemfile', nil, []).dependencies.map(&:name)
31
+ end
32
+
33
+ @sidekiq = @gemfile.include? 'sidekiq'
34
+ @anycable = @gemfile.include? 'anycable-rails'
35
+ @rmagick = @gemfile.include? 'rmagick'
36
+ @image_processing = @gemfile.include? 'image_processing'
37
+ @bootstrap = @gemfile.include? 'bootstrap'
38
+ @puppeteer = @gemfile.include? 'puppeteer'
39
+
40
+ ### node modules ###
41
+
42
+ @package_json = []
43
+
44
+ if File.exist? 'package.json'
45
+ @package_json += JSON.load_file('package.json')['dependencies'].keys rescue []
46
+ end
47
+
48
+ @puppeteer ||= @package_json.include? 'puppeteer'
49
+
50
+ ### cable/redis ###
51
+
52
+ @cable = ! Dir['app/channels/*.rb'].empty?
53
+
54
+ if @cable
55
+ @redis_cable = true
56
+ if (YAML.load_file('config/cable.yml').dig('production', 'adapter') rescue '').include? 'any_cable'
57
+ @anycable = true
58
+ end
59
+ end
60
+
61
+ if (IO.read('config/environments/production.rb') =~ /redis/i rescue false)
62
+ @redis_cache = true
63
+ end
64
+
65
+ @redis = @redis_cable || @redis_cache || @sidekiq
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,66 @@
1
+ begin
2
+ require 'pty'
3
+ rescue LoadError
4
+ # Presumably Windows
5
+ end
6
+
7
+ module FlyIoRails
8
+ module Utils
9
+
10
+ def tee cmd
11
+ say_status :run, cmd if defined? say_status
12
+ FlyIoRails::Utils.tee cmd
13
+ end
14
+
15
+ def self.tee cmd
16
+ data = []
17
+
18
+ if defined? PTY
19
+ begin
20
+ # PTY supports ANSI cursor control and colors
21
+ PTY.spawn(cmd) do |read, write, pid|
22
+ begin
23
+ read.each do |line|
24
+ print line
25
+ data << line
26
+ end
27
+ rescue Errno::EIO
28
+ end
29
+ end
30
+ rescue PTY::ChildExited
31
+ end
32
+ else
33
+ # no support for ANSI cursor control and colors
34
+ Open3.popen2e(cmd) do |stdin, out, thread|
35
+ out.each do |line|
36
+ print line
37
+ data << line
38
+ end
39
+ end
40
+ end
41
+
42
+ data.join
43
+ end
44
+
45
+ def create_app(name: nil, org: 'personal', regions: [], nomad: false, **rest)
46
+ cmd = if name
47
+ "flyctl apps create #{name.inspect} --org #{org.inspect} --machines"
48
+ else
49
+ "flyctl apps create --generate-name --org #{org.inspect} --machines"
50
+ end
51
+
52
+ cmd.sub! ' --machines', '' if nomad
53
+
54
+ output = tee cmd
55
+ exit 1 unless output =~ /^New app created: /
56
+
57
+ @app = output.split.last
58
+
59
+ unless regions.empty?
60
+ @regions = regions.flatten
61
+ end
62
+
63
+ @app
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ module Fly
2
+ VERSION = '0.3.5'
3
+ end
data/lib/fly-rails.rb ADDED
@@ -0,0 +1,27 @@
1
+ require 'rails'
2
+ require 'fly-rails/generators'
3
+ require 'fly-rails/utils'
4
+
5
+ class FlyIoRailtie < Rails::Railtie
6
+ # load rake tasks
7
+ rake_tasks do
8
+ Dir[File.expand_path('tasks/*.rake', __dir__)].each do |file|
9
+ load file
10
+ end
11
+ end
12
+
13
+ # set FLY_IMAGE_NAME on Nomad VMs
14
+ if not ENV['FLY_IMAGE_REF'] and ENV['FLY_APP_NAME'] and ENV['FLY_API_TOKEN']
15
+ require 'fly-rails/machines'
16
+
17
+ ENV['FLY_IMAGE_REF'] = Fly::Machines.graphql(%{
18
+ query {
19
+ app(name: "#{ENV['FLY_APP_NAME']}") {
20
+ currentRelease {
21
+ imageRef
22
+ }
23
+ }
24
+ }
25
+ }).dig(:data, :app, :currentRelease, :imageRef)
26
+ end
27
+ end
@@ -0,0 +1,72 @@
1
+ require 'fly-rails/actions'
2
+
3
+ module Fly::Generators
4
+ class AppGenerator < Rails::Generators::Base
5
+ include FlyIoRails::Utils
6
+
7
+ class_option :name, type: :string, required: false
8
+ class_option :org, type: :string, default: 'personal'
9
+ class_option :region, type: :array, repeatable: true, default: []
10
+ class_option :nomad, type: :boolean, default: false
11
+ class_option :eject, type: :boolean, default: nil
12
+
13
+ class_option :anycable, type: :boolean, default: false
14
+ class_option :avahi, type: :boolean, default: false
15
+ class_option :litefs, type: :boolean, default: false
16
+ class_option :nats, type: :boolean, default: false
17
+ class_option :redis, type: :boolean, default: false
18
+ class_option :passenger, type: :boolean, default: false
19
+ class_option :serverless, type: :boolean, default: false
20
+
21
+ def generate_app
22
+ source_paths.push File.expand_path('../templates', __dir__)
23
+
24
+ # the plan is to make eject an option, default to false, but until
25
+ # that is ready, have generate fly:app always eject
26
+ opts = options.to_h.symbolize_keys
27
+ opts[:eject] = opts[:nomad] if opts[:eject] == nil
28
+
29
+ if File.exist? 'fly.toml'
30
+ toml = TOML.load_file('fly.toml')
31
+ opts[:name] ||= toml['app']
32
+ apps = JSON.parse(`flyctl list apps --json`) rescue []
33
+
34
+ if toml.keys.length == 1
35
+ if opts[:name] != toml['app']
36
+ # replace existing fly.toml
37
+ File.unlink 'fly.toml'
38
+ create_app(**opts.symbolize_keys)
39
+ elsif not apps.any? {|item| item['ID'] == opts[:name]}
40
+ create_app(**opts.symbolize_keys)
41
+ end
42
+ elsif opts[:name] != toml['app']
43
+ say_status "fly:app", "Using the name in the existing toml file", :red
44
+ opts[:name] = toml['app']
45
+ end
46
+ else
47
+ create_app(**opts.symbolize_keys)
48
+ end
49
+
50
+ action = Fly::Actions.new(@app, opts)
51
+
52
+ action.generate_toml
53
+ action.generate_fly_config unless File.exist? 'config/fly.rb'
54
+
55
+ if opts[:eject]
56
+ action.generate_dockerfile
57
+ action.generate_dockerignore unless File.exist? '.dockerignore'
58
+ action.generate_nginx_conf unless File.exist? 'config/nginx.conf'
59
+ action.generate_raketask unless File.exist? 'lib/tasks/fly.rake'
60
+ action.generate_procfile unless File.exist? 'Procfile.rake'
61
+ action.generate_litefs if opts[:litefs] and not File.exist? 'config/litefs'
62
+ action.generate_patches
63
+ end
64
+
65
+ ips = `flyctl ips list`.strip.lines[1..].map(&:split).map(&:first)
66
+ action.generate_ipv4 unless ips.include? 'v4'
67
+ action.generate_ipv6 unless ips.include? 'v6'
68
+
69
+ action.launch(action.app)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,19 @@
1
+ require 'fly-rails/actions'
2
+
3
+ module Fly::Generators
4
+ class ConfigGenerator < Rails::Generators::Base
5
+ include FlyIoRails::Utils
6
+
7
+ # despite its name, this is a debug tool that will dump the config
8
+ def generate_config
9
+ action = Fly::Actions.new(@app, options)
10
+
11
+ config = {}
12
+ action.instance_variables.sort.each do |name|
13
+ config[name] = action.instance_variable_get(name)
14
+ end
15
+
16
+ pp config
17
+ end
18
+ end
19
+ end