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
9 changes: 9 additions & 0 deletions internal/commands/service/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ func addBinary(ctx context.Context, reg *registry.Registry, svc services.BinaryS
return fmt.Errorf("cannot save registry: %w", err)
}

// Projects linked before this service was added won't be in ProjectsUsingService;
// bind them now so updateLinkedProjectsEnvBinary can find them.
if err := bindBinaryServiceToAllProjects(reg, name); err != nil {
return fmt.Errorf("cannot bind service to projects: %w", err)
}
if err := reg.Save(); err != nil {
return fmt.Errorf("cannot save registry after binding service: %w", err)
}

// Update .env for linked Laravel projects — parity with the docker path
// (updateLinkedProjectsEnv at the end of addDocker). Without this the
// user adds s3 but linked projects never get AWS_* keys written.
Expand Down
29 changes: 29 additions & 0 deletions internal/commands/service/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,35 @@ func updateLinkedProjectsEnvBinary(reg *registry.Registry, svcName string, svc s
}
}

// bindBinaryServiceToAllProjects sets the per-project Services flag for svcName
// on every Laravel project so updateLinkedProjectsEnvBinary can find projects
// that were linked before the service existed. Returns an error for unknown
// svcName so new binary services can't silently skip this step — the set of
// cases here must stay in lockstep with registry.ProjectServices fields.
func bindBinaryServiceToAllProjects(reg *registry.Registry, svcName string) error {
switch svcName {
case "mail", "s3":
default:
return fmt.Errorf("bindBinaryServiceToAllProjects: unknown binary service %q (add a case here and a field on ProjectServices)", svcName)
}
for i := range reg.Projects {
p := &reg.Projects[i]
if p.Type != "laravel" && p.Type != "laravel-octane" {
continue
}
if p.Services == nil {
p.Services = &registry.ProjectServices{}
}
switch svcName {
case "mail":
p.Services.Mail = true
case "s3":
p.Services.S3 = true
}
}
return nil
}

// applyStopAllFallbacks applies env fallbacks for every Docker service in the
// registry. Binary services are skipped because the stop-all command does not
// stop them (they're managed by the daemon). Called from the no-args
Expand Down
110 changes: 110 additions & 0 deletions internal/commands/service/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,116 @@ func TestUpdateLinkedProjectsEnv_OnlyUpdatesLinkedProject(t *testing.T) {
}
}

func TestBindBinaryServiceToAllProjects_Mail(t *testing.T) {
reg := &registry.Registry{
Projects: []registry.Project{
{Name: "app-no-services", Path: "/tmp/a", Type: "laravel"},
{Name: "app-services-unset", Path: "/tmp/b", Type: "laravel",
Services: &registry.ProjectServices{Redis: true}},
{Name: "app-octane", Path: "/tmp/c", Type: "laravel-octane"},
{Name: "app-other", Path: "/tmp/d", Type: "static"},
{Name: "app-already", Path: "/tmp/e", Type: "laravel",
Services: &registry.ProjectServices{Mail: true}},
},
}

if err := bindBinaryServiceToAllProjects(reg, "mail"); err != nil {
t.Fatalf("bindBinaryServiceToAllProjects returned error: %v", err)
}

for _, tc := range []struct {
name string
wantMail bool
}{
{"app-no-services", true},
{"app-services-unset", true},
{"app-octane", true},
{"app-other", false},
{"app-already", true},
} {
p := reg.Find(tc.name)
if p == nil {
t.Fatalf("project %q not found", tc.name)
}
gotMail := p.Services != nil && p.Services.Mail
if gotMail != tc.wantMail {
t.Errorf("project %q: Mail = %v, want %v", tc.name, gotMail, tc.wantMail)
}
}
}

func TestBindBinaryServiceToAllProjects_S3(t *testing.T) {
reg := &registry.Registry{
Projects: []registry.Project{
{Name: "app-laravel", Path: "/tmp/a", Type: "laravel"},
{Name: "app-static", Path: "/tmp/b", Type: "static"},
},
}

if err := bindBinaryServiceToAllProjects(reg, "s3"); err != nil {
t.Fatalf("bindBinaryServiceToAllProjects returned error: %v", err)
}

laravelApp := reg.Find("app-laravel")
if laravelApp == nil || !laravelApp.Services.S3 {
t.Error("app-laravel: S3 should be true after bindBinaryServiceToAllProjects")
}
staticApp := reg.Find("app-static")
if staticApp != nil && staticApp.Services != nil && staticApp.Services.S3 {
t.Error("app-static: S3 should not be set for non-Laravel projects")
}
}

