commit 6fda5da1d219fd1d0df227783298d275f2f5a1e4 Author: Loic Nageleisen Date: Wed Jan 3 15:55:09 2024 +0100 Add minimal apps diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb8f7ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +/log/ +/tmp/ diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..4a4726a --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use_nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b903e52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/log/ +/tmp/ +/vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d76330e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +ARG RUBY_VERSION + +FROM ruby:${RUBY_VERSION} + +# bash: for consistency +# tzdata: rails +# gcompat: nokogiri & al. +# Error loading shared library ld-linux-x86-64.so.2: No such file or directory +# Error loading shared library ld-linux-aarch64.so.1: No such file or directory +RUN if [ -f /etc/alpine-release ]; then apk add build-base bash tzdata && if ! grep -e '^3\.8' /etc/alpine-release; then apk add gcompat; fi; fi + +RUN <<-SHELL + case ${RUBY_VERSION} in + 2.1*|2.2*) + gem update --system '2.7.11' + gem install bundler -v '~> 1.17.3' + ;; + 2.3*|2.4*) + # rails 4.1 and 4.2 need bundler < 2.0 + gem update --system '2.7.11' + gem install bundler -v '~> 1.17.3' + ;; + 2.5*) + gem update --system '3.3.27' + gem install bundler -v '~> 2.3.27' + ;; + 2.6*|2.7*) + gem update --system '3.4.22' + gem install bundler -v '~> 2.4.22' + ;; + *) + gem update --system + gem install bundler + ;; + esac +SHELL diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2039a71 --- /dev/null +++ b/Rakefile @@ -0,0 +1,244 @@ +def resolve_args(args) + require 'yaml' + + compatibility = YAML.load(File.read('compatibility')) + a = args.to_a + + filename = nil + ruby_image = nil + while (arg = a.shift) + candidate = File.basename(arg, '.rb') + + if (c = compatibility[candidate.split(':')[0]]) + kind = candidate.split(':')[0] + version = candidate.split(':')[1] # TODO: nil? + compatibility_match = c.select do |e| + # TODO: corner cases + # does not match on 3 if only ~> 3.2, e.g rails + # matches on 4 if ~> 4.0 and ~> 4.2 but will resolve to 4.2 but with 4.0 compat match (wrong ruby, wrong gem...) + Gem::Requirement.new(e['version']).satisfied_by?(Gem::Version.new(version)) + end.tap do |m| + if m.empty? + raise ArgumentError, "unmatched requirement for #{kind}:#{version}" + elsif !m.one? + raise ArgumentError, "ambiguous version range for #{kind}:#{version}" + end + end.first + elsif candidate =~ /^(\d+\.\d+(?:\.\d+|))$/ + ruby_version = $1 + ruby_libc = 'gnu' + elsif candidate =~ /^(\d+\.\d+(?:\.\d+|))-alpine$/ + ruby_version = $1 + ruby_libc = 'musl' + elsif %w[x86_64 aarch64].include?(candidate) + ruby_cpu = candidate + elsif %w[musl alpine].include?(candidate) + ruby_libc = 'musl' + elsif %w[gnu debian].include?(candidate) + ruby_libc = 'gnu' + elsif %w[puma unicorn rainbows thin falcon webrick].include?(candidate) + server = candidate + else + raise ArgumentError, "unsupported arg: #{arg}" + end + end + + if server.nil? + server = 'thin' + end + + if kind.nil? + if ruby_version.nil? + kind = compatibility.keys.first # TODO: pick a better one than .first + compatibility_match = compatibility[kind].first # TODO: pick a better one than .first + # TODO: get kind version (for serve), like having kind specified but versionless + else + match = compatibility.each_with_object([]) do |(k, c), m| + version_matches = c.select do |e| + Gem::Requirement.new(e['ruby']).satisfied_by?(Gem::Version.new(ruby_version)) + end + + m << [k, version_matches] if version_matches.any? + end.first # TODO: pick a better one than .first + + if match.nil? + raise ArgumentError, "unmatched requirement for ruby:#{ruby_version}" + end + + compatibility_match = match[1].first # TODO: pick a better one than .first + # TODO: get kind version (for serve), like having kind specified but versionless + kind = match[0] + end + end + + if filename.nil? + filename = compatibility_match['main'] || kind + '.rb' + end + + if ruby_version.nil? + match = %w[2.1 2.2 2.3 2.4 2.5 2.6 2.7 3.0 3.1 3.2 3.3].map { |v| Gem::Version.new(v) }.select do |v| + Gem::Requirement.new(compatibility_match['ruby']).satisfied_by?(v) + end.max + + if match.nil? + raise ArgumentError, "unmatched requirement for ruby with #{kind}:#{version}" + end + + ruby_version = match.to_s + end + + if ruby_cpu.nil? + if RUBY_PLATFORM =~ /^(?:universal\.|)(x86_64|aarch64|arm64)/ + ruby_cpu = $1.sub(/arm64(:?e|)/, 'aarch64') + else + raise ArgumentError, "unsupported platform: #{RUBY_PLATFORM}" + end + end + + ruby_os = 'linux' + + if ruby_libc.nil? + ruby_libc = 'gnu' + end + + ruby_platform = "#{ruby_cpu}-#{ruby_os}-#{ruby_libc}" + + if ruby_image.nil? + if ruby_libc == 'musl' + ruby_image = ruby_version + '-alpine' + else + ruby_image = ruby_version + end + end + + { + version: version, + ruby_image: ruby_image, + ruby_version: ruby_version, + filename: filename, + server: server, + ruby_platform: ruby_platform, + }.tap { |r| p r } +end + +def satisfied?(result, deps = []) + result_time = case result + when String + File.ctime(result).to_datetime + when Proc + result.call + else + raise ArgumentError, "invalid type: #{dep.class}" + end + + return false if result_time.nil? + return true if deps.empty? + + deps.map do |dep| + dep_time = case dep + when String + File.ctime(dep).to_datetime + when Proc + dep.call + else + raise ArgumentError, "invalid type: #{dep.class}" + end + + result_time > dep_time + end.reduce(:&) +end + +namespace :docker do + def image(env) + "sandbox/minimal:ruby-#{env[:ruby_image]}" + end + + def volume(env) + "sandbox-minimal-ruby-#{env[:ruby_image]}-#{env[:ruby_platform]}" + end + + def docker_platform(env) + env[:ruby_platform].split('-').take(2).reverse.join('/') + end + + def image_time(image) + require 'time' + + last_tag_time = `docker image inspect -f '{{ .Metadata.LastTagTime }}' '#{image}'`.chomp + + if $?.to_i == 0 + DateTime.strptime(last_tag_time, '%Y-%m-%d %H:%M:%S.%N %z') + else + nil + end + end + + def volume_time(volume) + require 'time' + + volume_creation_time = `docker volume inspect -f '{{ .CreatedAt }}' '#{volume}'`.chomp + + if $?.to_i == 0 + DateTime.strptime(volume_creation_time, '%Y-%m-%dT%H:%M:%S%z') + else + nil + end + end + + namespace :image do + task :build do |_task, args| + env = resolve_args(args) + + deps = [ + 'Dockerfile' + ] + + next if satisfied?(-> { image_time(image(env)) }, deps) + + sh "docker buildx build --platform #{docker_platform(env)} -f Dockerfile --build-arg RUBY_VERSION='#{env[:ruby_image]}' --tag '#{image(env)}' ." + end + + task :clean do |_task, args| + env = resolve_args(args) + + sh "docker image rm '#{image(env)}'" + end + end + + namespace :volume do + task :create do |_task, args| + env = resolve_args(args) + + next if satisfied?(-> () { volume_time(volume(env))} ) + + sh "docker volume create #{volume(env)}" + end + + task :clean do |_task, args| + env = resolve_args(args) + + sh "docker volume rm '#{volume(env)}'" + end + end + + task :build => :'docker:image:build' + task :volume => :'docker:volume:create' + + task :ruby => [:build, :volume] do |_task, args| + env = resolve_args(args) + + sh "docker run --rm -it --platform #{docker_platform(env)} -v '#{volume(env)}':'/usr/local/bundle' -v '#{Dir.pwd}':'#{Dir.pwd}' -w '#{Dir.pwd}' '#{image(env)}'" + end + + task :shell => [:build, :volume] do |_task, args| + env = resolve_args(args) + + sh "docker run --rm -it --platform #{docker_platform(env)} -v '#{volume(env)}':'/usr/local/bundle' -v '#{Dir.pwd}':'#{Dir.pwd}' -w '#{Dir.pwd}' '#{image(env)}' /bin/bash" + end + + task :serve => [:build, :volume] do |_task, args| + env = resolve_args(args) + + sh "docker run --rm -it --platform #{docker_platform(env)} -v '#{volume(env)}':'/usr/local/bundle' -v '#{Dir.pwd}':'#{Dir.pwd}' -w '#{Dir.pwd}' -p 3000:3000 '#{image(env)}' ruby '#{env[:filename]}' '#{env[:version]}' '#{env[:server]}'" + end +end diff --git a/compatibility b/compatibility new file mode 100644 index 0000000..2ffb646 --- /dev/null +++ b/compatibility @@ -0,0 +1,83 @@ +rack: + - version: '~> 1.3' + ruby: ['>= 1.8.7', '< 3.0'] + - version: '~> 2.0' + ruby: '>= 2.3.0' + - version: '~> 3.0' + ruby: '>= 2.4.0' + gem: + rackup: '>= 0' + # thin: false +sinatra: + - version: '~> 1.0' + ruby: ['>= 1.8.7', '< 3.0'] + gem: + rack: '< 2.0' + - version: '~> 2.0' + ruby: '>= 2.3.0' + - version: '~> 3.0' + ruby: '>= 2.6.0' +rails: + - version: '~> 3.2.0' + ruby: ['>= 1.8.7', '< 2.4'] + gem: + bundler: '< 2.0' + - version: '~> 4.0.0' + ruby: ['>= 1.9.3', '< 2.3'] + gem: + bundler: '< 2.0' + - version: '~> 4.1.0' + ruby: ['>= 1.9.3', '< 2.4'] + gem: + bundler: '< 2.0' + - version: '~> 4.2.0' + ruby: ['>= 1.9.3', '< 2.5'] + gem: + loofah: '~> 2.19.1' # solve Nokogiri::HTML4 exception + bundler: '< 2.0' + - version: '~> 5.0.0' + ruby: ['>= 2.2.2', '< 2.5'] + gem: + loofah: '~> 2.19.1' # solve Nokogiri::HTML4 exception + - version: '~> 5.1.0' + ruby: ['>= 2.2.2', '< 2.6'] + - version: '~> 5.2.0' + ruby: ['>= 2.2.2', '< 2.7'] + - version: '~> 6.0.0' + # 2.7 excluded because stringio defaults to 3.0 + ruby: ['>= 2.5', '< 2.7'] + gem: + # superclass mismatch for class StringIO (TypeError) + stringio: '< 3.0' + - version: '~> 6.1.0' + # 3.3 has a bug: https://bugs.ruby-lang.org/issues/20085 + ruby: ['>= 2.5', '< 3.3'] + - version: '~> 7.0.0' + # 3.3 has a bug: https://bugs.ruby-lang.org/issues/20085 + ruby: ['>= 2.7', '< 3.3'] + - version: '~> 7.1.0' + # 3.3 has a bug: https://bugs.ruby-lang.org/issues/20085 + ruby: ['>= 2.7', '< 3.3'] +grape: + - version: ['~> 1.0', '< 1.3'] + ruby: ['>= 2.0', '< 3.0'] + gem: + activesupport: '< 7.0' + rack: '< 2.0' + - version: ['~> 1.3', '< 1.6'] + ruby: ['>= 2.4', '< 3.0'] + gem: + activesupport: '< 7.0' + rack: '< 3.0' + - version: ['~> 1.6', '< 1.8'] + ruby: ['>= 2.5'] + gem: + rackup: '>= 0' + - version: ['~> 1.8', '< 2.0'] + ruby: ['>= 2.6'] + gem: + rackup: '>= 0' + - version: '~> 2.0' + ruby: ['>= 2.6'] + gem: + rackup: '>= 0' diff --git a/grape.rb b/grape.rb new file mode 100644 index 0000000..c3afa8d --- /dev/null +++ b/grape.rb @@ -0,0 +1,73 @@ +begin + require "bundler/inline" +rescue LoadError => e + $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" + raise e +end + +require 'yaml' +compatibility = YAML.load(File.read('compatibility')) + +kind = File.basename(__FILE__, '.rb') +version = ARGV[0] || raise(ArgumentError, 'missing version') +match = compatibility[kind].select do |e| + Gem::Requirement.new(e['version']).satisfied_by?(Gem::Version.new(version)) +end.tap do |m| + if m.empty? + raise ArgumentError, "unmatched requirement for #{kind}:#{version}" + elsif !m.one? + raise ArgumentError, "ambiguous version range for #{kind}:#{version}" + end +end.first +server = ARGV[1] || 'thin' + +gemfile(true) do + source "https://rubygems.org" + + ruby match['ruby'] + + gem 'grape', "~> #{version}.0" + gem server + + match.fetch('gem', []).each do |name, requirement| + gem name, requirement + end +end + +require 'rack' +require 'grape' + +class API < Grape::API + version 'v0', using: :header, vendor: 'hello' + format :json + prefix :hello + + get :world do + { hello: 'world' } + end + + #resource :hello do + # route_param :id do + # get do + # { hello: 'world' } + # end + # end + #end + + # mount API::Sub + # mount API::V1 => '/v1' +end + +App = Rack::Builder.new do + # precompile routes + API.compile! + + run API + + # sinatra: + # use Rack::Session::Cookie + # run Rack::Cascade.new [Web, API] +end + +Rack::Server.new(app: App, Host: '0.0.0.0', Port: 3000).start + diff --git a/rack.rb b/rack.rb new file mode 100644 index 0000000..02b6be4 --- /dev/null +++ b/rack.rb @@ -0,0 +1,46 @@ +begin + require "bundler/inline" +rescue LoadError => e + $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" + raise e +end + +require 'yaml' +compatibility = YAML.load(File.read('compatibility')) + +kind = File.basename(__FILE__, '.rb') +version = ARGV[0] || raise(ArgumentError, 'missing version') +match = compatibility[kind].select do |e| + Gem::Requirement.new(e['version']).satisfied_by?(Gem::Version.new(version)) +end.tap do |m| + if m.empty? + raise ArgumentError, "unmatched requirement for #{kind}:#{version}" + elsif !m.one? + raise ArgumentError, "ambiguous version range for #{kind}:#{version}" + end +end.first +server = ARGV[1] || 'thin' + +gemfile(true) do + source "https://rubygems.org" + + ruby match['ruby'] + + gem 'rack', "~> #{version}.0" + gem server + + match.fetch('gem', []).each do |name, requirement| + gem name, requirement + end +end + +require 'rack' +require 'json' + +App = Rack::Builder.new do + map "/hello/world" do + run -> (env) { [200, { 'content-type' => 'application/json' }, [JSON.dump({ hello: :world })]] } + end +end + +Rack::Server.new(app: App, Host: '0.0.0.0', Port: 3000).start diff --git a/rails.rb b/rails.rb new file mode 100644 index 0000000..0176424 --- /dev/null +++ b/rails.rb @@ -0,0 +1,68 @@ +begin + require "bundler/inline" +rescue LoadError => e + $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" + raise e +end + +require 'yaml' +compatibility = YAML.load(File.read('compatibility')) + +kind = File.basename(__FILE__, '.rb') +version = ARGV[0] || raise(ArgumentError, 'missing version') +match = compatibility[kind].select do |e| + Gem::Requirement.new(e['version']).satisfied_by?(Gem::Version.new(version)) +end.tap do |m| + if m.empty? + raise ArgumentError, "unmatched requirement for #{kind}:#{version}" + elsif !m.one? + raise ArgumentError, "ambiguous version range for #{kind}:#{version}" + end +end.first +server = ARGV[1] || 'thin' + +gemfile(true) do + source "https://rubygems.org" + + ruby match['ruby'] + + gem 'rails', "~> #{version}.0" + gem server + + match.fetch('gem', []).each do |name, requirement| + gem name, requirement + end +end + +require "action_controller/railtie" + +class App < Rails::Application + routes.append do + get "/hello/world" => "hello#world" + end + + config.consider_all_requests_local = true # display errors + config.eager_load = true # load everything + + if Gem::Requirement.new('< 4.0').satisfied_by?(Gem.loaded_specs['rails'].version) + config.secret_token = 'a4e6df27-2f39-41e4-83d2-3bc4d087c910' + else + config.secret_key_base = 'a4e6df27-2f39-41e4-83d2-3bc4d087c910' + end +end + +if Gem::Requirement.new('< 5.0').satisfied_by?(Gem.loaded_specs['rails'].version) + action_controller_api_class = ActionController::Base +else + action_controller_api_class = ActionController::API +end + +class HelloController < action_controller_api_class + def world + render json: {hello: :world} + end +end + +App.initialize! + +Rack::Server.new(app: App, Host: '0.0.0.0', Port: 3000).start diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..c2451a9 --- /dev/null +++ b/shell.nix @@ -0,0 +1,25 @@ +{ + pkgs ? import {}, +}: +let + ruby = pkgs.ruby_3_2; + llvm = pkgs.llvmPackages_16; + gcc = pkgs.gcc13; +in llvm.stdenv.mkDerivation { + name = "sandbox-minimal.shell"; + + buildInputs = [ + ruby + + # for psych >= 5.1 pulled by rails 7.1 + pkgs.libyaml.dev + ]; + + + shellHook = '' + export RUBY_VERSION="$(ruby -e 'puts RUBY_VERSION.gsub(/\d+$/, "0")')" + export GEM_HOME="$(pwd)/vendor/bundle/ruby/$RUBY_VERSION" + export BUNDLE_PATH="$(pwd)/vendor/bundle" + export PATH="$GEM_HOME/bin:$PATH" + ''; +} diff --git a/sinatra.rb b/sinatra.rb new file mode 100644 index 0000000..2d76517 --- /dev/null +++ b/sinatra.rb @@ -0,0 +1,48 @@ +begin + require "bundler/inline" +rescue LoadError => e + $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" + raise e +end + +require 'yaml' +compatibility = YAML.load(File.read('compatibility')) + +kind = File.basename(__FILE__, '.rb') +version = ARGV[0] || raise(ArgumentError, 'missing version') +match = compatibility[kind].select do |e| + Gem::Requirement.new(e['version']).satisfied_by?(Gem::Version.new(version)) +end.tap do |m| + if m.empty? + raise ArgumentError, "unmatched requirement for #{kind}:#{version}" + elsif !m.one? + raise ArgumentError, "ambiguous version range for #{kind}:#{version}" + end +end.first +server = ARGV[1] || 'thin' + +gemfile(true) do + source "https://rubygems.org" + + ruby match['ruby'] if match['ruby'] + + gem 'sinatra', "~> #{version}.0" + gem server + + match.fetch('gem', []).each do |name, requirement| + gem name, requirement + end +end + +require 'sinatra/base' +require 'json' + +class App < Sinatra::Base + get '/hello/world' do + status 200 + content_type :json + body JSON.dump({ hello: :world }) + end +end + +Rack::Server.new(app: App, Host: '0.0.0.0', Port: 3000).start