diff --git a/examples/ruby-rails-migrator/.ruby-version b/examples/ruby-rails-migrator/.ruby-version new file mode 100644 index 0000000000..15a2799817 --- /dev/null +++ b/examples/ruby-rails-migrator/.ruby-version @@ -0,0 +1 @@ +3.3.0 diff --git a/examples/ruby-rails-migrator/Gemfile b/examples/ruby-rails-migrator/Gemfile new file mode 100644 index 0000000000..8a31e30ce6 --- /dev/null +++ b/examples/ruby-rails-migrator/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1.0" +gem "pg" +gem "puma" diff --git a/examples/ruby-rails-migrator/Rakefile b/examples/ruby-rails-migrator/Rakefile new file mode 100644 index 0000000000..9a5ea7383a --- /dev/null +++ b/examples/ruby-rails-migrator/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/examples/ruby-rails-migrator/app/controllers/application_controller.rb b/examples/ruby-rails-migrator/app/controllers/application_controller.rb new file mode 100644 index 0000000000..4ac8823b09 --- /dev/null +++ b/examples/ruby-rails-migrator/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::API +end diff --git a/examples/ruby-rails-migrator/app/jobs/application_job.rb b/examples/ruby-rails-migrator/app/jobs/application_job.rb new file mode 100644 index 0000000000..d394c3d106 --- /dev/null +++ b/examples/ruby-rails-migrator/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/examples/ruby-rails-migrator/app/mailers/application_mailer.rb b/examples/ruby-rails-migrator/app/mailers/application_mailer.rb new file mode 100644 index 0000000000..3c34c8148f --- /dev/null +++ b/examples/ruby-rails-migrator/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/examples/ruby-rails-migrator/app/models/application_record.rb b/examples/ruby-rails-migrator/app/models/application_record.rb new file mode 100644 index 0000000000..b63caeb8a5 --- /dev/null +++ b/examples/ruby-rails-migrator/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/examples/ruby-rails-migrator/app/views/layouts/.keep b/examples/ruby-rails-migrator/app/views/layouts/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/bin/rails b/examples/ruby-rails-migrator/bin/rails new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/bin/rake b/examples/ruby-rails-migrator/bin/rake new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/bin/setup b/examples/ruby-rails-migrator/bin/setup new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/config/application.rb b/examples/ruby-rails-migrator/config/application.rb new file mode 100644 index 0000000000..fef4c33b9f --- /dev/null +++ b/examples/ruby-rails-migrator/config/application.rb @@ -0,0 +1,44 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +# require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module RubyRailsMigrator + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w(assets tasks)) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Only loads a smaller set of middleware suitable for API only apps. + # Middleware like session, flash, cookies can be added back manually. + # Skip views, helpers and assets when generating a new resource. + config.api_only = true + end +end diff --git a/examples/ruby-rails-migrator/config/boot.rb b/examples/ruby-rails-migrator/config/boot.rb new file mode 100644 index 0000000000..e742fc2eaf --- /dev/null +++ b/examples/ruby-rails-migrator/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" if ENV["BOOTSNAP_CACHE_DIR"] # Speed up boot time by caching expensive operations. diff --git a/examples/ruby-rails-migrator/config/database.yml b/examples/ruby-rails-migrator/config/database.yml new file mode 100644 index 0000000000..2b51a01c8b --- /dev/null +++ b/examples/ruby-rails-migrator/config/database.yml @@ -0,0 +1,11 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + host: <%= ENV["DB_HOST"] %> + database: <%= ENV["DB_NAME"] %> + username: <%= ENV["DB_USER"] %> + password: <%= ENV["DB_PASSWORD"] %> + +production: + <<: *default diff --git a/examples/ruby-rails-migrator/config/environment.rb b/examples/ruby-rails-migrator/config/environment.rb new file mode 100644 index 0000000000..cac5315775 --- /dev/null +++ b/examples/ruby-rails-migrator/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/examples/ruby-rails-migrator/config/environments/development.rb b/examples/ruby-rails-migrator/config/environments/development.rb new file mode 100644 index 0000000000..4609c9628c --- /dev/null +++ b/examples/ruby-rails-migrator/config/environments/development.rb @@ -0,0 +1,14 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + config.enable_reloading = true + config.eager_load = false + config.consider_all_requests_local = true + config.server_timing = true + config.active_storage.service = :local + config.active_support.deprecation = :log + config.active_support.disallowed_deprecation = :raise + config.active_support.disallowed_deprecation_log_level = :info + config.active_record.migration_error = :page_load + config.active_record.verbose_query_logs = true +end diff --git a/examples/ruby-rails-migrator/config/environments/production.rb b/examples/ruby-rails-migrator/config/environments/production.rb new file mode 100644 index 0000000000..ae7f1e5f5b --- /dev/null +++ b/examples/ruby-rails-migrator/config/environments/production.rb @@ -0,0 +1,11 @@ +require "rails/all" + +module RubyRailsMigrator + class Application < Rails::Application + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false + config.active_support.report_deprecations = false + config.active_record.dump_schema_after_migration = false + end +end diff --git a/examples/ruby-rails-migrator/config/environments/test.rb b/examples/ruby-rails-migrator/config/environments/test.rb new file mode 100644 index 0000000000..3485e31518 --- /dev/null +++ b/examples/ruby-rails-migrator/config/environments/test.rb @@ -0,0 +1,13 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + config.enable_reloading = false + config.eager_load = ENV["CI"].present? + config.public_file_server.enabled = true + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } + config.consider_all_requests_local = true + config.active_storage.service = :test + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.active_support.disallowed_deprecation_log_level = :info +end diff --git a/examples/ruby-rails-migrator/config/initializers/assets.rb b/examples/ruby-rails-migrator/config/initializers/assets.rb new file mode 100644 index 0000000000..0f6c6b1679 --- /dev/null +++ b/examples/ruby-rails-migrator/config/initializers/assets.rb @@ -0,0 +1,2 @@ +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" diff --git a/examples/ruby-rails-migrator/config/initializers/content_security_policy.rb b/examples/ruby-rails-migrator/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000..dcf3193532 --- /dev/null +++ b/examples/ruby-rails-migrator/config/initializers/content_security_policy.rb @@ -0,0 +1,14 @@ +# Rails.application.config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end + +# Rails.application.config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) } +# Rails.application.config.content_security_policy_nonce_directives = %w(script-src style-src) +# Rails.application.config.content_security_policy_report_only = true diff --git a/examples/ruby-rails-migrator/config/initializers/filter_parameter_logging.rb b/examples/ruby-rails-migrator/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000000..350dadecf6 --- /dev/null +++ b/examples/ruby-rails-migrator/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,4 @@ +# Configure parameters which will be filtered from the log file. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/examples/ruby-rails-migrator/config/initializers/inflections.rb b/examples/ruby-rails-migrator/config/initializers/inflections.rb new file mode 100644 index 0000000000..ad1f19de17 --- /dev/null +++ b/examples/ruby-rails-migrator/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale-dependent, and can be localized as you see fit. +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# Add new inflection rules using the following format. Inflections +# are locale-dependent, and can be localized as you see fit. +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/examples/ruby-rails-migrator/config/initializers/permissions_policy.rb b/examples/ruby-rails-migrator/config/initializers/permissions_policy.rb new file mode 100644 index 0000000000..90f8cd0c29 --- /dev/null +++ b/examples/ruby-rails-migrator/config/initializers/permissions_policy.rb @@ -0,0 +1,8 @@ +# Rails.application.config.permissions_policy do |f| +# f.camera :none +# f.gyroscope :none +# f.microphone :none +# f.usb :none +# f.fullscreen :self +# f.payment :self, "https://secure.example.com" +# end diff --git a/examples/ruby-rails-migrator/config/locales/en.yml b/examples/ruby-rails-migrator/config/locales/en.yml new file mode 100644 index 0000000000..a9f72ecc77 --- /dev/null +++ b/examples/ruby-rails-migrator/config/locales/en.yml @@ -0,0 +1,2 @@ +en: + hello: "Hello world" diff --git a/examples/ruby-rails-migrator/config/puma.rb b/examples/ruby-rails-migrator/config/puma.rb new file mode 100644 index 0000000000..b060e3943e --- /dev/null +++ b/examples/ruby-rails-migrator/config/puma.rb @@ -0,0 +1,8 @@ +threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +threads threads_count, threads_count + +port ENV.fetch("PORT") { 3000 } +environment ENV.fetch("RAILS_ENV") { "development" } +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +plugin :tmp_restart diff --git a/examples/ruby-rails-migrator/config/routes.rb b/examples/ruby-rails-migrator/config/routes.rb new file mode 100644 index 0000000000..a125ef0850 --- /dev/null +++ b/examples/ruby-rails-migrator/config/routes.rb @@ -0,0 +1,10 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/examples/ruby-rails-migrator/db/migrate/20231027000000_create_users.rb b/examples/ruby-rails-migrator/db/migrate/20231027000000_create_users.rb new file mode 100644 index 0000000000..a5c65cb8bf --- /dev/null +++ b/examples/ruby-rails-migrator/db/migrate/20231027000000_create_users.rb @@ -0,0 +1,10 @@ +class CreateUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :name + t.string :email + + t.timestamps + end + end +end diff --git a/examples/ruby-rails-migrator/db/seeds.rb b/examples/ruby-rails-migrator/db/seeds.rb new file mode 100644 index 0000000000..4fbd6ed970 --- /dev/null +++ b/examples/ruby-rails-migrator/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/examples/ruby-rails-migrator/handler.rb b/examples/ruby-rails-migrator/handler.rb new file mode 100644 index 0000000000..5e7757e5b5 --- /dev/null +++ b/examples/ruby-rails-migrator/handler.rb @@ -0,0 +1,28 @@ +require_relative "config/environment" +require "json" + +def handler(event:, context:) + puts "Event: #{event.inspect}" + + puts "Running migrations..." + + # Ensure Rails logs to stdout + Rails.logger = Logger.new(STDOUT) + + # Run migrations via ActiveRecord + ActiveRecord::Tasks::DatabaseTasks.migrate + + puts "Migrations complete!" + + { + statusCode: 200, + body: JSON.generate({ message: "Migrations completed successfully" }) + } +rescue => e + puts "Migration failed: #{e.message}" + puts e.backtrace.join("\n") + { + statusCode: 500, + body: JSON.generate({ error: e.message, backtrace: e.backtrace }) + } +end diff --git a/examples/ruby-rails-migrator/lib/tasks/.keep b/examples/ruby-rails-migrator/lib/tasks/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/public/404.html b/examples/ruby-rails-migrator/public/404.html new file mode 100644 index 0000000000..fad074e283 --- /dev/null +++ b/examples/ruby-rails-migrator/public/404.html @@ -0,0 +1,66 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/examples/ruby-rails-migrator/public/422.html b/examples/ruby-rails-migrator/public/422.html new file mode 100644 index 0000000000..9cf620e48b --- /dev/null +++ b/examples/ruby-rails-migrator/public/422.html @@ -0,0 +1,66 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/examples/ruby-rails-migrator/public/500.html b/examples/ruby-rails-migrator/public/500.html new file mode 100644 index 0000000000..64936793c9 --- /dev/null +++ b/examples/ruby-rails-migrator/public/500.html @@ -0,0 +1,65 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/examples/ruby-rails-migrator/public/robots.txt b/examples/ruby-rails-migrator/public/robots.txt new file mode 100644 index 0000000000..26e06b5f19 --- /dev/null +++ b/examples/ruby-rails-migrator/public/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/examples/ruby-rails-migrator/sst.config.ts b/examples/ruby-rails-migrator/sst.config.ts new file mode 100644 index 0000000000..cf604855f9 --- /dev/null +++ b/examples/ruby-rails-migrator/sst.config.ts @@ -0,0 +1,33 @@ +/// + +export default $config({ + app(input) { + return { + name: "ruby-rails-migrator", + removal: input?.stage === "production" ? "retain" : "remove", + home: "aws", + }; + }, + async run() { + const vpc = new sst.aws.Vpc("MyVpc"); + const rds = new sst.aws.Postgres("MyDatabase", { vpc }); + + const migrator = new sst.aws.Function("MyMigrator", { + vpc, + handler: "handler.handler", + runtime: "ruby3.3", + link: [rds], + environment: { + DB_HOST: rds.host, + DB_NAME: rds.database, + DB_USER: rds.username, + DB_PASSWORD: rds.password, + RAILS_ENV: "production", + }, + }); + + return { + migrator: migrator.name, + }; + }, +}); diff --git a/examples/ruby-rails-migrator/storage/.keep b/examples/ruby-rails-migrator/storage/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/test/.keep b/examples/ruby-rails-migrator/test/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ruby-rails-migrator/vendor/.keep b/examples/ruby-rails-migrator/vendor/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/project/project.go b/pkg/project/project.go index 06b5601d06..1932d9e82c 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -23,6 +23,7 @@ import ( "github.com/sst/sst/v3/pkg/runtime/golang" "github.com/sst/sst/v3/pkg/runtime/node" "github.com/sst/sst/v3/pkg/runtime/python" + "github.com/sst/sst/v3/pkg/runtime/ruby" "github.com/sst/sst/v3/pkg/runtime/rust" "github.com/sst/sst/v3/pkg/runtime/worker" ) @@ -133,6 +134,7 @@ func New(input *ProjectConfig) (*Project, error) { worker.New(), python.New(), golang.New(), + ruby.New(), rust.New(), ), } diff --git a/pkg/runtime/ruby/ruby.go b/pkg/runtime/ruby/ruby.go new file mode 100644 index 0000000000..b150f614db --- /dev/null +++ b/pkg/runtime/ruby/ruby.go @@ -0,0 +1,243 @@ +package ruby + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/sst/sst/v3/pkg/process" + "github.com/sst/sst/v3/pkg/project/path" + "github.com/sst/sst/v3/pkg/runtime" +) + +type Worker struct { + stdout io.ReadCloser + stderr io.ReadCloser + cmd *exec.Cmd +} + +func (w *Worker) Stop() { + process.Kill(w.cmd.Process) +} + +func (w *Worker) Logs() io.ReadCloser { + reader, writer := io.Pipe() + + go func() { + defer writer.Close() + + var wg sync.WaitGroup + wg.Add(2) + + copyStream := func(dst io.Writer, src io.Reader, name string) { + defer wg.Done() + buf := make([]byte, 1024) + for { + n, err := src.Read(buf) + if n > 0 { + _, werr := dst.Write(buf[:n]) + if werr != nil { + slog.Error("error writing to pipe", "stream", name, "err", werr) + return + } + } + if err != nil { + if err != io.EOF { + slog.Error("error reading from stream", "stream", name, "err", err) + } + return + } + } + } + + go copyStream(writer, w.stdout, "stdout") + go copyStream(writer, w.stderr, "stderr") + + wg.Wait() + }() + + return reader +} + +type RubyRuntime struct { + lastBuiltHandler map[string]string +} + +func New() *RubyRuntime { + return &RubyRuntime{ + lastBuiltHandler: map[string]string{}, + } +} + +func (r *RubyRuntime) Match(runtime string) bool { + return strings.HasPrefix(runtime, "ruby") +} + +func (r *RubyRuntime) Build(ctx context.Context, input *runtime.BuildInput) (*runtime.BuildOutput, error) { + slog.Info("building ruby function", "handler", input.Handler) + + type Properties struct { + Architecture string `json:"architecture"` + } + var props Properties + if err := json.Unmarshal(input.Properties, &props); err != nil { + return nil, fmt.Errorf("failed to parse properties: %v", err) + } + + arch := props.Architecture + if arch == "" { + arch = "x86_64" + } + + // 1. Prepare artifact directory + out := input.Out() + rootDir := path.ResolveRootDir(input.CfgPath) + handlerDir := filepath.Dir(filepath.Join(rootDir, input.Handler)) + + // Find Gemfile + gemfilePath := "" + currentDir := handlerDir + for { + if _, err := os.Stat(filepath.Join(currentDir, "Gemfile")); err == nil { + gemfilePath = filepath.Join(currentDir, "Gemfile") + break + } + parent := filepath.Dir(currentDir) + if parent == currentDir || parent == rootDir { + break + } + currentDir = parent + } + + // Copy all files from handler directory (or workspace root if Gemfile found) to out + copySource := handlerDir + if gemfilePath != "" { + copySource = filepath.Dir(gemfilePath) + } + + err := exec.Command("cp", "-r", copySource+"/.", out).Run() + if err != nil { + return nil, fmt.Errorf("failed to copy source files: %v", err) + } + + if input.IsContainer && !input.Dev { + // Handle container build + slog.Info("container build", "out", out) + dockerfilePath := filepath.Join(copySource, "Dockerfile") + if _, err := os.Stat(dockerfilePath); err != nil { + // Copy default Dockerfile + defaultDockerfilePath := filepath.Join(path.ResolvePlatformDir(input.CfgPath), "/dist/dockerfiles/ruby.Dockerfile") + err := copyFile(defaultDockerfilePath, filepath.Join(out, "Dockerfile")) + if err != nil { + return nil, fmt.Errorf("failed to copy default Dockerfile: %v", err) + } + } else { + copyFile(dockerfilePath, filepath.Join(out, "Dockerfile")) + } + + return &runtime.BuildOutput{ + Handler: input.Handler, + Errors: []string{}, + }, nil + } + + // 2. Install dependencies if Gemfile exists + if gemfilePath != "" { + slog.Info("installing ruby dependencies", "dir", out) + + // For Ruby, we usually want to bundle into vendor/bundle + cmd := process.CommandContext(ctx, "bundle", "config", "set", "--local", "deployment", "true") + cmd.Dir = out + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to set bundle config: %v", err) + } + + cmd = process.CommandContext(ctx, "bundle", "config", "set", "--local", "path", "vendor/bundle") + cmd.Dir = out + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to set bundle path: %v", err) + } + + cmd = process.CommandContext(ctx, "bundle", "install") + cmd.Dir = out + if output, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to run bundle install: %v\n%s", err, string(output)) + } + } + + r.lastBuiltHandler[input.FunctionID] = input.Handler + + return &runtime.BuildOutput{ + Handler: input.Handler, + Errors: []string{}, + }, nil +} + +func (r *RubyRuntime) Run(ctx context.Context, input *runtime.RunInput) (runtime.Worker, error) { + // Copy the ruby bridge + bridgePath := filepath.Join(input.Build.Out, "index.rb") + if _, err := os.Stat(bridgePath); os.IsNotExist(err) { + err := copyFile(filepath.Join(path.ResolvePlatformDir(input.CfgPath), "/dist/ruby-runtime/index.rb"), bridgePath) + if err != nil { + return nil, fmt.Errorf("failed to copy ruby bridge: %v", err) + } + } + + // Run with bundle exec if Gemfile exists + args := []string{"ruby", "index.rb", input.Build.Handler} + if _, err := os.Stat(filepath.Join(input.Build.Out, "Gemfile")); err == nil { + args = []string{"bundle", "exec", "ruby", "index.rb", input.Build.Handler} + } + + cmd := process.CommandContext(ctx, args[0], args[1:]...) + cmd.Env = append(input.Env, "AWS_LAMBDA_RUNTIME_API="+input.Server) + cmd.Dir = input.Build.Out + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + + slog.Info("starting ruby worker", "args", cmd.Args) + if err := cmd.Start(); err != nil { + return nil, err + } + + return &Worker{ + stdout: stdout, + stderr: stderr, + cmd: cmd, + }, nil +} + +func (r *RubyRuntime) ShouldRebuild(functionID string, file string) bool { + return strings.HasSuffix(file, ".rb") || filepath.Base(file) == "Gemfile" || filepath.Base(file) == "Gemfile.lock" +} + +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} diff --git a/pkg/runtime/ruby/ruby_test.go b/pkg/runtime/ruby/ruby_test.go new file mode 100644 index 0000000000..1d6c323d3b --- /dev/null +++ b/pkg/runtime/ruby/ruby_test.go @@ -0,0 +1,23 @@ +package ruby + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatch(t *testing.T) { + r := New() + assert.True(t, r.Match("ruby3.2")) + assert.True(t, r.Match("ruby3.3")) + assert.False(t, r.Match("python3.11")) + assert.False(t, r.Match("nodejs18.x")) +} + +func TestShouldRebuild(t *testing.T) { + r := New() + assert.True(t, r.ShouldRebuild("func", "handler.rb")) + assert.True(t, r.ShouldRebuild("func", "Gemfile")) + assert.True(t, r.ShouldRebuild("func", "Gemfile.lock")) + assert.False(t, r.ShouldRebuild("func", "handler.py")) +} diff --git a/platform/functions/docker/ruby.Dockerfile b/platform/functions/docker/ruby.Dockerfile new file mode 100644 index 0000000000..6d3c7066a7 --- /dev/null +++ b/platform/functions/docker/ruby.Dockerfile @@ -0,0 +1,35 @@ +# Specify the Ruby version as an ARG +ARG RUBY_VERSION=3.3 +ARG RUBY_RUNTIME + +# Stage 1: Build environment (install build tools and dependencies) +FROM public.ecr.aws/lambda/ruby:${RUBY_VERSION} AS build + +# Ensure git and build tools are installed +RUN yum install -y git gcc make gcc-c++ + +# Copy Gemfile and Gemfile.lock (if it exists) +COPY Gemfile* ${LAMBDA_TASK_ROOT}/ + +# Install dependencies using Bundler +WORKDIR ${LAMBDA_TASK_ROOT} +RUN bundle config set --local deployment 'true' && \ + bundle config set --local path 'vendor/bundle' && \ + bundle install + +# Stage 2: Final runtime image +FROM public.ecr.aws/lambda/ruby:${RUBY_VERSION} + +# Copy the installed dependencies from the build stage +COPY --from=build ${LAMBDA_TASK_ROOT}/vendor/bundle ${LAMBDA_TASK_ROOT}/vendor/bundle +COPY --from=build ${LAMBDA_TASK_ROOT}/.bundle ${LAMBDA_TASK_ROOT}/.bundle + +# Copy the application code into the final image +COPY . ${LAMBDA_TASK_ROOT} + +# Ensure dependencies are available in the load path +ENV BUNDLE_PATH=${LAMBDA_TASK_ROOT}/vendor/bundle +ENV BUNDLE_WITHOUT=development:test +ENV GEM_PATH=${LAMBDA_TASK_ROOT}/vendor/bundle/ruby/3.3.0 + +# No need to configure the handler or entrypoint - SST will do that diff --git a/platform/functions/ruby-runtime/index.rb b/platform/functions/ruby-runtime/index.rb new file mode 100644 index 0000000000..8d8af260c0 --- /dev/null +++ b/platform/functions/ruby-runtime/index.rb @@ -0,0 +1,97 @@ +require "net/http" +require "json" +require "time" + +def report_error(ex, request_id = nil) + runtime_api = ENV["AWS_LAMBDA_RUNTIME_API"] + error_response = { + errorMessage: ex.message, + errorType: ex.class.name, + stackTrace: ex.backtrace + } + + endpoint = if request_id.nil? + "http://#{runtime_api}/2018-06-01/runtime/init/error" + else + "http://#{runtime_api}/2018-06-01/runtime/invocation/#{request_id}/error" + end + + uri = URI(endpoint) + Net::HTTP.post(uri, error_response.to_json, "Content-Type" => "application/json") +end + +def log(message) + puts message + $stdout.flush + $stderr.flush +end + +handler = ARGV[0] # Expecting "file.method" +runtime_api = ENV["AWS_LAMBDA_RUNTIME_API"] + +begin + file_name, method_name = handler.split(".") + + # Add current directory to load path + $LOAD_PATH.unshift(Dir.pwd) unless $LOAD_PATH.include?(Dir.pwd) + + require file_name +rescue Exception => ex + report_error(ex) + exit 1 +end + +loop do + begin + # Get next invocation + next_uri = URI("http://#{runtime_api}/2018-06-01/runtime/invocation/next") + response = Net::HTTP.get_response(next_uri) + + request_id = response["Lambda-Runtime-Aws-Request-Id"] + deadline_ms = response["Lambda-Runtime-Deadline-Ms"].to_i + + context = { + aws_request_id: request_id, + invoked_function_arn: response["Lambda-Runtime-Invoked-Function-Arn"], + get_remaining_time_in_millis: -> { [deadline_ms - (Time.now.to_f * 1000).to_i, 0].max }, + function_name: ENV["AWS_LAMBDA_FUNCTION_NAME"], + function_version: ENV["AWS_LAMBDA_FUNCTION_VERSION"], + memory_limit_in_mb: ENV["AWS_LAMBDA_FUNCTION_MEMORY_SIZE"], + log_group_name: ENV["AWS_LAMBDA_LOG_GROUP_NAME"], + log_stream_name: ENV["AWS_LAMBDA_LOG_STREAM_NAME"] + } + + event = JSON.parse(response.body) + rescue Exception => ex + log("Error getting next invocation: #{ex}") + report_error(ex) + next + end + + # Run the handler function + begin + # The handler can be a top-level method or defined in a module if the file required it + # Most Lambda Ruby handlers are defined as standalone methods in the file + result = send(method_name, event: event, context: context) + rescue Exception => ex + log("Error running handler: #{ex}") + report_error(ex, request_id) + next + end + + # Send the response back to Lambda + loop do + begin + response_uri = URI("http://#{runtime_api}/2018-06-01/runtime/invocation/#{request_id}/response") + Net::HTTP.post(response_uri, result.to_json, "Content-Type" => "application/json") + break + rescue Exception => ex + log("Error sending response: #{ex}") + sleep 0.5 + next + end + end + + $stdout.flush + $stderr.flush +end diff --git a/platform/scripts/build b/platform/scripts/build index f2ec38d566..22bffb286b 100755 --- a/platform/scripts/build +++ b/platform/scripts/build @@ -19,8 +19,12 @@ cp ./dist/bridge/bootstrap ./dist/nodejs-bridge/bootstrap mkdir -p ./dist/python-runtime/ cp ./functions/python-runtime/index.py ./dist/python-runtime/index.py +mkdir -p ./dist/ruby-runtime/ +cp ./functions/ruby-runtime/index.rb ./dist/ruby-runtime/index.rb + mkdir -p ./dist/dockerfiles/ cp ./functions/docker/python.Dockerfile ./dist/dockerfiles/python.Dockerfile +cp ./functions/docker/ruby.Dockerfile ./dist/dockerfiles/ruby.Dockerfile cd ./support/bridge-task timestamp=$(date +%Y%m%d%H%M%S) diff --git a/platform/src/components/aws/function.ts b/platform/src/components/aws/function.ts index 22f1723600..f568a59038 100644 --- a/platform/src/components/aws/function.ts +++ b/platform/src/components/aws/function.ts @@ -368,6 +368,8 @@ export interface FunctionArgs { | "python3.11" | "python3.12" | "python3.13" + | "ruby3.2" + | "ruby3.3" >; /** * Path to the source code directory for the function. By default, the handler is @@ -1189,6 +1191,57 @@ export interface FunctionArgs { } >; }>; + /** + * Configure how your Ruby function is packaged. + */ + ruby?: Input<{ + /** + * Set this to `true` if you want to deploy this function as a container image. + * There are a couple of reasons why you might want to do this. + * + * 1. The Lambda package size has an unzipped limit of 250MB. Whereas the + * container image size has a limit of 10GB. + * 2. Even if you are below the 250MB limit, larger Lambda function packages + * have longer cold starts when compared to container image. + * 3. You might want to use a custom Dockerfile to handle complex builds. + * + * @default `false` + * @example + * ```ts + * { + * ruby: { + * container: true + * } + * } + * ``` + * + * When you run `sst deploy`, it uses a built-in Dockerfile. It also needs + * the Docker daemon to be running. + * + * :::note + * This needs the Docker daemon to be running. + * ::: + * + * To use a custom Dockerfile, add one to the root of your Ruby project. + * + * ```txt {4} + * ├── sst.config.ts + * ├── Gemfile + * ├── Dockerfile + * └── function.rb + * ``` + */ + container?: Input< + | boolean + | { + /** + * Controls whether Docker build cache is enabled. + * @default `true` + */ + cache?: Input; + } + >; + }>; /** * Add additional files to copy into the function package. Takes a list of objects * with `from` and `to` paths. These will be copied over before the function package @@ -1737,13 +1790,14 @@ export class Function extends Component implements Link.Linkable { const parent = this; const dev = normalizeDev(); - const isContainer = all([args.python, dev]).apply( - ([python, dev]) => !dev && !!python?.container, + const isContainer = all([args.python, args.ruby, dev]).apply( + ([python, ruby, dev]) => !dev && (!!python?.container || !!ruby?.container), ); - const containerCache = all([args.python]).apply(([python]) => - typeof python?.container === "object" - ? (python.container.cache ?? true) - : true, + const containerCache = all([args.python, args.ruby]).apply( + ([python, ruby]) => { + const container = python?.container ?? ruby?.container; + return typeof container === "object" ? (container.cache ?? true) : true; + }, ); const partition = getPartitionOutput({}, opts).partition; const region = getRegionOutput({}, opts).region; @@ -1796,12 +1850,14 @@ export class Function extends Component implements Link.Linkable { Object.fromEntries(input.map((item) => [item.name, item.properties])), ), copyFiles, - properties: output({ nodejs: args.nodejs, python: args.python }).apply( - (val) => ({ - ...(val.nodejs || val.python), - architecture, - }), - ), + properties: output({ + nodejs: args.nodejs, + python: args.python, + ruby: args.ruby, + }).apply((val) => ({ + ...(val.nodejs || val.python || val.ruby), + architecture, + })), dev, });