func TestBindBinaryServiceToAllProjects_UnknownServiceReturnsError(t *testing.T) {
reg := &registry.Registry{
Projects: []registry.Project{
{Name: "app", Path: "/tmp/a", Type: "laravel"},
},
}

err := bindBinaryServiceToAllProjects(reg, "bogus")
if err == nil {
t.Fatal("expected error for unknown service name, got nil")
}

p := reg.Find("app")
if p != nil && p.Services != nil {
t.Error("unknown service: must not mutate project Services (guards against silent skips when new binary services are added)")
}
}

// TestBindBinaryServiceToAllProjects_EnablesProjectsUsingServiceLookup locks
// the contract with registry.ProjectsUsingService — the reason this function
// exists. Regression here would silently break the #69 fix.
func TestBindBinaryServiceToAllProjects_EnablesProjectsUsingServiceLookup(t *testing.T) {
reg := &registry.Registry{
Projects: []registry.Project{
{Name: "pre-linked", Path: "/tmp/a", Type: "laravel"},
{Name: "pre-linked-octane", Path: "/tmp/b", Type: "laravel-octane"},
{Name: "static-site", Path: "/tmp/c", Type: "static"},
},
}

if before := reg.ProjectsUsingService("mail"); len(before) != 0 {
t.Fatalf("precondition: ProjectsUsingService(mail) should be empty before bind, got %d", len(before))
}

if err := bindBinaryServiceToAllProjects(reg, "mail"); err != nil {
t.Fatalf("bindBinaryServiceToAllProjects returned error: %v", err)
}

names := map[string]bool{}
for _, n := range reg.ProjectsUsingService("mail") {
names[n] = true
}
if !names["pre-linked"] || !names["pre-linked-octane"] {
t.Errorf("ProjectsUsingService(mail) missing laravel projects after bind: got %v", names)
}
if names["static-site"] {
t.Error("ProjectsUsingService(mail) should not include non-Laravel projects")
}
}

func TestUnbindService_ClearsMailBinding(t *testing.T) {
reg := &registry.Registry{
Projects: []registry.Project{
Expand Down
21 changes: 21 additions & 0 deletions scripts/e2e/s3-binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@ START_PID=$!
sleep 8

cleanup() {
sudo -E pv unlink e2e-s3-env >/dev/null 2>&1 || true
sudo -E pv stop >/dev/null 2>&1 || true
rm -rf "${ENVTEST_DIR:-}" 2>/dev/null || true
}
trap cleanup EXIT

# Create a minimal linked Laravel project BEFORE service:add so we exercise
# the retroactive-bind path (issue #69): projects linked before a binary
# service existed must still get their .env keys written.
ENVTEST_DIR=$(mktemp -d)
echo '{"require":{"php":"^8.2","laravel/framework":"^11.0"}}' > "$ENVTEST_DIR/composer.json"
mkdir -p "$ENVTEST_DIR/public"
echo '<?php echo "test";' > "$ENVTEST_DIR/public/index.php"
echo "FILESYSTEM_DISK=local" > "$ENVTEST_DIR/.env"
sudo -E pv link "$ENVTEST_DIR" --name e2e-s3-env >/dev/null 2>&1 || { echo "FAIL: pv link for env test"; exit 1; }

echo "==> service:add s3"
sudo -E pv service:add s3 || { echo "FAIL: pv service:add s3 failed"; exit 1; }

Expand Down Expand Up @@ -42,6 +54,15 @@ done
nc -z 127.0.0.1 9000 || { echo "FAIL: port 9000 not reachable after service:add"; exit 1; }
echo "OK: port 9000 reachable"

echo "==> Verify linked project .env got AWS_ENDPOINT"
grep -q "AWS_ENDPOINT=http://127.0.0.1:9000" "$ENVTEST_DIR/.env" || {
echo "FAIL: linked project .env should have AWS_ENDPOINT after service:add s3";
echo " actual .env contents:";
cat "$ENVTEST_DIR/.env";
exit 1;
}
echo "OK: linked project .env has AWS_ENDPOINT"

echo "==> service:stop s3"
sudo -E pv service:stop s3
sleep 2
Expand Down
Loading