MySQL Backup to S3

Here’s a script I wrote to backup my MySQL database and upload to S3. It’s. based on a Ruby version I’ve been using for years.

I’m using Ecto and ExAws libraries as well as the following System ENV variables

BACKUP_DIRECTORY
BACKUP_AWS_ACCESS_KEY_ID
BACKUP_AWS_SECRET_ACCESS_KEY
BACKUP_AWS_BUCKET

The tasks are

myapp.mysql.backup    <- backup the database and upload to S3
myapp.mysql.download  <- download the latest version from S3
myapp.mysql.restore.  <- restore the latest version

The code

defmodule Mix.Tasks.MyApp.Mysql do
  @moduledoc false

  alias MyApp.Repo
  alias ExAws.S3
  alias ExAws.S3.Upload

  defmodule Backup do
    @moduledoc false
    @shortdoc "MySql Backup"

    use Mix.Task
    alias Mix.Tasks.MyApp.Mysql

    def run(_args) do
      Mysql.s3_start()

      path = Mysql.path()
      filename = Mysql.filename()

      IO.puts("BACKUP : " <> filename)

      opts =
        (Mysql.mysql_opts() ++
           [
             "--default-character-set=utf8",
             "--skip-lock-tables",
             "--single-transaction",
             Mysql.database()
           ])
        |> Enum.join(" ")

      _ =
        "mysqldump #{opts} | bzip2 > #{path}/#{filename}"
        |> String.to_charlist()
        |> :os.cmd()

      # if Application.get_env(:linklab, :environment) == :production do
      if Mix.env() == :production do
        Mysql.s3_upload(path, filename)
        File.rm!(path <> "/" <> filename)
      end

      IO.puts("BACKUP : SUCCESS #{filename}")

      :ok
    end
  end

  defmodule Download do
    @moduledoc false
    @shortdoc "MySql Download"

    use Mix.Task
    alias Mix.Tasks.MyApp.Mysql
    alias ExAws.S3

    def run(_args) do
      Mysql.s3_start()

      body =
        "BACKUP_AWS_BUCKET"
        |> System.get_env()
        |> S3.list_objects(delimiter: "/", prefix: "production")
        |> ExAws.request!(Mysql.s3_secrets())

      %{body: %{contents: contents}} = body

      %{key: filename} =
        contents
        |> Enum.sort_by(fn content -> content[:key] end)
        |> Enum.reverse()
        |> (&hd(&1)).()

      path = Mysql.path()

      if File.exists?(path <> "/" <> filename) do
        File.rm!(path <> "/" <> filename)
      end

      IO.puts("DOWNLOAD : " <> path <> "/" <> filename)

      "BACKUP_AWS_BUCKET"
      |> System.get_env()
      |> S3.download_file(filename, path <> "/" <> filename)
      |> ExAws.request!(Mysql.s3_secrets())

      IO.puts("DOWNLOAD : SUCCESS")
      :ok
    end
  end

  defmodule Restore do
    @moduledoc false
    @shortdoc "MySql Restore"

    use Mix.Task
    alias Mix.Tasks.MyApp.Mysql

    def run(args) do

      env = case args do
          [] -> "prod"
          [env | _] -> env
        end

      path = Mysql.path()
      filenames =
        "#{path}/#{env}-*.sql.bz2"
        |> Path.wildcard()
        |> Enum.sort()
        |> Enum.reverse()

      case filenames do
        [] ->
          IO.puts(
            "NO FILES TO RESTORE : " <>
            path <>
            "/#{env}-*.sql.bz2"
          )

        [filename | _] ->
          IO.puts("RESTORE : #{filename}")

          opts =
            Mysql.mysql_opts()
            |> Enum.join(" ")

          "bzip2 -dc #{filename} | mysql #{opts} --database=#{Mysql.database()}"
          |> String.to_charlist()
          |> :os.cmd()

          IO.puts("RESTORE : SUCCESS")
      end

      :ok
    end
  end

  def path do
    path = System.get_env("BACKUP_DIRECTORY")

    unless File.exists?(path) do
      File.mkdir_p!(path)
    end

    path
  end

  def filename do
    {:ok, dt} =
      :seconds
      |> :os.system_time()
      |> DateTime.from_unix()

    [
      Mix.env(),
      dt.year |> Integer.to_string() |> String.pad_leading(4, "0"),
      dt.month |> Integer.to_string() |> String.pad_leading(2, "0"),
      dt.day |> Integer.to_string() |> String.pad_leading(2, "0"),
      dt.hour |> Integer.to_string() |> String.pad_leading(2, "0"),
      dt.minute |> Integer.to_string() |> String.pad_leading(2, "0"),
      dt.second |> Integer.to_string() |> String.pad_leading(2, "0")
    ]
    |> Enum.join("-")
    |> (&(&1 <> ".sql.bz2")).()
  end

  def s3_start do
    {:ok, _started} = Application.ensure_all_started(:hackney)
  end

  def s3_secrets do
    [
      access_key_id: System.get_env("BACKUP_AWS_ACCESS_KEY_ID"),
      secret_access_key: System.get_env("BACKUP_AWS_SECRET_ACCESS_KEY")
    ]
  end

  def s3_upload(path, filename) do
    (path <> "/" <> filename)
    |> Upload.stream_file()
    |> S3.upload(System.get_env("BACKUP_AWS_BUCKET"), filename)
    |> ExAws.request!(s3_secrets())
  end

  def mysql_opts do
    Repo.config()
    |> Enum.map(fn
      {_, nil} ->
        nil

      {_, ""} ->
        nil

      {key, value} ->
        case key do
          :username -> "--user=#{value}"
          :password -> "--password=#{value}"
          :hostname -> "--host=#{value}"
          :port -> "--port=#{value}"
          _ -> nil
        end
    end)
    |> Enum.reject(&is_nil/1)
  end

  def database do
    Repo.config()[:database]
  end
end