Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions cmd/auto_regenerate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,30 @@ func TestAutoRegenerate(t *testing.T) {
wantRegen: true,
wantBuild: true,
},
{
name: "missing base.Dockerfile forces structural regen + build",
seedYAML: yamlFrankenphpBase,
mutate: func(t *testing.T, dir string) string {
// Pre-split project: monolithic Dockerfile present, base absent.
// Version + hash both match, so only the structural trigger fires.
os.Remove(filepath.Join(dir, ".frank", "base.Dockerfile"))
return "1.0.0"
},
wantRegen: true,
wantBuild: true, // base.Dockerfile missing → dockerfileChanged fail-safe
},
{
name: "edited base.Dockerfile forces build on regen",
seedYAML: yamlFrankenphpBase,
mutate: func(t *testing.T, dir string) string {
// base.Dockerfile drifted from template; dev fires Tier 1 regen,
// dockerfileChanged then sees the mismatch and forces --build.
os.WriteFile(filepath.Join(dir, ".frank", "base.Dockerfile"), []byte("FROM scratch\n"), 0644)
return "dev"
},
wantRegen: true,
wantBuild: true,
},
{
name: "malformed yaml skips gracefully",
seedYAML: yamlFrankenphpBase,
Expand Down Expand Up @@ -264,6 +288,38 @@ func TestAutoRegenerate_QueueRepro(t *testing.T) {
}
}

// TestDockerfileChanged_BaseDockerfile proves base.Dockerfile is part of the
// diff set: a freshly generated project reports no change, while deleting or
// editing base.Dockerfile (leaving the primary Dockerfile intact) flips it true.
func TestDockerfileChanged_BaseDockerfile(t *testing.T) {
dir := seedFrankProject(t, yamlFrankenphpBase, "1.0.0")
cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("config.Load: %v", err)
}

if dockerfileChanged(dir, cfg) {
t.Fatal("freshly generated project should report no dockerfile change")
}

// Edit base.Dockerfile only → must report changed.
base := filepath.Join(dir, ".frank", "base.Dockerfile")
if err := os.WriteFile(base, []byte("FROM scratch\n"), 0644); err != nil {
t.Fatal(err)
}
if !dockerfileChanged(dir, cfg) {
t.Error("edited base.Dockerfile should report changed")
}

// Delete base.Dockerfile → must report changed.
if err := os.Remove(base); err != nil {
t.Fatal(err)
}
if !dockerfileChanged(dir, cfg) {
t.Error("missing base.Dockerfile should report changed")
}
}

// TestFrankConfigHash_Deterministic ensures the hash is stable across calls so
// it doesn't churn or cause spurious regenerations.
func TestFrankConfigHash_Deterministic(t *testing.T) {
Expand Down
28 changes: 28 additions & 0 deletions cmd/baseimage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cmd

import "testing"

func TestComposeSubcmdBuilds(t *testing.T) {
cases := []struct {
name string
args []string
want bool
}{
{"build", []string{"build"}, true},
{"up detached", []string{"up", "-d"}, true},
{"run", []string{"run", "laravel.test", "bash"}, true},
{"create", []string{"create"}, true},
{"ps", []string{"ps", "-a"}, false},
{"logs follow", []string{"logs", "-f"}, false},
{"down", []string{"down"}, false},
{"leading file flag then build", []string{"-f", "x", "build"}, true},
{"empty", []string{}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := composeSubcmdBuilds(tc.args); got != tc.want {
t.Errorf("composeSubcmdBuilds(%v) = %v, want %v", tc.args, got, tc.want)
}
})
}
}
9 changes: 9 additions & 0 deletions cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,14 @@ func runCompose(cmd *cobra.Command, args []string) error {
composeArgs = composeArgs[1:]
}

// Build-capable subcommands (build/up/run/create) need the shared base
// image present, since the thin .frank/Dockerfile is `FROM frank/runtime`.
// Read-only subcommands (ps/logs/down/…) skip this.
if composeSubcmdBuilds(composeArgs) {
if err := ensureBaseImage(dir); err != nil {
return err
}
}

return docker.New(dir).Run(composeArgs...)
}
3 changes: 3 additions & 0 deletions cmd/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ var downCmd = &cobra.Command{
}
}

// `docker compose down` runs without --rmi, so the shared base
// (frank/runtime:*) is NOT removed — other Frank projects depend on it.
// Do not add --rmi here.
region := output.Region("Stopping containers")
err := client.RunStream(region, "down")
region.Stop(err)
Expand Down
51 changes: 51 additions & 0 deletions cmd/eject.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package cmd

import (
"fmt"
"path/filepath"
"strings"

"github.com/phlisg/frank/internal/baseimage"
"github.com/phlisg/frank/internal/compose"
"github.com/phlisg/frank/internal/config"
"github.com/phlisg/frank/internal/docker"
"github.com/phlisg/frank/internal/output"
"github.com/phlisg/frank/internal/template"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -74,6 +77,54 @@ func runEject(cmd *cobra.Command, args []string) error {
output.Warning(fmt.Sprintf("could not restore phpunit.xml: %v", err))
}

// Flatten .frank/Dockerfile from the thin `FROM frank/runtime:<tag>` form
// back to a self-contained Dockerfile. Sail can't rebuild FROM frank/runtime
// (it has no notion of Frank's shared base), so an ejected project must be
// fully Frank-independent. Non-fatal: the project still works against any
// already-built image.
if err := flattenDockerfile(dir, cfg); err != nil {
output.Warning(fmt.Sprintf("could not flatten .frank/Dockerfile: %v", err))
} else {
output.Detail("flattened .frank/Dockerfile to self-contained form")
}

fmt.Println(" eject complete — run ./vendor/bin/sail up to start containers")
return nil
}

