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
72 changes: 72 additions & 0 deletions cmd/run/generated_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package run
import (
"context"
"fmt"
"slices"
"strings"

"github.com/codefly-dev/core/configurations"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
"github.com/codefly-dev/core/resources"

"github.com/codefly-dev/cli/cmd/config"
Expand All @@ -28,9 +31,22 @@ func provisionGeneratedSecrets(ctx context.Context, workspace *resources.Workspa
if err != nil {
return nil, err
}
resource, err := env.Resource()
if err != nil {
return nil, err
}
profiles, err := resource.ConfigurationProfileNames()
if err != nil {
return nil, err
}
var written []string
for index := range secrets.Generate {
generator := &secrets.Generate[index]
if generator.Scope == environments.SecretGeneratorScopeWorkspace {
if err := refuseShadowingAnInheritedGroup(ctx, workspace, profiles, generator); err != nil {
return nil, fmt.Errorf("service-secrets generate[%d]: %w", index, err)
}
}
scopes, err := generatorScopes(ctx, workspace, generator)
if err != nil {
return nil, fmt.Errorf("service-secrets generate[%d]: %w", index, err)
Expand All @@ -50,6 +66,62 @@ func provisionGeneratedSecrets(ctx context.Context, workspace *resources.Workspa
return written, nil
}

// refuseShadowingAnInheritedGroup refuses a workspace-scope generator whose
// group this workspace inherits from a workspace it composes and does not
// author itself. The generated file is an own group of that name, and an own
// group replaces the inherited one whole (core's workspace composition) — so
// writing it, or reading one an earlier run wrote, silently drops every key
// the composed workspace supplies: a workspace importing platform-core lost
// the hop's MODULES that way, and with it every hop route. Declaring the group
// in `configuration-extensions` makes the generated keys extend the inherited
// group instead, which is then the composition's own answer. A group the
// workspace authors (an own file holding a key the generator does not write)
// is its author's to replace.
func refuseShadowingAnInheritedGroup(ctx context.Context, workspace *resources.Workspace, profiles []string, generator *environments.EnvironmentSecretGenerator) error {
name := generator.Configuration
if _, extends := workspace.ConfigurationExtensions[name]; extends {
return nil
}
inheritedFrom, err := composedWorkspaceDeclaring(ctx, workspace, profiles, name)
if err != nil || inheritedFrom == "" {
return err
}
own, err := configurations.LoadProfileConfigurations(ctx, workspace.Dir(), "configurations", profiles)
if err != nil {
return err
}
for _, info := range own.Infos {
if info.GetName() != name {
continue
}
for _, value := range info.GetConfigurationValues() {
if !slices.ContainsFunc(generator.Keys, func(key string) bool { return resources.Match(strings.TrimSpace(key), value.GetKey()) }) {
return nil
}
}
}
return fmt.Errorf("workspace %s inherits configuration %q from workspace %s and generates %s into it; a generated file is an own %q group, which replaces the inherited one whole and drops every key %s supplies: declare it under configuration-extensions in %s (`configuration-extensions: {%s: {}}`) so the generated keys extend the inherited group",
workspace.Name, name, inheritedFrom, strings.Join(generator.Keys, ", "), name, inheritedFrom, resources.WorkspaceConfigurationName, name)
}

// composedWorkspaceDeclaring names the composed workspace, at any depth, whose
// own configurations declare the group, or "" when none does.
func composedWorkspaceDeclaring(ctx context.Context, workspace *resources.Workspace, profiles []string, name string) (string, error) {
for _, child := range workspace.ComposedWorkspaces() {
provided, err := configurations.LoadProfileConfigurations(ctx, child.Dir(), "configurations", profiles)
if err != nil {
return "", err
}
if slices.ContainsFunc(provided.Infos, func(info *basev0.ConfigurationInformation) bool { return info.GetName() == name }) {
return child.Name, nil
}
if found, err := composedWorkspaceDeclaring(ctx, child, profiles, name); err != nil || found != "" {
return found, err
}
}
return "", nil
}

// generatorScopes is the directory each of the generator's configurations
// lives under: the workspace's, or each named service's.
func generatorScopes(ctx context.Context, workspace *resources.Workspace, generator *environments.EnvironmentSecretGenerator) ([]string, error) {
Expand Down
80 changes: 80 additions & 0 deletions cmd/run/generated_secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/codefly-dev/core/configurations"
"github.com/codefly-dev/core/resources"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -86,3 +87,82 @@ func TestALocalRunWritesNothingForAStoreAndGuessesNoService(t *testing.T) {
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "only for the services the generator names"), err.Error())
}

// importingWorkspace is a workspace composing `base` by path, whose own
// configurations declare the hop group (MODULES, ENDPOINT) the way
// platform-core does; extension and authored add the importing workspace's own
// declarations.
func importingWorkspace(t *testing.T, extension, authored bool) *resources.Workspace {
t.Helper()
root := t.TempDir()
declaration := "name: product\nlayout: modules\nworkspaces:\n - name: base\n path: ../base\nenvironments:\n - name: local\n"
if extension {
declaration += "configuration-extensions:\n hop: {}\n"
}
files := map[string]string{
"base/workspace.codefly.yaml": "name: base\nlayout: modules\nenvironments:\n - name: local\n",
"base/configurations/local/hop.env": "MODULES=annotations,robin\nENDPOINT=hop:1\n",
"product/.gitignore": "**/configurations/local/*.secret.env\n",
"product/workspace.codefly.yaml": declaration,
}
if authored {
files["product/configurations/local/hop.env"] = "MODULES=robin\n"
}
for name, content := range files {
path := filepath.Join(root, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
}
cmd := exec.Command("git", "init", "-q")
cmd.Dir = filepath.Join(root, "product")
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git init: %s", out)
workspace, err := resources.LoadWorkspaceFromDir(context.Background(), filepath.Join(root, "product"))
require.NoError(t, err)
return workspace
}

// A generated key for a group the workspace inherits from a composed
// workspace would make an own group that replaces the inherited one whole —
// the hop would lose MODULES and every route with it — so the run refuses,
// naming the declaration that makes the key extend the inherited group, and
// writes nothing. Declared, the generated key joins the inherited group. A
// group the workspace authors itself is its own to replace.
func TestAGeneratedSecretNeverReplacesAnInheritedGroup(t *testing.T) {
ctx := context.Background()
env := &environments.Environment{Name: "local", ServiceSecrets: &environments.EnvironmentServiceSecrets{
Generate: []environments.EnvironmentSecretGenerator{{Scope: environments.SecretGeneratorScopeWorkspace, Configuration: "hop", Keys: []string{"PROOF"}}},
}}
read := func(t *testing.T, workspace *resources.Workspace) map[string]bool {
t.Helper()
provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, &resources.Environment{Name: "local"})
require.NoError(t, err)
keys := map[string]bool{}
for _, info := range provided.Infos {
if info.GetName() == "hop" {
for _, value := range info.GetConfigurationValues() {
keys[value.GetKey()] = true
}
}
}
return keys
}

refused := importingWorkspace(t, false, false)
_, err := provisionGeneratedSecrets(ctx, refused, env)
require.ErrorContains(t, err, `workspace product inherits configuration "hop" from workspace base and generates PROOF into it`)
require.ErrorContains(t, err, "configuration-extensions: {hop: {}}")
require.NoFileExists(t, filepath.Join(refused.Dir(), "configurations", "local", "hop.secret.env"))
require.Equal(t, map[string]bool{"MODULES": true, "ENDPOINT": true}, read(t, refused), "the inherited group is intact")

extended := importingWorkspace(t, true, false)
written, err := provisionGeneratedSecrets(ctx, extended, env)
require.NoError(t, err)
require.Equal(t, []string{"hop/PROOF"}, written)
require.Equal(t, map[string]bool{"MODULES": true, "ENDPOINT": true, "PROOF": true}, read(t, extended), "the generated key extends the inherited group")

authored := importingWorkspace(t, false, true)
written, err = provisionGeneratedSecrets(ctx, authored, env)
require.NoError(t, err)
require.Equal(t, []string{"hop/PROOF"}, written)
}
7 changes: 6 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,12 @@ configuration does not hold yet to a value minted by the same generator, into
the file `codefly config generate` writes (`configurations/<profile>/<group>.secret.env`,
0600, refused when git would track it), and says which keys it generated,
never their values. A held value is never rotated. A `service` generator names
the services it covers; a local run guesses none. An environment whose
the services it covers; a local run guesses none. A `workspace` generator for a
group the workspace inherits from a composed workspace, and does not author, is
refused unless the group is declared under `configuration-extensions`: the
generated file is an own group, which would replace the inherited one whole
(the hop's `MODULES` lost, and every route with it); declared, the generated
keys extend it. An environment whose
`service-secrets` names a store keeps its secrets there, and a run writes
nothing for it; a render refuses a secret in an environment that names no
store, since it carries secrets only as references into one. So a playground
Expand Down
8 changes: 5 additions & 3 deletions docs/module-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -1127,9 +1127,11 @@ run, the CLI is:
for its routed endpoints, signs it with the developer key and carries it,
with a native credential for account `delivery` in the module's namespace,
to `ApplyManifest` of the `codefly.dev/composition` interface the
`host.delivery` endpoint declares in its module's interface endpoints. A
host whose delivery endpoint declares no such interface — the saas host on
main — receives it through the legacy HTTP route instead, POSTed to
`host.delivery` endpoint declares in its module's interface endpoints —
a `grpc` endpoint, or a `connect` one, which answers the gRPC protocol on
the same listener (the saas host's Connect face). A
host whose delivery endpoint declares no such interface (a saas host
delivered at its `rest` endpoint) receives it through the legacy HTTP route instead, POSTed to
`/platform/_delivery/manifest`, with the envelope written through its
gateway (below); the run logs that reason on every delivery that takes it.
A host whose module does not load receives nothing, with the reason; the
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026143932-67b85b14a942
github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026143932-67b85b14a942
github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855
github.com/codefly-dev/core v0.19.7-0.20261026150808-7c0407679119
github.com/codefly-dev/golor v0.1.3
github.com/codefly-dev/llm v0.1.7
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000
github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q=
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 h1:zokkjvY+C+FhXq9Tuo93wJBVwLxcX4RfHy8U8KeAAW8=
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20/go.mod h1:021MSgdhW9pSlRLcHEryPDXfI/rGY49rJAhe4RbU7BQ=
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855 h1:XmPltOHirB97RopD8WIJe9bwt1wkHAzngKoj8k2XI8U=
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855/go.mod h1:rfn41uiORQ36RrOeFEjXHPLC9NUi0j14AoYJtcovsn4=
github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE=
github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As=
github.com/codefly-dev/core v0.19.7-0.20261026150808-7c0407679119 h1:+Gkwdvx1g0xla5oyHe0OXBtT0NA2E2BqjfGX7r6uhy8=
Expand Down
19 changes: 14 additions & 5 deletions pkg/orchestration/grpc_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ func grpcTransport(secure bool) credentials.TransportCredentials {

// GRPCEndpointTarget resolves a running service's gRPC endpoint as this run
// recorded it now — the host-native instance first, as GetAddressForEndpoint
// answers — with the transport its declaration asks for. An endpoint that
// serves another API is refused by name.
// answers — with the transport its declaration asks for. A Connect endpoint is
// one too: a Connect server answers the gRPC protocol on the same listener
// (h2c in plaintext), which is the rule the hop's routing already holds — it
// sends a service's gRPC calls to its Connect endpoint (authority#31). An
// endpoint that serves another API is refused by name.
func (flow *Flow) GRPCEndpointTarget(ctx context.Context, module, service, endpoint string) (GRPCTarget, error) {
if flow == nil || flow.SharedState == nil {
return GRPCTarget{}, fmt.Errorf("cannot resolve %s/%s/%s: the run records no network state", module, service, endpoint)
Expand All @@ -56,8 +59,14 @@ func (flow *Flow) GRPCEndpointTarget(ctx context.Context, module, service, endpo
if declared.GetName() != endpoint {
continue
}
if declared.GetApi() != standards.GRPC {
return GRPCTarget{}, fmt.Errorf("%s/%s serves %q, not gRPC", unique, endpoint, declared.GetApi())
var secure bool
switch declared.GetApi() {
case standards.GRPC:
secure = resources.IsGRPC(ctx, declared).GetSecured()
case standards.CONNECT:
secure = declared.GetApiDetails().GetHttp().GetSecured()
default:
return GRPCTarget{}, fmt.Errorf("%s/%s serves %q, which speaks neither gRPC nor Connect", unique, endpoint, declared.GetApi())
}
instance := reachableNetworkInstance(mapping.GetInstances())
if instance == nil {
Expand All @@ -67,7 +76,7 @@ func (flow *Flow) GRPCEndpointTarget(ctx context.Context, module, service, endpo
if address == "" {
return GRPCTarget{}, fmt.Errorf("%s/%s has no address recorded", unique, endpoint)
}
return GRPCTarget{Address: address, Secure: resources.IsGRPC(ctx, declared).GetSecured()}, nil
return GRPCTarget{Address: address, Secure: secure}, nil
}
return GRPCTarget{}, fmt.Errorf("%s declares no endpoint %s in this run", unique, endpoint)
}
51 changes: 50 additions & 1 deletion pkg/orchestration/grpc_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ package orchestration

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"

"connectrpc.com/connect"
compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1"

basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
"github.com/codefly-dev/core/resources"
"github.com/codefly-dev/core/standards"
Expand All @@ -24,6 +30,8 @@ func TestGRPCEndpointTargetIsTheNativeInstanceWithItsDeclaredTransport(t *testin
}},
{Endpoint: grpcEndpoint("secured", true), Instances: []*basev0.NetworkInstance{networkInstance(resources.NetworkAccessNative, "https://localhost:7002")}},
{Endpoint: &basev0.Endpoint{Name: "rest", Api: standards.REST}, Instances: []*basev0.NetworkInstance{networkInstance(resources.NetworkAccessNative, "http://localhost:7003")}},
{Endpoint: &basev0.Endpoint{Name: "connect", Api: standards.CONNECT, ApiDetails: resources.ToHTTPAPI(&basev0.HttpAPI{})}, Instances: []*basev0.NetworkInstance{networkInstance(resources.NetworkAccessNative, "http://localhost:7004")}},
{Endpoint: &basev0.Endpoint{Name: "connect-tls", Api: standards.CONNECT, ApiDetails: resources.ToHTTPAPI(&basev0.HttpAPI{Secured: true})}, Instances: []*basev0.NetworkInstance{networkInstance(resources.NetworkAccessNative, "https://localhost:7005")}},
})
ctx := context.Background()
target, err := flow.GRPCEndpointTarget(ctx, "platform", "host", "delivery")
Expand All @@ -32,8 +40,16 @@ func TestGRPCEndpointTargetIsTheNativeInstanceWithItsDeclaredTransport(t *testin
target, err = flow.GRPCEndpointTarget(ctx, "platform", "host", "secured")
require.NoError(t, err)
require.Equal(t, GRPCTarget{Address: "localhost:7002", Secure: true}, target)
// A Connect listener answers gRPC on the same port: a host whose
// composition interface is its Connect face is reached there.
connectTarget, err := flow.GRPCEndpointTarget(ctx, "platform", "host", "connect")
require.NoError(t, err)
require.Equal(t, GRPCTarget{Address: "localhost:7004"}, connectTarget)
connectTarget, err = flow.GRPCEndpointTarget(ctx, "platform", "host", "connect-tls")
require.NoError(t, err)
require.Equal(t, GRPCTarget{Address: "localhost:7005", Secure: true}, connectTarget)
_, err = flow.GRPCEndpointTarget(ctx, "platform", "host", "rest")
require.ErrorContains(t, err, "not gRPC")
require.ErrorContains(t, err, "speaks neither gRPC nor Connect")
_, err = flow.GRPCEndpointTarget(ctx, "platform", "host", "missing")
require.ErrorContains(t, err, "declares no endpoint missing")
_, err = flow.GRPCEndpointTarget(ctx, "platform", "other", "delivery")
Expand All @@ -42,3 +58,36 @@ func TestGRPCEndpointTargetIsTheNativeInstanceWithItsDeclaredTransport(t *testin
require.NoError(t, err)
require.NoError(t, conn.Close())
}

// The claim the Connect case rests on, on the wire: the gRPC client the run
// delivers manifests with reaches a Connect handler of the composition
// interface on a plaintext listener, as a host serving its Connect face over
// h2c answers it.
func TestAConnectListenerAnswersTheCompositionClientOverGRPC(t *testing.T) {
handler := connect.NewUnaryHandler(compositionv1.CompositionService_ApplyManifest_FullMethodName,
func(_ context.Context, request *connect.Request[compositionv1.ApplyManifestRequest]) (*connect.Response[compositionv1.ApplyManifestResponse], error) {
if request.Msg.GetCredential() != "delivery-token" {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unexpected credential"))
}
return connect.NewResponse(&compositionv1.ApplyManifestResponse{Generation: 7}), nil
})
server := httptest.NewUnstartedServer(handler)
server.Config.Protocols = new(http.Protocols)
server.Config.Protocols.SetHTTP1(true)
server.Config.Protocols.SetUnencryptedHTTP2(true)
server.Start()
t.Cleanup(server.Close)

flow := flowWithNetworkMappings("platform/host", []*basev0.NetworkMapping{
{Endpoint: &basev0.Endpoint{Name: "connect", Api: standards.CONNECT, ApiDetails: resources.ToHTTPAPI(&basev0.HttpAPI{})},
Instances: []*basev0.NetworkInstance{networkInstance(resources.NetworkAccessNative, server.URL)}},
})
target, err := flow.GRPCEndpointTarget(t.Context(), "platform", "host", "connect")
require.NoError(t, err)
conn, err := target.Dial()
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
out, err := compositionv1.NewCompositionServiceClient(conn).ApplyManifest(t.Context(), &compositionv1.ApplyManifestRequest{Credential: "delivery-token"})
require.NoError(t, err)
require.Equal(t, uint64(7), out.GetGeneration())
}
Loading
Loading