diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 93d8add1..8b349a04 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -898,7 +898,7 @@ func (cluster *launcher) renderDelivery(ctx context.Context, deliverer *gitops.N } services = append(services, service) } - if early { + if early && bundle == nil { // Deferral removes a page. Keep a previously published payload during // unrelated partial startup instead of accidentally unpublishing it. if previous := cluster.delivered[module]; previous != nil && previous.Frontend != nil { diff --git a/cmd/run/launcher_admission.go b/cmd/run/launcher_admission.go index f692237f..43b35c16 100644 --- a/cmd/run/launcher_admission.go +++ b/cmd/run/launcher_admission.go @@ -80,10 +80,6 @@ func (cluster *launcher) beginRuntimeStart(ctx context.Context, service *resourc // Failed Start leaves an admitted but unready workload, never a terminal // withdrawal; normal run teardown stops credential refresh. func (cluster *launcher) admitSelected(ctx context.Context) error { - if err := cluster.acquireDelivery(ctx); err != nil { - return err - } - defer cluster.delivery.Release(1) // One snapshot of the selection decides both what is admitted and which // binding targets are part of the run. modules, selected := map[string]bool{}, map[string]bool{} @@ -100,6 +96,27 @@ func (cluster *launcher) admitSelected(ctx context.Context) error { names = append(names, module) } sort.Strings(names) + // A declared page is part of what a module is: the authority admits a + // module's roles only beside its page, so admission names the page's + // bundle as a ready delivery does. It is built here, before the delivery + // lock, so a build delays no other module's delivery; the ready delivery + // reuses the bundle retained for unchanged inputs. + loadedModules := make([]*resources.Module, len(names)) + bundles := make([]*solutionhost.FrontendPayload, len(names)) + for n, module := range names { + loaded, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return err + } + if bundles[n], err = cluster.frontendBundle(ctx, loaded); err != nil { + return err + } + loadedModules[n] = loaded + } + if err := cluster.acquireDelivery(ctx); err != nil { + return err + } + defer cluster.delivery.Release(1) if err := cluster.checkRoutingSnapshot(ctx); err != nil { return err } @@ -108,14 +125,8 @@ func (cluster *launcher) admitSelected(ctx context.Context) error { return err } manifests := make([]*solutionhost.ModuleManifest, 0, len(names)) - for _, module := range names { - loaded, err := cluster.composed().LoadModuleFromName(ctx, module) - if err != nil { - return err - } - // Admission builds no page: the bundle a ready delivery builds, outside - // the delivery lock, publishes it. - manifest, err := cluster.renderDelivery(ctx, deliverer, loaded, true, nil) + for n, module := range names { + manifest, err := cluster.renderDelivery(ctx, deliverer, loadedModules[n], true, bundles[n]) if err != nil { return err } diff --git a/cmd/run/launcher_admission_test.go b/cmd/run/launcher_admission_test.go index faec4364..91e70846 100644 --- a/cmd/run/launcher_admission_test.go +++ b/cmd/run/launcher_admission_test.go @@ -23,6 +23,7 @@ import ( "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/run/identity" + "github.com/codefly-dev/cli/pkg/solutionrun" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/solutionhost" @@ -490,3 +491,49 @@ func TestFailedExternalPreparationReleasesRefresh(t *testing.T) { _, projected := cluster.vouch.Projected("alpha/api") require.False(t, projected, "failed preparation removes refresh membership") } + +// A declared page is part of what a module is: the authority admits a module's +// roles only beside its page, so admission names the page's bundle, built +// before the first consumer starts, rather than deferring it to the ready +// delivery. The ready delivery reuses the bundle retained for those inputs. +func TestAdmissionCarriesADeclaredPage(t *testing.T) { + cluster, peer := admissionLauncher(t) + dir := filepath.Join(cluster.composed().Dir(), "modules", "alpha") + moduleFile := filepath.Join(dir, resources.ModuleConfigurationName) + body, err := os.ReadFile(moduleFile) + require.NoError(t, err) + require.NoError(t, os.WriteFile(moduleFile, append([]byte("service-entry: api\n"), body...), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "services", "api", solutionrun.FrontendFileName), []byte("title: Alpha\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) + remote := filepath.Join(dir, "services", "fe-remote") + require.NoError(t, os.MkdirAll(remote, 0o755)) + for name, content := range map[string]string{"package.json": `{"scripts":{"build":"owner-build"}}`, "package-lock.json": `{"lockfileVersion":3}`, "Page.tsx": "page"} { + require.NoError(t, os.WriteFile(filepath.Join(remote, name), []byte(content), 0o600)) + } + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + builds := filepath.Join(t.TempDir(), "builds") + fakeTool(t, bin, "npm", `case "$*" in +ci) mkdir -p node_modules ;; +"run build") echo build >> `+builds+` && mkdir -p dist && printf '{}' > dist/mf-manifest.json ;; +esac +`) + workspace, err := resources.LoadWorkspaceFromDir(t.Context(), cluster.composed().Dir()) + require.NoError(t, err) + cluster.workspace = workspace + + require.NoError(t, cluster.admitSelected(t.Context())) + pages := map[string]*solutionhost.FrontendPayload{} + for _, m := range peer.snapshots() { + if m.Frontend != nil { + pages[m.Alias] = m.Frontend.Payload + } + } + require.Len(t, pages, 1, "only the module that declares a page is admitted with one") + require.NotNil(t, pages["alpha"], "admission names the page's bundle") + require.NotEmpty(t, pages["alpha"].Digest) + + require.NoError(t, cluster.admitSelected(t.Context())) + ran, err := os.ReadFile(builds) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(ran), "build"), "unchanged inputs reuse the retained bundle") +}