Skip to content

Commit

Permalink
Litestream support for Phoenix applications (#4224)
Browse files Browse the repository at this point in the history
* Litestream support for Phoenix applications

If mix.exs contains "ecto_sqlite3", have fly launch default to creating a Tigris bucket.

Unless overridden by the launch UI, modify the Dockerfile to install litestream,
configure it for the sqlite3 database, and create a startup script which will
restore databases from backups if they don't exist, run migrations, and then
launch litestream replicate with the phoenix application.

Also, configure the volume to start out at 1Gb, expand by an additional
1Gb each time it reaches 80%, up to a maximum of 10Gb.

Tested with:

```
mix phx.new blogdemo --database=sqlite3
cd blogdemo
mix phx.gen.html Blog Post posts title:string body:text
sed -i.bak -e "/:home/a\\
    resources \"/posts\", PostController" lib/blogdemo_web/router.ex
```

Visit /posts, create a post then run:

fly machine list --json | jq -r ".[]|.id" | xargs fly machine destroy -f
fly volume list --json | jq -r ".[]|.id" | xargs fly volume destroy -y

Wait a minute or so, then run:

fly deploy

Your post should be present on the new machine/volume.
  • Loading branch information
rubys authored Feb 17, 2025
1 parent 6560673 commit b657dad
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 3 deletions.
7 changes: 7 additions & 0 deletions internal/command/launch/launch_frameworks.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ func (state *launchState) scannerRunInitCommands(ctx context.Context) error {
}
}
}

if state.sourceInfo != nil && state.sourceInfo.PostInitCallback != nil {
if err := state.sourceInfo.PostInitCallback(); err != nil {
return err
}
}

return nil
}

Expand Down
199 changes: 196 additions & 3 deletions scanner/phoenix.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package scanner

import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/pkg/errors"
"github.com/superfly/flyctl/helpers"
Expand Down Expand Up @@ -113,11 +115,16 @@ a Postgres database.
s.ReleaseCmd = "/app/bin/migrate"
} else if checksPass(sourceDir, dirContains("mix.exs", "ecto_sqlite3")) {
s.DatabaseDesired = DatabaseKindSqlite
s.ObjectStorageDesired = true
s.Env["DATABASE_PATH"] = "/mnt/name/name.db"
s.Volumes = []Volume{
{
Source: "name",
Destination: "/mnt/name",
Source: "name",
Destination: "/mnt/name",
InitialSize: "1GB",
AutoExtendSizeThreshold: 80,
AutoExtendSizeIncrement: "1GB",
AutoExtendSizeLimit: "10GB",
},
}
}
Expand All @@ -133,7 +140,7 @@ a Postgres database.
return s, nil
}

