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,
});