From 6fda5da1d219fd1d0df227783298d275f2f5a1e4 Mon Sep 17 00:00:00 2001 From: Loic Nageleisen Date: Wed, 3 Jan 2024 15:55:09 +0100 Subject: [PATCH] Add minimal apps --- .dockerignore | 2 + .envrc | 1 + .gitignore | 3 + Dockerfile | 36 ++++++++ Rakefile | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++ compatibility | 83 +++++++++++++++++ grape.rb | 73 +++++++++++++++ rack.rb | 46 ++++++++++ rails.rb | 68 ++++++++++++++ shell.nix | 25 ++++++ sinatra.rb | 48 ++++++++++ 11 files changed, 629 insertions(+) create mode 100644 .dockerignore create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Rakefile create mode 100644 compatibility create mode 100644 grape.rb create mode 100644 rack.rb create mode 100644 rails.rb create mode 100644 shell.nix create mode 100644 sinatra.rb 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