A 10 year old app running on a modern Mac? Yes, you can!

We recently had a really old app roll into our shop: a Rails 4.1 app, running Ruby 2.3. The git history was a bit convoluted, but the app is definitely no younger than 10 years old. How do we get it running on a Mac with Apple silicon? Dev Containers to the rescue!

After cloning the repo locally, we open it up in VS Code. We add a new hidden folder, .devcontainer to the root of the project, and then add four files:

The Dockerfile

Our Dockerfile will start with the official ruby:2.3.4 image, which uses Debian Jessie.


FROM ruby:2.3.4 

Jessie is end-of-life, so apt-get won't work and you won't be able to use it to install packages. Fortunately, there is a work-around. We can manually update the OS's package source list to pull from the archives. We can also disable key checking, as the signature keys will most definitely be expired:


RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list && \    
sed -i '/security.debian.org/d' /etc/apt/sources.list && \    
sed -i '/jessie-updates/d' /etc/apt/sources.list && \    
echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99ignore-releases && \    
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure-repositories && \    
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure-repositories && \    
apt-get update

Next, our app also relied on the TinyTDS gem, which needs the FreeTDS package. These lines will download and build it for us:


RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --allow-unauthenticated wget build-essential libc6-dev && \
    wget http://www.freetds.org/files/stable/freetds-1.4.10.tar.gz && \
    tar -xzf freetds-1.4.10.tar.gz && \
    cd freetds-1.4.10 && \
    ./configure --prefix=/usr/local --with-tdsver=7.4 && \
    make && \
    make install
    
# These lines ensure that environment variables persist for subsequent RUN commands (neccesary for the instalation of the tiny_tds gem - see above).
ENV PATH="/usr/local/bin:$PATH" \
    C_INCLUDE_PATH="/usr/local/include:$C_INCLUDE_PATH" \
    LIBRARY_PATH="/usr/local/lib:$LIBRARY_PATH" \
    LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"


The Docker Compose File

Our docker-compose.yml file is pretty standard. We're using the latest PostgreSQL 9.6 image - later versions have security features that don't play well with this old app.

Also worth noting: this will create a new volume, postgres-data, to persist the database data. It will also copy a database initialization SQL command (.devcontainer/create-db-user.sql)


services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    volumes:
      - ../..:/workspaces:cached

    # Overrides default command so things don't shut down after the process ends.
    command: sleep infinity

    # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
    network_mode: service:db
  db:
    image: postgres:9.6.24 
    restart: unless-stopped
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql
    environment:
      POSTGRES_DB: postgres
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres

volumes:
  postgres-data:

Here's what we put in the .devcontainer/create-db-user.sql file. Note that dev containers, by default, are run as a user called vscode.


CREATE USER vscode CREATEDB;
CREATE DATABASE vscode WITH OWNER vscode;

The Dev Container JSON File

Finally, the devcontainer.json file includes some basic dev container configurations. Note that we're using the postCreateCommand section to install the gems (bundle install) and to initiate the database.


{
	"name": "Ruby on Rails 4.1 & Postgres 9.6",
	"dockerComposeFile": "docker-compose.yml",
	"service": "app",
	"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

	// Features to add to the dev container. More info: https://containers.dev/features.
	// "features": {},

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// This can be used to network with other containers or the host.
	// "forwardPorts": [3000, 5432],

	// Use 'postCreateCommand' to run commands after the container is created.
	"postCreateCommand": "bundle install && bundle exec rake db:migrate db:seed"

	// Configure tool-specific properties.
	// "customizations": {},

	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
	// "remoteUser": "root"
}

App Tweaks

Naturally, there will be some small app tweaks necessary to get the app running properly, but as a general goal, we tried to make as little changes as we could to the app to get it to run. Here's what we found:

Internalizing Unsupported Gems

We found two very niche and abandoned gems that the app relied on. In the interest of maintainability, we internalized the gems by copying them into the /vendor/gems folder.

Where our Gemfile used to have this:

gem 'weird_gem', :git => 'https://github.com/author/weird_gem.git', :branch => 'weird-branch'

we now just refer to the local copy of the gem:

gem 'weird_gem', path: 'vendor/gems/weird_gem'

Add Long-Lost Dependency Gems

This app uses the paperclip gem, which has long since been abandoned, but at least still exists on Github. However, one of its dependencies, mimemagic, specifically version 0.3.0 is no longer available as a release. We had to dig through that gem's commits to find the commit at that specific version, and refer to that in our Gemfile, which now looks like this:

gem 'mimemagic', git: 'https://github.com/mimemagicrb/mimemagic', ref: 'a4b038c6c1b9d76dac33d5711d28aaa9b4c42c66'

What Version of Ruby Were We On Again?

The app's Gemfile and .ruby-version both indicated that the app was running Ruby 2.3.4, but the app also used an older version of the AuthLogic gem that relied on the unpack1 method, which wasn't available until Ruby 2.4. Instead of upgrading Ruby just to get that method (which could have introduced other side effects), we added a temporary monkey-patch to add that method as a initializer:

# config/initializers/unpack_patch.rb
unless String.instance_methods.include?(:unpack1)
 class String
   def unpack1(format)
     unpack(format).first
   end
 end
end

More AuthLogic Shenanigans

AuthLogic wasn't done with us yet. It was trying to call a with_scope method, which was removed after Rails 3.1. The best choice in this case was to simply upgrade AuthLogic to a later minor version (3.3 to 3.6) that didn't call that missing method anymore.

Success!

And that did it! With a dev containers, and a few minor app code tweaks, we were able to get this very old app running on modern hardware.