diff --git a/docs/commands.md b/docs/commands.md index fc847a9a..01e96b8c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -525,11 +525,15 @@ host's gateway (`/v1//…`), and a browser reaches a solution's page and backend through it (`//…`), so the environment's `host.gateway` service receives a `CODEFLY__ENDPOINT__…` carrier for every module endpoint a composed solution's `api.consumes` names and one for every composed solution's backend -endpoint — the one `http` or `rest` endpoint its `service-entry` declares — in -a run at the address the run allocated, at the gateway's own network access; -in a render at the producer's in-cluster address. The gateway resolves both -through the Codefly SDK from the carrier and forwards plain HTTP: a solution -reports no address and serves no certificate, the mesh carries its identity. +and every module's declared page — the one `http`, `rest` or `connect` endpoint +its `service-entry` declares (Connect is HTTP on its own listener, so a gRPC + +Connect backend owns a page; one `rest` beside one `connect` is forwarded to +`connect`, since REST is deprecated; none or several is refused, naming the +endpoints the entry declares) — in a run at the address the run allocated, at +the gateway's own network access; in a render at the producer's in-cluster +address. The gateway resolves both through the Codefly SDK from the carrier and +forwards plain HTTP: a solution reports no address and serves no certificate, +the mesh carries its identity. No dependency edge is added: the gateway neither waits on nor builds a module or a solution. The derivation is the one every carrier uses (the producer's own mappings, judged by its declared visibility for the gateway's module, so a diff --git a/pkg/orchestration/host_route_carriers.go b/pkg/orchestration/host_route_carriers.go index 62e630a2..e67d921d 100644 --- a/pkg/orchestration/host_route_carriers.go +++ b/pkg/orchestration/host_route_carriers.go @@ -24,12 +24,13 @@ import ( // declares it consumes; // - a browser reaches a solution backend or a module's declared page through it // (//…), so it must reach each component's entry endpoint — the -// one HTTP endpoint the solution's entry service declares — in plain -// HTTP, the way it already reaches a module. Nothing a solution reports -// at run time is an address: the gateway routes a solution from its -// applied module manifest's endpoints, which carry these addresses, as it routes a -// module, and a solution's transport identity is the mesh's, not a -// certificate it serves. +// one http, rest or connect endpoint the entry service declares +// (pageEntryEndpoint) — in plain HTTP, the way it already reaches a +// module. Nothing a solution reports at run time is an address: the +// gateway routes a solution from its applied module manifest's +// endpoints, which carry these addresses, as it routes a module, and a +// solution's transport identity is the mesh's, not a certificate it +// serves. // // Both come from here the same way every carrier does: the producer's own // network mappings (producerNetworkMappings — its deployed in-cluster mappings @@ -171,8 +172,9 @@ func (route hostRoute) describe() string { // /v1/ on the module's name, so a consume that names it under another // alias, or two solutions reaching one module through different endpoints, // has no single route and is refused rather than chosen between; and it -// routes // to one HTTP endpoint of the solution's entry service, so an -// entry declaring none or several is refused the same way. +// routes // to one http, rest or connect endpoint of the component's +// entry service (pageEntryEndpoint), so an entry with no single one is refused +// the same way. func (world *World) hostRoutes(ctx context.Context, hostModule string) ([]hostRoute, error) { return hostRoutes(ctx, world.Workspace, hostModule) } @@ -290,24 +292,53 @@ func componentBackendRoutes(ctx context.Context, workspace *resources.Workspace, if err != nil { return nil, fmt.Errorf("component %s names %s as its service-entry: %w", component.Name, component.ServiceEntry, err) } - var candidates []string - for _, endpoint := range entry.Endpoints { - if endpoint != nil && (endpoint.API == standards.HTTP || endpoint.API == standards.REST) { - candidates = append(candidates, endpoint.Name) - } - } - switch len(candidates) { - case 1: - routes = append(routes, hostRoute{solution: component.Name, module: component.Name, service: entry.Name, endpoint: candidates[0], backend: true}) - case 0: - return nil, fmt.Errorf("component %s's entry service %s declares no http or rest endpoint; the host forwards /%s/ to one", component.Name, entry.Name, component.Name) - default: - return nil, fmt.Errorf("component %s's entry service %s declares several http or rest endpoints (%s); the host forwards /%s/ to one, and which has no single answer", component.Name, entry.Name, strings.Join(candidates, ", "), component.Name) + endpoint, err := pageEntryEndpoint(entry.Endpoints) + if err != nil { + return nil, fmt.Errorf("component %s's entry service %s %w; the host forwards /%s/ to one", component.Name, entry.Name, err, component.Name) } + routes = append(routes, hostRoute{solution: component.Name, module: component.Name, service: entry.Name, endpoint: endpoint, backend: true}) } return routes, nil } +// pageEntryEndpoint is the one endpoint of a component's entry service the +// host forwards // to: its http, rest or connect endpoint. Connect is +// HTTP (HTTP/1.1 + JSON) on its own listener, so the host forwards a page's +// calls to it exactly as to an HTTP endpoint, which is what lets a gRPC + +// Connect backend own a page. An entry declaring exactly one rest and one +// connect endpoint is forwarded to connect: REST is deprecated, and Connect +// serves the same API. Any other entry with none, or several, has no single +// answer and is refused, naming the endpoints it declares. +func pageEntryEndpoint(endpoints []*resources.Endpoint) (string, error) { + var candidates, declared []string + byAPI := map[string][]string{} + for _, endpoint := range endpoints { + if endpoint == nil { + continue + } + named := endpoint.Name + " (" + endpoint.API + ")" + declared = append(declared, named) + if endpoint.API == standards.HTTP || endpoint.API == standards.REST || endpoint.API == standards.CONNECT { + candidates = append(candidates, named) + byAPI[endpoint.API] = append(byAPI[endpoint.API], endpoint.Name) + } + } + switch { + case len(candidates) == 1: + for _, names := range byAPI { + return names[0], nil + } + case len(candidates) == 2 && len(byAPI[standards.REST]) == 1 && len(byAPI[standards.CONNECT]) == 1: + return byAPI[standards.CONNECT][0], nil + case len(candidates) == 0: + if len(declared) == 0 { + declared = []string{"none"} + } + return "", fmt.Errorf("declares no http, rest or connect endpoint (it declares: %s)", strings.Join(declared, ", ")) + } + return "", fmt.Errorf("declares several http, rest or connect endpoints (%s)", strings.Join(candidates, ", ")) +} + // HostRouteCarriers is hostRouteCarriers for a render: the gateway's route // carriers at container access, keyed by the gateway's unique, when this flow // deploys it. diff --git a/pkg/orchestration/host_route_carriers_test.go b/pkg/orchestration/host_route_carriers_test.go index fc92531f..d0e114aa 100644 --- a/pkg/orchestration/host_route_carriers_test.go +++ b/pkg/orchestration/host_route_carriers_test.go @@ -267,8 +267,8 @@ func TestOneUnroutableConsumeLeavesTheOtherRoutes(t *testing.T) { // rather than one being chosen. func TestASolutionBackendIsOneHTTPEndpointOfItsEntry(t *testing.T) { for _, tc := range []struct{ name, endpoints, want string }{ - {"none", "endpoints:\n - name: grpc\n api: grpc\n", "declares no http or rest endpoint"}, - {"several", "endpoints:\n - name: http\n api: http\n - name: rest\n api: rest\n", "several http or rest endpoints (http, rest)"}, + {"none", "endpoints:\n - name: grpc\n api: grpc\n", "declares no http, rest or connect endpoint (it declares: grpc (grpc))"}, + {"several", "endpoints:\n - name: http\n api: http\n - name: rest\n api: rest\n", "several http, rest or connect endpoints (http (http), rest (rest))"}, } { t.Run(tc.name, func(t *testing.T) { world, workspace := hostRoutesWorld(t, SnapshotMode, func(dir string) { @@ -287,44 +287,81 @@ func TestASolutionBackendIsOneHTTPEndpointOfItsEntry(t *testing.T) { } } -// A shared module's page is a route independently of any solution's API needs. +// A shared module's page is a route independently of any solution's API needs, +// and a gRPC + Connect backend owns one: the host forwards the page's calls to +// its Connect endpoint as it would to an HTTP one. func TestModulePageGetsACarrierWithoutASolutionConsumer(t *testing.T) { - for _, mode := range []Mode{SnapshotMode, RunMode} { - t.Run(fmt.Sprint(mode), func(t *testing.T) { - world, workspace := hostRoutesWorld(t, mode, func(dir string) { - writeWikiConsumes(t, dir, "") - module := filepath.Join(dir, "modules", "docs") - path := filepath.Join(module, "module.codefly.yaml") - raw, err := os.ReadFile(path) - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, append(raw, []byte("service-entry: documents\n")...), 0o600)) - entry := filepath.Join(module, "services", "documents") - require.NoError(t, os.WriteFile(filepath.Join(entry, "solution.frontend.codefly.yaml"), []byte("title: Documents\norder: 0\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) - path = filepath.Join(entry, "service.codefly.yaml") - raw, err = os.ReadFile(path) - require.NoError(t, err) - head, _, found := strings.Cut(string(raw), "endpoints:\n") - require.True(t, found) - require.NoError(t, os.WriteFile(path, []byte(head+"endpoints:\n - name: web\n api: http\n visibility: internal\n"), 0o600)) - }) - gateway := parityService(t, workspace, "host", "gateway") - access := resources.NewContainerNetworkAccess() - if mode == RunMode { - access = resources.NetworkAccessFromRuntimeContext(&basev0.RuntimeContext{Kind: resources.RuntimeContextNative}) - for _, pair := range [][2]string{{"docs", "documents"}, {"wiki", "backend"}} { - service := parityService(t, workspace, pair[0], pair[1]) - identity, err := service.Identity() + for _, tc := range []struct{ name, endpoints string }{ + {"http", " - name: web\n api: http\n visibility: internal\n"}, + {"connect", " - name: grpc\n api: grpc\n visibility: internal\n - name: web\n api: connect\n visibility: internal\n"}, + } { + for _, mode := range []Mode{SnapshotMode, RunMode} { + t.Run(fmt.Sprint(tc.name, "/", mode), func(t *testing.T) { + world, workspace := hostRoutesWorld(t, mode, func(dir string) { + writeWikiConsumes(t, dir, "") + module := filepath.Join(dir, "modules", "docs") + path := filepath.Join(module, "module.codefly.yaml") + raw, err := os.ReadFile(path) require.NoError(t, err) - endpoints, err := service.LoadEndpoints(t.Context()) + require.NoError(t, os.WriteFile(path, append(raw, []byte("service-entry: documents\n")...), 0o600)) + entry := filepath.Join(module, "services", "documents") + require.NoError(t, os.WriteFile(filepath.Join(entry, "solution.frontend.codefly.yaml"), []byte("title: Documents\norder: 0\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) + path = filepath.Join(entry, "service.codefly.yaml") + raw, err = os.ReadFile(path) require.NoError(t, err) - require.NoError(t, world.SharedState.RecordEndpoints(t.Context(), identity, endpoints)) + head, _, found := strings.Cut(string(raw), "endpoints:\n") + require.True(t, found) + require.NoError(t, os.WriteFile(path, []byte(head+"endpoints:\n"+tc.endpoints), 0o600)) + }) + gateway := parityService(t, workspace, "host", "gateway") + access := resources.NewContainerNetworkAccess() + if mode == RunMode { + access = resources.NetworkAccessFromRuntimeContext(&basev0.RuntimeContext{Kind: resources.RuntimeContextNative}) + for _, pair := range [][2]string{{"docs", "documents"}, {"wiki", "backend"}} { + service := parityService(t, workspace, pair[0], pair[1]) + identity, err := service.Identity() + require.NoError(t, err) + endpoints, err := service.LoadEndpoints(t.Context()) + require.NoError(t, err) + require.NoError(t, world.SharedState.RecordEndpoints(t.Context(), identity, endpoints)) + } } + carriers, problems, err := world.hostRouteCarriers(t.Context(), gateway, access) + require.NoError(t, err) + require.Empty(t, problems) + key := resources.EndpointAsEnvironmentVariableKey(&resources.EndpointInformation{Module: "docs", Service: "documents", Name: "web", API: tc.name}) + require.NotEmpty(t, carriers[key], "the module page needs its own route without a solution consumer") + }) + } + } +} + +// The page's entry is the entry service's one http, rest or connect endpoint; +// one rest beside one connect is forwarded to connect (REST is deprecated), and +// anything else with no single answer is refused naming what it declares. +func TestAPageEntryIsOneHTTPRestOrConnectEndpoint(t *testing.T) { + endpoint := func(name, api string) *resources.Endpoint { return &resources.Endpoint{Name: name, API: api} } + for _, tc := range []struct { + name string + endpoints []*resources.Endpoint + want string + refusal string + }{ + {"grpc and connect", []*resources.Endpoint{endpoint("grpc", "grpc"), endpoint("connect", "connect")}, "connect", ""}, + {"rest and connect", []*resources.Endpoint{endpoint("rest", "rest"), endpoint("connect", "connect")}, "connect", ""}, + {"grpc only", []*resources.Endpoint{endpoint("grpc", "grpc")}, "", "declares no http, rest or connect endpoint (it declares: grpc (grpc))"}, + {"nothing", nil, "", "(it declares: none)"}, + {"two connect", []*resources.Endpoint{endpoint("a", "connect"), endpoint("b", "connect")}, "", "several http, rest or connect endpoints (a (connect), b (connect))"}, + {"http and connect", []*resources.Endpoint{endpoint("http", "http"), endpoint("connect", "connect")}, "", "several http, rest or connect endpoints (http (http), connect (connect))"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := pageEntryEndpoint(tc.endpoints) + if tc.refusal != "" { + require.ErrorContains(t, err, tc.refusal) + return } - carriers, problems, err := world.hostRouteCarriers(t.Context(), gateway, access) require.NoError(t, err) - require.Empty(t, problems) - key := resources.EndpointAsEnvironmentVariableKey(&resources.EndpointInformation{Module: "docs", Service: "documents", Name: "web", API: "http"}) - require.NotEmpty(t, carriers[key], "the module page needs its own route without a solution consumer") + require.Equal(t, tc.want, got) }) } }