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
14 changes: 9 additions & 5 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,15 @@ host's gateway (`/v1/<as>/…`), and a browser reaches a solution's page and
backend through it (`/<alias>/…`), 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
Expand Down
73 changes: 52 additions & 21 deletions pkg/orchestration/host_route_carriers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ import (
// declares it consumes;
// - a browser reaches a solution backend or a module's declared page through it
// (/<alias>/…), 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
Expand Down Expand Up @@ -171,8 +172,9 @@ func (route hostRoute) describe() string {
// /v1/<alias> 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 /<alias>/ to one HTTP endpoint of the solution's entry service, so an
// entry declaring none or several is refused the same way.
// routes /<alias>/ 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)
}
Expand Down Expand Up @@ -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 /<alias>/ 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.
Expand Down
105 changes: 71 additions & 34 deletions pkg/orchestration/host_route_carriers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
})
}
}
Expand Down
Loading