fly-rails 0.3.5

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,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