// caddyfileBlock is the laravel.test-specific COPY layer the base template omits.
// In the pre-split monolithic Dockerfile it sat between the user-setup block and
// the entrypoint heredoc — flattening reinserts it there so the byte output
// matches that original single-file form.
const caddyfileBlock = "# Copy Caddyfile (generated at .frank/Caddyfile; build context is project root)\n" +
"COPY .frank/Caddyfile /etc/caddy/Caddyfile\n\n"

// entrypointMarker is the comment line that opens the entrypoint heredoc section
// in both base templates. The Caddyfile block is reinserted immediately before it.
const entrypointMarker = "# Entrypoint (heredoc"

// flattenDockerfile rewrites .frank/Dockerfile from the thin
// `FROM frank/runtime:<tag>` form into a self-contained Dockerfile, so the
// ejected project no longer depends on Frank's shared base image.
//
// It renders the base Dockerfile (whose FROM is dunglas/frankenphp:… for
// frankenphp, ubuntu:24.04 for fpm) and — for frankenphp only — reinserts the
// app-specific Caddyfile COPY layer that the base template omits, restoring the
// original monolithic byte-for-byte layout.
func flattenDockerfile(dir string, cfg *config.Config) error {
engine := template.New(TemplateFS)

body, err := baseimage.Render(engine, cfg)
if err != nil {
return fmt.Errorf("render base Dockerfile: %w", err)
}

if cfg.PHP.Runtime == "frankenphp" {
body = strings.Replace(body, entrypointMarker, caddyfileBlock+entrypointMarker, 1)
}

if err := writeFile(filepath.Join(dir, ".frank", "Dockerfile"), body); err != nil {
return err
}
return nil
}
79 changes: 79 additions & 0 deletions cmd/eject_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/phlisg/frank/internal/config"
)

// TestFlattenDockerfile asserts that eject's flatten step rewrites
// .frank/Dockerfile into a self-contained form: no FROM frank/runtime, the
// runtime's real base FROM, and (frankenphp only) the Caddyfile COPY layer.
func TestFlattenDockerfile(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
wantFrom string
wantCaddyCopy bool
}{
{
name: "frankenphp",
cfg: &config.Config{
PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"},
Services: []string{"pgsql", "mailpit"},
},
wantFrom: "FROM dunglas/frankenphp",
wantCaddyCopy: true,
},
{
name: "fpm",
cfg: &config.Config{
PHP: config.PHP{Version: "8.4", Runtime: "fpm"},
Services: []string{"mysql", "redis"},
},
wantFrom: "FROM ubuntu:24.04",
wantCaddyCopy: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
frankDir := filepath.Join(dir, ".frank")
if err := os.MkdirAll(frankDir, 0755); err != nil {
t.Fatal(err)
}
// Placeholder Caddyfile + thin Dockerfile to be overwritten.
if err := os.WriteFile(filepath.Join(frankDir, "Caddyfile"), []byte("# placeholder\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(frankDir, "Dockerfile"), []byte("FROM frank/runtime:thin\n"), 0644); err != nil {
t.Fatal(err)
}

if err := flattenDockerfile(dir, tt.cfg); err != nil {
t.Fatalf("flattenDockerfile: %v", err)
}

out, err := os.ReadFile(filepath.Join(frankDir, "Dockerfile"))
if err != nil {
t.Fatal(err)
}
got := string(out)

if strings.Contains(got, "FROM frank/runtime") {
t.Errorf("flattened Dockerfile still contains FROM frank/runtime")
}
if !strings.Contains(got, tt.wantFrom) {
t.Errorf("flattened Dockerfile missing %q\n---\n%s", tt.wantFrom, got)
}
hasCaddy := strings.Contains(got, "COPY .frank/Caddyfile")
if hasCaddy != tt.wantCaddyCopy {
t.Errorf("Caddyfile COPY present=%v, want %v", hasCaddy, tt.wantCaddyCopy)
}
})
}
}
13 changes: 13 additions & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"

"github.com/phlisg/frank/internal/baseimage"
"github.com/phlisg/frank/internal/cert"
"github.com/phlisg/frank/internal/compose"
"github.com/phlisg/frank/internal/config"
Expand Down Expand Up @@ -162,6 +163,18 @@ func generate(cfg *config.Config, dir, version string) error {
}
output.Detail("wrote .frank/Dockerfile")

// Render the shared base Dockerfile (project-invariant; built once and reused
// by the thin .frank/Dockerfile via FROM frank/runtime:<tag>). One render for
// both runtimes — baseimage.Render selects the template by cfg.PHP.Runtime.
baseDockerfile, err := baseimage.Render(engine, cfg)
if err != nil {
return fmt.Errorf("render base.Dockerfile: %w", err)
}
if err := writeFile(filepath.Join(frankDir, "base.Dockerfile"), baseDockerfile); err != nil {
return err
}
output.Detail("wrote .frank/base.Dockerfile")

switch cfg.PHP.Runtime {
case "frankenphp":
caddyfile, err := engine.RenderRuntime("frankenphp", "Caddyfile.tmpl", data)
Expand Down
Loading