Skip to content

Commit

Permalink
feat: add ftl box --compose flag (#1915)
Browse files Browse the repository at this point in the history
The `--compose` flag writes a completely self-contained Docker compose
file that starts PG + ftl-in-a-box.

Also:
- Modified BuildEngine.Graph() to pull dependencies from the schema as
well, because that's all we have in the box.
- Added an integration test.

> [!WARNING]
> Until #1887 is integrated, config/secrets can't be used, and once that
lands `ftl box` will need to switch to an alternate config/secret
resolver.
  • Loading branch information
alecthomas authored Jul 1, 2024
1 parent 2b5a667 commit ff7375c
Show file tree
Hide file tree
Showing 17 changed files with 454 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ linters-settings:
- pkg: github.com/pkg/errors
desc: "use fmt.Errorf or errors.New"
- pkg: github.com/stretchr/testify
desc: "use fmt.Errorf or errors.New"
desc: "use github.com/alecthomas/assert/v2"
- pkg: github.com/alecthomas/errors
desc: "use fmt.Errorf or errors.New"
- pkg: braces.dev/errtrace
Expand Down
13 changes: 10 additions & 3 deletions buildengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/internal/log"
"github.com/TBD54566975/ftl/internal/rpc"
"github.com/TBD54566975/ftl/internal/slices"
)