func PhoenixCallback(appName string, _ *SourceInfo, plan *plan.LaunchPlan, flags []string) error {
func PhoenixCallback(appName string, srcInfo *SourceInfo, plan *plan.LaunchPlan, flags []string) error {
envEExPath := "rel/env.sh.eex"
envEExContents := `
# configure node for distributed erlang with IPV6 support
Expand Down Expand Up @@ -173,5 +180,191 @@ export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"
return err
}
}

// add Litestream if object storage is present and database is sqlite3
if plan.ObjectStorage.Provider() != nil && srcInfo.DatabaseDesired == DatabaseKindSqlite {
srcInfo.PostInitCallback = install_litestream
}

return nil
}

// Read the Dockerfile and insert the necessary commands to install Litestream
// and run the Litestream script as the entrypoint. Primary constraint:
// do no harm. If the Dockerfile is not in the expected format, do not modify it.
func install_litestream() error {
// Ensure config directory exists
if _, err := os.Stat("config"); os.IsNotExist(err) {
return nil
}

// Open original Dockerfile
file, err := os.Open("Dockerfile")
if err != nil {
return err
}
defer file.Close()

// Create temporary output
var lines []string

// Variables to track state
workdir := ""
scanner := bufio.NewScanner(file)
insertedLitestreamInstall := false
foundEntrypoint := false
insertedEntrypoint := false
installedWget := false
copiedLitestream := false

// Read line by line
for scanner.Scan() {
line := scanner.Text()

// Insert litestream script as entrypoint
if strings.HasPrefix(strings.TrimSpace(line), "CMD ") && !insertedEntrypoint {
script := workdir + "/bin/litestream.sh"

if foundEntrypoint {
if strings.Contains(line, "CMD [") {
// JSON array format: CMD ["cmd"]
line = strings.Replace(line, "CMD [", fmt.Sprintf("CMD [\"/bin/bash\", \"%s\",", script), 1)
insertedEntrypoint = true
} else if strings.Contains(line, "CMD \"") {
// Shell format with quotes: CMD "cmd"
line = strings.Replace(line, "CMD \"", fmt.Sprintf("CMD \"/bin/bash %s", script), 1)
insertedEntrypoint = true
}
} else {
lines = append(lines, "# Run litestream script as entrypoint")
lines = append(lines, fmt.Sprintf("ENTRYPOINT [\"/bin/bash\", \"%s\"]", script))
lines = append(lines, "")
insertedEntrypoint = true
}
}

// Add wget to install litestream
if strings.Contains(line, "build-essential") && !installedWget {
line = strings.Replace(line, "build-essential", "build-essential wget", 1)
installedWget = true
}

// Copy litestream binary from build stage, and setup from source
if strings.HasPrefix(strings.TrimSpace(line), "USER ") && !copiedLitestream {
lines = append(lines, "# Copy Litestream binary from build stage")
lines = append(lines, "COPY --from=builder /usr/bin/litestream /usr/bin/litestream")
lines = append(lines, "COPY litestream.sh /app/bin/litestream.sh")
lines = append(lines, "COPY config/litestream.yml /etc/litestream.yml")
lines = append(lines, "")
copiedLitestream = true
}

// Append original line
lines = append(lines, line)

// Install litestream
if strings.Contains(line, "apt-get clean") && !insertedLitestreamInstall {
lines = append(lines, "")
lines = append(lines, "# Install litestream")
lines = append(lines, "ARG LITESTREAM_VERSION=0.3.13")
lines = append(lines, "RUN wget /~https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.deb \\")
lines = append(lines, " && dpkg -i litestream-v${LITESTREAM_VERSION}-linux-amd64.deb")

insertedLitestreamInstall = true
}

// Check for existing entrypoint
if strings.HasPrefix(strings.TrimSpace(line), "ENTRYPOINT ") {
foundEntrypoint = true
}

// Track WORKDIR
if strings.HasPrefix(strings.TrimSpace(line), "WORKDIR ") {
workdir = strings.Split(strings.TrimSpace(line), " ")[1]
workdir = strings.Trim(workdir, "\"")
workdir = strings.TrimRight(workdir, "/")
}
}

// Check for errors
if err := scanner.Err(); err != nil {
return err
}

// If we didn't complete the insertion, return without writing to file
if !insertedLitestreamInstall || !insertedEntrypoint || !copiedLitestream {
fmt.Println("Failed to insert Litestream installation commands. Skipping Litestream installation.")
return nil
} else {
fmt.Fprintln(os.Stdout, "Updating Dockerfile to install Litestream")
}

// Write dockerfile back to file
dockerfile, err := os.Create("Dockerfile")
if err != nil {
return err
}
defer dockerfile.Close()

for _, line := range lines {
fmt.Fprintln(dockerfile, line)
}

// Create litestream.sh
script, err := os.Create("litestream.sh")
if err != nil {
return bufio.ErrBadReadCount
}
defer script.Close()

_, err = fmt.Fprint(script, strings.TrimSpace(`
#!/usr/bin/env bash
set -e
# If db doesn't exist, try restoring from object storage
if [ ! -f "$DATABASE_PATH" ] && [ -n "$BUCKET_NAME" ]; then
litestream restore -if-replica-exists "$DATABASE_PATH"
fi
# Migrate database
/app/bin/migrate
# Launch application
if [ -n "$BUCKET_NAME" ]; then
litestream replicate -exec "${*}"
else
exec "${@}"
fi
`))

if err != nil {
return err
}

// Create litestream.yml
config, err := os.Create("config/litestream.yml")
if err != nil {
return err
}

defer config.Close()

_, err = fmt.Fprint(config, strings.TrimSpace(strings.ReplaceAll(`
# This is the configuration file for litestream.
#
# For more details, see: https://litestream.io/reference/config/
#
dbs:
- path: $DATABASE_PATH
replicas:
- type: s3
endpoint: $AWS_ENDPOINT_URL_S3
bucket: $BUCKET_NAME
path: litestream${DATABASE_PATH}
access-key-id: $AWS_ACCESS_KEY_ID
secret-access-key: $AWS_SECRET_ACCESS_KEY
region: $AWS_REGION
`, "\t", " ")))

return err
}
1 change: 1 addition & 0 deletions scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type SourceInfo struct {
AutoInstrumentErrors bool
FailureCallback func(err error) error
Runtime plan.RuntimeStruct
PostInitCallback func() error
}

type SourceFile struct {
Expand Down

0 comments on commit b657dad

Please sign in to comment.