type CompilerBuildError struct {
Expand Down Expand Up @@ -205,13 +206,19 @@ func (e *Engine) buildGraph(moduleName string, out map[string][]string) error {
if _, ok := out[moduleName]; ok {
return nil
}
foundModule := false
if meta, ok := e.moduleMetas.Load(moduleName); ok {
foundModule = true
deps = meta.module.Dependencies
} else if sch, ok := e.controllerSchema.Load(moduleName); ok {
deps = sch.Imports()
} else {
}
if sch, ok := e.controllerSchema.Load(moduleName); ok {
foundModule = true
deps = append(deps, sch.Imports()...)
}
if !foundModule {
return fmt.Errorf("module %q not found", moduleName)
}
deps = slices.Unique(deps)
out[moduleName] = deps
for _, dep := range deps {
if err := e.buildGraph(dep, out); err != nil {
Expand Down
123 changes: 116 additions & 7 deletions cmd/ftl/cmd_box.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"text/template"

"github.com/otiai10/copy"

Expand All @@ -18,11 +20,23 @@ import (
"github.com/TBD54566975/ftl/internal/log"
)

// Test locally by running:
//
// Rebuild the image:
// docker build -t ftl0/ftl-box:latest --platform=linux/amd64 -f Dockerfile.box .
//
// Build the box:
// ftl box echo --compose=echo-compose.yml

const boxftlProjectFile = `module-dirs = ["/root/modules"]
`

const boxDockerFile = `FROM {{.BaseImage}}
WORKDIR /root
COPY modules /root
COPY modules /root/modules
COPY ftl-project.toml /root
EXPOSE 8891
EXPOSE 8892
Expand All @@ -31,15 +45,75 @@ ENTRYPOINT ["/root/ftl", "box-run", "/root/modules"]
`

const boxComposeFile = `name: {{.Name}}-box
services:
db:
image: postgres
platform: linux/{{.GOARCH}}
command: postgres
user: postgres
restart: always
environment:
POSTGRES_PASSWORD: secret
expose:
- 5432
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 60s
retries: 60
start_period: 80s
{{.Name}}:
image: {{.Name}}
platform: linux/amd64
depends_on:
db:
condition: service_healthy
links:
- db
ports:
- "8891:8891"
- "8892:8892"
environment:
LOG_LEVEL: debug
FTL_CONFIG: /root/ftl-project.toml
FTL_CONTROLLER_DSN: postgres://postgres:secret@db:5432/ftl?sslmode=disable
`

func init() {
if strings.Contains(boxComposeFile, "\t") {
panic("tabs in boxComposeFile in cmd_box.go")
}
}

type boxCmd struct {
BaseImage string `help:"Name of the ftl-box Docker image to use as a base." default:"ftl0/ftl-box:${version}"`
Parallelism int `short:"j" help:"Number of modules to build in parallel." default:"${numcpu}"`
Image string `arg:"" help:"Name of image to build."`
Compose string `help:"Path to a compose file to generate."`
Name string `arg:"" help:"Name of the project."`
Dirs []string `arg:"" help:"Base directories containing modules (defaults to modules in project config)." type:"existingdir" optional:""`
}

func (b *boxCmd) Help() string {
return ``
return `
To build a new box with echo and time from examples/go:
ftl box echo --compose=echo-compose.yml
To run the box:
docker compose -f echo-compose.yml up --recreate --watch
Interact with the box:
ftl schema
ftl ps
ftl call echo echo '{name:"Alice"}'
Bring the box down:
docker compose -f echo-compose.yml down --rmi local
`
}

func (b *boxCmd) Run(ctx context.Context, client ftlv1connect.ControllerServiceClient, projConfig projectconfig.Config) error {
Expand Down Expand Up @@ -80,6 +154,7 @@ func (b *boxCmd) Run(ctx context.Context, client ftlv1connect.ControllerServiceC
return err
}
files = append(files, filepath.Join(config.Dir, "ftl.toml"))
files = append(files, config.Schema)
for _, file := range files {
relFile, err := filepath.Rel(config.Dir, file)
if err != nil {
Expand All @@ -104,11 +179,45 @@ func (b *boxCmd) Run(ctx context.Context, client ftlv1connect.ControllerServiceC
}
baseImage = baseImageParts[0] + ":" + version
}
dockerFile := strings.ReplaceAll(boxDockerFile, "{{.BaseImage}}", baseImage)
err = os.WriteFile(filepath.Join(workDir, "Dockerfile"), []byte(dockerFile), 0600)
err = writeFile(filepath.Join(workDir, "Dockerfile"), boxDockerFile, struct{ BaseImage string }{BaseImage: baseImage})
if err != nil {
return fmt.Errorf("failed to write Dockerfile: %w", err)
}
logger.Infof("Building image %s", b.Image)
return exec.Command(ctx, log.Debug, workDir, "docker", "build", "-t", b.Image, "--progress=plain", "--platform=linux/amd64", ".").RunBuffered(ctx)
err = writeFile(filepath.Join(workDir, "ftl-project.toml"), boxftlProjectFile, nil)
if err != nil {
return fmt.Errorf("failed to write ftl-project.toml: %w", err)
}
logger.Infof("Building image %s", b.Name)
err = exec.Command(ctx, log.Debug, workDir, "docker", "build", "-t", b.Name, "--progress=plain", "--platform=linux/amd64", ".").RunBuffered(ctx)
if err != nil {
return fmt.Errorf("failed to build image: %w", err)
}
if b.Compose != "" {
err = writeFile(b.Compose, boxComposeFile, struct {
Name string
GOARCH string
}{
Name: b.Name,
GOARCH: runtime.GOARCH,
})
if err != nil {
return fmt.Errorf("failed to write compose file: %w", err)
}
logger.Infof("Wrote compose file %s", b.Compose)
}
return nil
}

func writeFile(path, content string, context any) error {
t := template.Must(template.New(path).Parse(content))
w, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer w.Close() //nolint:errcheck
err = t.Execute(w, context)
if err != nil {
return fmt.Errorf("failed to write %q: %w", path, err)
}
return nil
}
18 changes: 18 additions & 0 deletions cmd/ftl/cmd_box_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import (
"github.com/TBD54566975/ftl/backend/controller/scaling/localscaling"
"github.com/TBD54566975/ftl/backend/controller/sql/databasetesting"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/buildengine"
"github.com/TBD54566975/ftl/internal/bind"
"github.com/TBD54566975/ftl/internal/log"
"github.com/TBD54566975/ftl/internal/model"
"github.com/TBD54566975/ftl/internal/rpc"
)
Expand Down Expand Up @@ -77,6 +79,22 @@ func (b *boxRunCmd) Run(ctx context.Context) error {
return fmt.Errorf("failed to create build engine: %w", err)
}

logger := log.FromContext(ctx)

// Manually import the schema for each module to get the dependency graph.
err = engine.Each(func(m buildengine.Module) error {
logger.Debugf("Loading schema for module %q", m.Config.Module)
mod, err := schema.ModuleFromProtoFile(m.Config.Abs().Schema)
if err != nil {
return fmt.Errorf("failed to read schema for module %q: %w", m.Config.Module, err)
}
engine.Import(ctx, mod)
return nil
})
if err != nil {
return fmt.Errorf("failed to load schemas: %w", err)
}

if err := engine.Deploy(ctx, 1, true); err != nil {
return fmt.Errorf("failed to deploy: %w", err)
}
Expand Down
31 changes: 31 additions & 0 deletions cmd/ftl/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//go:build integration

package main

import (
"context"
"testing"

"github.com/alecthomas/assert/v2"

. "github.com/TBD54566975/ftl/integration"
"github.com/TBD54566975/ftl/internal/exec"
"github.com/TBD54566975/ftl/internal/log"
)

func TestBox(t *testing.T) {
// Need a longer timeout to wait for FTL inside Docker.
t.Setenv("FTL_INTEGRATION_TEST_TIMEOUT", "30s")
Infof("Building local ftl0/ftl-box:latest Docker image")
ctx := log.ContextWithNewDefaultLogger(context.Background())
err := exec.Command(ctx, log.Debug, "../..", "docker", "build", "-t", "ftl0/ftl-box:latest", "--progress=plain", "--platform=linux/amd64", "-f", "Dockerfile.box", ".").Run()
assert.NoError(t, err)
RunWithoutController(t, "",
CopyModule("time"),
CopyModule("echo"),
Exec("ftl", "box", "echo", "--compose=echo-compose.yml"),
Exec("docker", "compose", "-f", "echo-compose.yml", "up", "--wait"),
Call("echo", "echo", Obj{"name": "Alice"}, nil),
Exec("docker", "compose", "-f", "echo-compose.yml", "down", "--rmi", "local"),
)
}
32 changes: 32 additions & 0 deletions cmd/ftl/testdata/go/echo/echo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// This is the echo module.
package echo

import (
"context"
"fmt"

"ftl/time"

"github.com/TBD54566975/ftl/go-runtime/ftl"
)

// An echo request.
type EchoRequest struct {
Name ftl.Option[string] `json:"name"`
}

type EchoResponse struct {
Message string `json:"message"`
}

// Echo returns a greeting with the current time.
//
//ftl:verb
func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) {
tresp, err := ftl.Call(ctx, time.Time, time.TimeRequest{})
if err != nil {
return EchoResponse{}, err
}

return EchoResponse{Message: fmt.Sprintf("Hello, %s!!! It is %s!", req.Name.Default("world"), tresp.Time)}, nil
}
2 changes: 2 additions & 0 deletions cmd/ftl/testdata/go/echo/ftl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module = "echo"
language = "go"
45 changes: 45 additions & 0 deletions cmd/ftl/testdata/go/echo/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module ftl/echo

go 1.22.2

replace github.com/TBD54566975/ftl => ../../../../..

require github.com/TBD54566975/ftl v0.248.0

require (
connectrpc.com/connect v1.16.1 // indirect
connectrpc.com/grpcreflect v1.2.0 // indirect
connectrpc.com/otelconnect v0.7.0 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/alecthomas/concurrency v0.0.2 // indirect
github.com/alecthomas/participle/v2 v2.1.1 // indirect
github.com/alecthomas/types v0.16.0 // indirect
github.com/alessio/shellescape v1.4.2 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.2.0 // indirect
github.com/swaggest/jsonschema-go v0.3.72 // indirect
github.com/swaggest/refl v1.3.0 // indirect
github.com/zalando/go-keyring v0.2.5 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
Loading

0 comments on commit ff7375c

Please sign in to comment.