diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 4dc9985b..93d8add1 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -143,7 +143,7 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re // admission as run, with explicit configuration and flow-owned teardown. // Call after InitManagers determines whether this test starts any service. func AttachNativeLauncher(ctx context.Context, flow *orchestration.Flow, workspace *resources.Workspace, env *environments.Environment, selectedRuntime, fixture string) error { - if len(flow.LocalRuntimeServices()) == 0 { + if len(flow.LocalAuthorityServices()) == 0 { return nil } _, err := newNativeLauncher(ctx, flow, workspace, env, selectedRuntime, fixture) @@ -193,6 +193,7 @@ func newNativeLauncher(ctx context.Context, flow *orchestration.Flow, workspace world.RenderOwnedGroups = cluster.renderOwnedGroups world.RuntimeOverrides = cluster.runtimeOverrides world.BeginRuntimeStart = cluster.beginRuntimeStart + world.PrepareExternalWorkload = cluster.prepareExternalWorkload go cluster.refreshTokens(ctx) w.Info("native run: the run vouches for the composition's workloads", wool.Field("anchor", launcherIdentity.AnchorFingerprint()[:16])) world.BeforeRuntimeStop = cluster.close diff --git a/cmd/run/launcher_admission.go b/cmd/run/launcher_admission.go index 6a2f19de..f692237f 100644 --- a/cmd/run/launcher_admission.go +++ b/cmd/run/launcher_admission.go @@ -14,7 +14,7 @@ import ( func (cluster *launcher) declaredIdentities(module string) []gitops.NativeIdentity { var declared []gitops.NativeIdentity - for _, unique := range cluster.flow.LocalRuntimeServices() { + for _, unique := range cluster.flow.LocalAuthorityServices() { if identity, ok := cluster.identity(unique); ok && identity.Module == module { declared = append(declared, identity) } @@ -87,7 +87,7 @@ func (cluster *launcher) admitSelected(ctx context.Context) error { // 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{} - for _, unique := range cluster.flow.LocalRuntimeServices() { + for _, unique := range cluster.flow.LocalAuthorityServices() { if ref, err := resources.ParseServiceWithOptionalModule(unique); err == nil { selected[ref.Module] = true } @@ -164,3 +164,26 @@ func (cluster *launcher) checkBindingTargets(ctx context.Context, module string, } return nil } + +// The dependency SDK drives this process itself. Admit its declared workload, +// but leave it unready: the launcher neither starts it nor observes a listener. +func (cluster *launcher) prepareExternalWorkload(ctx context.Context, service *resources.Service) (func(), error) { + unique, declared, ok := cluster.identityOf(service) + if !ok { + return nil, fmt.Errorf("excluded root has no declared workload identity") + } + if _, err := cluster.project(unique, declared); err != nil { + return nil, err + } + abandon := func() { cluster.vouch.Forget(unique) } + err := hostDelivery{ + schedule: cluster.schedule, stop: cluster.stop, + ready: cluster.hostReady, deliver: cluster.admitSelected, + log: wool.Get(ctx).In("launcher.external"), + }.run(ctx) + if err != nil { + abandon() + return nil, err + } + return abandon, nil +} diff --git a/cmd/run/launcher_admission_test.go b/cmd/run/launcher_admission_test.go index 90170dba..faec4364 100644 --- a/cmd/run/launcher_admission_test.go +++ b/cmd/run/launcher_admission_test.go @@ -36,7 +36,7 @@ type admissionFlow struct { selected []string } -func (f *admissionFlow) LocalRuntimeServices() []string { return f.selected } +func (f *admissionFlow) LocalAuthorityServices() []string { return f.selected } func (f *admissionFlow) ReadyEndpointCarriers(_ context.Context, module string, _ *basev0.NetworkAccess, ready []string) (map[string]string, error) { carriers := map[string]string{} for _, unique := range ready { @@ -457,3 +457,36 @@ func TestAdmissionAndDeliveryRefuseAMovedRoutingSnapshot(t *testing.T) { require.ErrorContains(t, cluster.deliverReadiness(t.Context(), "alpha", true), "stop and restart") require.Empty(t, peer.snapshots(), "nothing is sent behind moved routes") } + +func TestExternalWorkloadIsAdmittedWithoutPublishingReadiness(t *testing.T) { + cluster, peer := admissionLauncher(t) + service, err := cluster.workspace.LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + abandon, err := cluster.prepareExternalWorkload(t.Context(), service) + require.NoError(t, err) + t.Cleanup(abandon) + require.Empty(t, cluster.startedIdentities("alpha")) + require.NotEmpty(t, cluster.runtimeOverrides(service)[gitops.WorkloadTokenFileVariable]) + found := false + for _, manifest := range peer.snapshots() { + if manifest.Alias != "alpha" { + continue + } + found = true + require.NotEmpty(t, manifest.Workloads) + require.Empty(t, manifest.Endpoints, "externally driven root has no observed listener") + } + require.True(t, found) +} + +func TestFailedExternalPreparationReleasesRefresh(t *testing.T) { + cluster, _ := admissionLauncher(t) + service, err := cluster.workspace.LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = cluster.prepareExternalWorkload(ctx, service) + require.Error(t, err) + _, projected := cluster.vouch.Projected("alpha/api") + require.False(t, projected, "failed preparation removes refresh membership") +} diff --git a/cmd/run/native_delivery_schedule.go b/cmd/run/native_delivery_schedule.go index 3651b7b8..dd804306 100644 --- a/cmd/run/native_delivery_schedule.go +++ b/cmd/run/native_delivery_schedule.go @@ -35,7 +35,7 @@ type launcherFlow interface { ServiceRuntimeContext(unique string) string EndpointCarriers(ctx context.Context, module string, access *basev0.NetworkAccess) (map[string]string, error) ReadyEndpointCarriers(ctx context.Context, module string, access *basev0.NetworkAccess, services []string) (map[string]string, error) - LocalRuntimeServices() []string + LocalAuthorityServices() []string CheckRoutingSnapshot(ctx context.Context, workspace *resources.Workspace) error ServiceReadiness(ctx context.Context, service string) *orchestration.ReadinessFailure } diff --git a/cmd/run/native_delivery_schedule_test.go b/cmd/run/native_delivery_schedule_test.go index fa83efa4..47bca165 100644 --- a/cmd/run/native_delivery_schedule_test.go +++ b/cmd/run/native_delivery_schedule_test.go @@ -327,7 +327,7 @@ func (f *fakeLauncherFlow) EndpointCarriers(context.Context, string, *basev0.Net func (f *fakeLauncherFlow) ReadyEndpointCarriers(context.Context, string, *basev0.NetworkAccess, []string) (map[string]string, error) { return nil, errors.New("no endpoints in this test") } -func (f *fakeLauncherFlow) LocalRuntimeServices() []string { return []string{"model/api"} } +func (f *fakeLauncherFlow) LocalAuthorityServices() []string { return []string{"model/api"} } func (f *fakeLauncherFlow) ServiceReadiness(_ context.Context, service string) *orchestration.ReadinessFailure { if f.ready(service) { return nil diff --git a/docs/module-manifests.md b/docs/module-manifests.md index f086edd1..3e5d2f96 100644 --- a/docs/module-manifests.md +++ b/docs/module-manifests.md @@ -1554,3 +1554,10 @@ installation, consent, provider availability or a successful runtime call. Packaged solution publications without a source module and rollback of old rendered artifacts still need artifact-level permission qualification; source preflight alone does not validate those paths. + +A dependency-only native run may declare its excluded root's workload identity +when that root opts into `module-identity`. The dependency client drives that +process outside the launcher. Admission still uses the signed manifest and must +complete before the SDK environment is ready; it does not mark the root started +or publish any of its endpoints. Credential projection and refresh belong to the +run, including normal teardown. See [orchestration](orchestration.md#identity-for-dependency-clients). diff --git a/docs/orchestration.md b/docs/orchestration.md index c3e357da..67362fcb 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -137,6 +137,16 @@ The `World` holds shared state that all managers can access: ### Workspace configuration groups +SDK dependency sessions receive these groups through the CLI configuration RPC, +including excluded-root consumers. The RPC uses the same resolver as a running +service, preserving endpoint resolution and credential withholding. +The current dependency RPC resolves references for the service's selected runtime +context; the SDK process must use that same context. An excluded SDK consumer's +saved container preference does not negotiate a native test process's context. +The Core SDK also does not remove previously injected keys on refresh until the +session is released. One-shot native admission evidence does not qualify either +mixed-context selection or removal of credentials during refresh. + Every path that delivers workspace configuration groups to a service resolves them in one place, `pkg/orchestration/workspace_configurations.go`, by one rule: @@ -1322,3 +1332,20 @@ An explicit workspace document retains precedence, including a document written by `codefly generate fixture-composition`. Generation and the read-only provider share the same fragment discovery and merge. Direct Core configuration reads do not synthesize this CLI-owned group; consumers that need it use the shared reader. + +### Identity for dependency clients + +A native dependency run using `--exclude-root` keeps a root declaring +`module-identity: true` in the authority declaration set. After the dependency +barrier, the launcher prepares that root's own credential and waits for the +ordinary signed admission to apply. The root is not started by an agent, is not +marked Running, and contributes no ready endpoint. Its credential refresh belongs to the +flow lifetime and ends on Stop or Destroy. Already-issued bytes retain their +ordinary expiry; teardown makes no immediate-revocation claim. + +The dependency SDK receives the prepared workload projection through +`GetConfiguration.ProcessVariables`, alongside the active environment and +service-scoped overrides. The same carriers reach `--output-env`. Reading +configuration never creates or restores a credential, and no dependency's token +is substituted for the root's. A root requesting identity in a run with no +workload authority fails before its dependency environment is reported ready. diff --git a/pkg/orchestration/excluded_root_output_env.go b/pkg/orchestration/excluded_root_output_env.go index 5828d09f..7f671f3a 100644 --- a/pkg/orchestration/excluded_root_output_env.go +++ b/pkg/orchestration/excluded_root_output_env.go @@ -81,14 +81,7 @@ func (flow *Flow) exportExcludedOriginEnvironment(ctx context.Context) error { WorkspacePath: identity.WorkspacePath, RelativeToWorkspace: identity.RelativeToWorkspace, } - originOverrides := flow.overridesFor(flow.originService) - overrides := make(map[string]string, len(originOverrides)+1) - for key, value := range originOverrides { - overrides[key] = value - } - if flow.world.Env != nil && flow.world.Env.NamingScope != "" { - overrides[resources.NamingScopePrefix] = flow.world.Env.NamingScope - } + overrides := flow.DependencyProcessVariables(flow.originService) // The selected payload is checked against itself and against this export's // override map — duplicates inside it are still collisions — and the // complete record batch is converted, before the first append. No agent @@ -116,3 +109,54 @@ func (flow *Flow) exportExcludedOriginEnvironment(ctx context.Context) error { } return nil } + +// prepareExcludedOrigin runs at the dependency barrier without loading an agent. +func (flow *Flow) prepareExcludedOrigin(ctx context.Context) error { + flow.externalPrepared.Store(false) + var abandon func() + handed := false + defer func() { + if !handed && abandon != nil { + abandon() + } + }() + if flow.externalIdentityOrigin() { + if flow.world.PrepareExternalWorkload == nil { + return fmt.Errorf("excluded root declares module-identity but this run supplies no workload authority") + } + var err error + abandon, err = flow.world.PrepareExternalWorkload(ctx, flow.originService) + if err != nil { + return err + } + } + if err := flow.exportExcludedOriginEnvironment(ctx); err != nil { + return err + } + handed = true + flow.externalPrepared.Store(true) + return nil +} + +// DependencyProcessVariables carries the same per-service overrides as a native +// process to the dependency SDK. It exports no other service's values. +func (flow *Flow) DependencyProcessVariables(service *resources.Service) map[string]string { + out := map[string]string{} + for key, value := range flow.overridesFor(service) { + out[key] = value + } + if flow.world != nil { + if flow.world.RuntimeOverrides != nil { + for key, value := range flow.world.RuntimeOverrides(service) { + out[key] = value + } + } + if flow.world.Env != nil { + out[resources.EnvironmentPrefix] = flow.world.Env.Name + if flow.world.Env.NamingScope != "" { + out[resources.NamingScopePrefix] = flow.world.Env.NamingScope + } + } + } + return out +} diff --git a/pkg/orchestration/excluded_root_output_env_internal_test.go b/pkg/orchestration/excluded_root_output_env_internal_test.go index 0687efb5..0c91a8a4 100644 --- a/pkg/orchestration/excluded_root_output_env_internal_test.go +++ b/pkg/orchestration/excluded_root_output_env_internal_test.go @@ -115,3 +115,51 @@ func TestExcludedRootExportCarriesOnlyItsOwnOverrides(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(body), "not-the-roots") } + +func TestExcludedIdentityRootIsDeclaredWithoutStartingOrReadyEndpoints(t *testing.T) { + ctx := t.Context() + workspace := writeTempWorkspace(t, map[string]string{ + "workspace.codefly.yaml": excludedRootExportWorkspace, + "modules/wiki/module.codefly.yaml": excludedRootExportModule, + "modules/wiki/services/backend/service.codefly.yaml": excludedRootExportService, + }) + module, err := workspace.LoadModuleFromName(ctx, "wiki") + require.NoError(t, err) + service, err := module.LoadServiceFromName(ctx, "backend") + require.NoError(t, err) + service.ModuleIdentity = true + env, err := SelectEnvironment(workspace, LocalEnvironmentName) + require.NoError(t, err) + flow, err := NewFlow(ctx, workspace, module, service, env, RunMode) + require.NoError(t, err) + flow.WithExcludeRoot(true) + flow.publishRuntimeServices() + require.Empty(t, flow.LocalRuntimeServices()) + require.Equal(t, []string{"wiki/backend"}, flow.LocalAuthorityServices()) + require.ErrorContains(t, flow.prepareExcludedOrigin(ctx), "no workload authority") + calls := 0 + flow.world.PrepareExternalWorkload = func(_ context.Context, selected *resources.Service) (func(), error) { + require.Same(t, service, selected) + calls++ + return func() {}, nil + } + require.NoError(t, flow.prepareExcludedOrigin(ctx)) + require.Equal(t, 1, calls) + require.Empty(t, flow.LocalRuntimeServices(), "admission is not an agent Start") + service.ModuleIdentity = false + flow.publishRuntimeServices() + require.Empty(t, flow.LocalAuthorityServices()) + require.NoError(t, flow.prepareExcludedOrigin(ctx)) + require.Equal(t, 1, calls, "plain excluded roots acquire no authority") + service.ModuleIdentity = true + abandoned := false + flow.world.PrepareExternalWorkload = func(context.Context, *resources.Service) (func(), error) { + return func() { abandoned = true }, nil + } + flow.WithRuntimeContext(resources.RuntimeContextNative) + require.NoError(t, flow.ConfigurationManager.Load(ctx, env.Runtime())) + flow.WithOutputEnv(t.TempDir()) // a directory cannot receive the environment file + require.Error(t, flow.prepareExcludedOrigin(ctx)) + require.True(t, abandoned, "an environment export failure abandons the prepared projection") + require.False(t, flow.externalPrepared.Load()) +} diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index 0c1959a7..2c4aebd3 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -36,7 +36,9 @@ import ( ) type Flow struct { - runtimeParticipants atomic.Pointer[[]string] + runtimeParticipants atomic.Pointer[[]string] + authorityParticipants atomic.Pointer[[]string] + externalPrepared atomic.Bool compositionCheck CompositionCheck // configurationReferences orders a consumer after the producers its @@ -247,6 +249,12 @@ type World struct { // agents is every agent pid this run spawned (run_containers.go). agents spawnedAgents + // PrepareExternalWorkload admits the excluded root's declared identity for + // the process driving its dependencies. It does not claim an agent started + // that process or publish any endpoint as ready. The returned abandon callback + // releases refresh membership if the environment handoff fails. + PrepareExternalWorkload func(context.Context, *resources.Service) (abandon func(), err error) + // beforeSourceRestart validates the accepted route inventory before stopping a service. beforeSourceRestart func(context.Context) error @@ -946,7 +954,7 @@ func (flow *Flow) Load(ctx context.Context) error { // its own and workspace configuration must still be admitted to the same // configuration manager as a real Runner would use. This loads configuration // authority only; it does not load the root agent or project process. - if flow.exportsExcludedOriginEnvironment() { + if flow.exportsExcludedOriginEnvironment() || flow.externalIdentityOrigin() { id, err := flow.originService.Identity() if err != nil { return w.Wrap(err) @@ -1437,7 +1445,9 @@ func (flow *Flow) WithAnswerProvider(provider AnswerProvider) *Flow { // stateEmitter interface so the runtime policy can report without knowing the // flow concretely. func (flow *Flow) emitState(service string, state tui.ServiceState, port int) { - if flow == nil { + // The excluded root's playbook node is a dependency barrier, never a + // process lifecycle. Its preparation has its own readiness predicate. + if flow == nil || (flow.excludeRoot && flow.originService != nil && service == resources.WithUnique(flow.originService).Unique()) { return } // Record that THIS run has begun driving this service's runtime, so a later @@ -1695,10 +1705,18 @@ func (flow *Flow) newTeardownContext() (context.Context, func()) { } } -func (flow *Flow) Stop() error { - if flow != nil && flow.world != nil && flow.world.BeforeRuntimeStop != nil { +func (flow *Flow) endRuntimeAuthority() { + if flow == nil { + return + } + flow.externalPrepared.Store(false) + if flow.world != nil && flow.world.BeforeRuntimeStop != nil { flow.world.BeforeRuntimeStop() } +} + +func (flow *Flow) Stop() error { + flow.endRuntimeAuthority() if flow == nil || flow.hub == nil || flow.validationSkipped { return nil } @@ -1719,6 +1737,7 @@ func (flow *Flow) Stop() error { } func (flow *Flow) Shutdown() error { + flow.endRuntimeAuthority() if flow == nil || flow.hub == nil || flow.validationSkipped { return nil } @@ -2083,10 +2102,10 @@ func (flow *Flow) InitManagers(ctx context.Context) error { // the same without loading its agent. If its SDK environment was requested, // the environment-only manager publishes it after dependency startup. noOp := NoOpManager{service: flow.originService} - if flow.exportsExcludedOriginEnvironment() { + if flow.exportsExcludedOriginEnvironment() || flow.externalIdentityOrigin() { flow.hub.managers = append(flow.hub.managers, &environmentOnlyManager{ NoOpManager: noOp, - export: flow.exportExcludedOriginEnvironment, + export: flow.prepareExcludedOrigin, }) } else { flow.hub.managers = append(flow.hub.managers, &noOp) diff --git a/pkg/orchestration/flow_stop_test.go b/pkg/orchestration/flow_stop_test.go index 1f2619d3..95506715 100644 --- a/pkg/orchestration/flow_stop_test.go +++ b/pkg/orchestration/flow_stop_test.go @@ -220,3 +220,12 @@ func TestFlowStartPropagatesPlaybookPanicToCaller(t *testing.T) { require.Equal(t, "playbook panic", recovered) } + +func TestShutdownEndsAuthorityEvenBeforeManagersExist(t *testing.T) { + called := false + flow := &Flow{world: &World{BeforeRuntimeStop: func() { called = true }}} + flow.externalPrepared.Store(true) + require.NoError(t, flow.Shutdown()) + require.True(t, called, "DestroyFlow uses Shutdown directly") + require.False(t, flow.externalPrepared.Load()) +} diff --git a/pkg/orchestration/readiness.go b/pkg/orchestration/readiness.go index b9c43a2e..0ded2c20 100644 --- a/pkg/orchestration/readiness.go +++ b/pkg/orchestration/readiness.go @@ -32,6 +32,9 @@ const ( // successfully. An endpointless dependency (a one-shot job) is satisfied by // this predicate alone: having no endpoint is not evidence of readiness. PredicateLifecycle ReadinessPredicate = "lifecycle" + // PredicateExternalIdentity waits for the excluded consumer's admission; + // it makes no claim about a listener or process started by the launcher. + PredicateExternalIdentity ReadinessPredicate = "external-identity" // PredicateRunner revokes readiness once a started runner reports failure. PredicateRunner ReadinessPredicate = "runner" // PredicateMapping requires a consumed endpoint to have a recorded network @@ -141,6 +144,9 @@ func (flow *Flow) Readiness(ctx context.Context) *ReadinessFailure { if failure, ok := flow.Failure(); ok { return &ReadinessFailure{Service: failure.Service, Predicate: PredicateRunner, Reason: failure.Message} } + if flow.externalIdentityOrigin() && !flow.externalPrepared.Load() { + return &ReadinessFailure{Service: resources.WithUnique(flow.originService).Unique(), Predicate: PredicateExternalIdentity, Reason: "external workload admission has not completed"} + } requirements, err := flow.readinessRequirements(ctx) if err != nil { return &ReadinessFailure{Predicate: PredicateRequirements, Reason: err.Error()} diff --git a/pkg/orchestration/readiness_test.go b/pkg/orchestration/readiness_test.go index 468100a2..04207620 100644 --- a/pkg/orchestration/readiness_test.go +++ b/pkg/orchestration/readiness_test.go @@ -555,3 +555,50 @@ func TestReadinessProbesBoltAndHTTPByDeclaredAPI(t *testing.T) { require.NotNil(t, failure) require.Equal(t, "bolt", failure.Endpoint) } + +func TestExternalIdentityReadinessWaitsForAdmissionWithoutRunning(t *testing.T) { + stack := newReadinessStack(t) + flow := stack.flow + flow.world.Mode = RunMode + flow.originService.ModuleIdentity = true + entered, release := make(chan struct{}), make(chan struct{}) + flow.world.PrepareExternalWorkload = func(ctx context.Context, _ *resources.Service) (func(), error) { + close(entered) + select { + case <-release: + return func() {}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + done := make(chan error, 1) + go func() { done <- flow.prepareExcludedOrigin(t.Context()) }() + <-entered + failure := flow.Readiness(t.Context()) + require.NotNil(t, failure) + require.Equal(t, PredicateExternalIdentity, failure.Predicate) + close(release) + require.NoError(t, <-done) + flow.emitState("app/api", tui.StateRunning, 0) + require.False(t, flow.startedRun("app/api")) + require.True(t, flow.Ready(t.Context())) + flow.world.PrepareExternalWorkload = func(context.Context, *resources.Service) (func(), error) { return nil, context.Canceled } + require.ErrorIs(t, flow.prepareExcludedOrigin(t.Context()), context.Canceled) + require.False(t, flow.Ready(t.Context()), "failed preparation cannot retain a prior ready result") +} + +func TestExcludedIdentityPolicyNeverEmitsRunning(t *testing.T) { + stack := newReadinessStack(t) + flow := stack.flow + flow.world.Mode = RunMode + flow.originService.ModuleIdentity = true + flow.world.PrepareExternalWorkload = func(context.Context, *resources.Service) (func(), error) { return func() {}, nil } + flow.hub = &Hub{managers: []IManager{&environmentOnlyManager{ + NoOpManager: NoOpManager{service: flow.originService}, export: flow.prepareExcludedOrigin, + }}} + policy, err := NewRuntimeStartPolicy(t.Context(), flow.world.Dependencies, flow) + require.NoError(t, err) + _, err = policy.Execute(t.Context(), Action{Type: RuntimeStart, Service: "app/api"}) + require.NoError(t, err) + require.False(t, flow.startedRun("app/api"), "excluded root must not be reported as an agent-started process") +} diff --git a/pkg/orchestration/runtime_participants.go b/pkg/orchestration/runtime_participants.go index 896557b1..a33ed1e0 100644 --- a/pkg/orchestration/runtime_participants.go +++ b/pkg/orchestration/runtime_participants.go @@ -25,6 +25,14 @@ func (flow *Flow) LocalRuntimeServices() []string { func (flow *Flow) publishRuntimeServices() { selected := flow.selectedRuntimeServices() flow.runtimeParticipants.Store(&selected) + declared := slices.Clone(selected) + if flow.externalIdentityOrigin() { + unique := resources.WithUnique(flow.originService).Unique() + if !slices.Contains(declared, unique) { + declared = append(declared, unique) + } + } + flow.authorityParticipants.Store(&declared) } func (flow *Flow) selectedRuntimeServices() []string { @@ -62,3 +70,28 @@ func (flow *Flow) selectedRuntimeServices() []string { } return selected } + +// LocalAuthorityServices also declares an excluded root driven by a dependency +// client. LocalRuntimeServices remains the actual agent Start set. +func (flow *Flow) LocalAuthorityServices() []string { + if flow != nil { + if selected := flow.authorityParticipants.Load(); selected != nil { + return slices.Clone(*selected) + } + } + return nil +} + +func (flow *Flow) externalIdentityOrigin() bool { + if flow == nil || flow.world == nil || flow.world.Mode != RunMode || flow.validationSkipped || + !flow.excludeRoot || flow.originService == nil || !flow.originService.ModuleIdentity { + return false + } + unique := resources.WithUnique(flow.originService).Unique() + for _, remote := range flow.remoteServices { + if remote.Unique() == unique { + return false + } + } + return true +} diff --git a/pkg/web/go-grpc/configuration_scope_test.go b/pkg/web/go-grpc/configuration_scope_test.go index 742eb2a8..cbd369f1 100644 --- a/pkg/web/go-grpc/configuration_scope_test.go +++ b/pkg/web/go-grpc/configuration_scope_test.go @@ -2,6 +2,7 @@ package go_grpc import ( "context" + "os" "path/filepath" "testing" @@ -22,6 +23,7 @@ func TestConfigurationRPCIncludesTheActiveEnvironmentScope(t *testing.T) { require.NoError(t, err) flow, err := orchestration.NewFlow(ctx, workspace, nil, nil, &environments.Environment{Name: "staging"}, orchestration.RunMode) require.NoError(t, err) + flow.WithRuntimeContext(resources.RuntimeContextNative) flows := engine.NewFlowManager() require.NoError(t, flows.Register("configuration", flow)) server, err := NewServer(&Configuration{EndpointGrpc: "127.0.0.1:0"}, workspace, flows) @@ -40,3 +42,71 @@ func TestConfigurationRPCIncludesTheActiveEnvironmentScope(t *testing.T) { require.Equal(t, resources.EnvironmentPrefix, response.ProcessVariables[0].Key) require.Equal(t, "staging", response.ProcessVariables[0].Value) } + +// The dependency SDK consumes ProcessVariables verbatim. Workspace groups must +// reach that carrier, including declared credentials, while credentials from an +// undeclared composition-root group remain withheld. +func TestConfigurationRPCDeliversResolvedWorkspaceGroups(t *testing.T) { + ctx := t.Context() + dir := t.TempDir() + for name, contents := range map[string]string{ + "workspace.codefly.yaml": "name: configuration-rpc\nlayout: modules\nmodules:\n - name: consumer\n", + "modules/consumer/module.codefly.yaml": "kind: module\nname: consumer\nservices:\n - name: caller\n", + "modules/consumer/services/caller/service.codefly.yaml": "kind: service\nname: caller\nversion: 0.0.0\nmodule: consumer\nworkspace-configuration-dependencies: [declared]\n", + "configurations/local/declared.env": "setting=visible\n", + "configurations/local/declared.secret.env": "credential=declared-test-value\n", + "configurations/local/root.env": "setting=inherited\n", + "configurations/local/root.secret.env": "credential=withheld-test-value\n", + } { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + } + workspace, err := resources.LoadWorkspaceFromDir(ctx, dir) + require.NoError(t, err) + env := &environments.Environment{Name: "local"} + flow, err := orchestration.NewFlow(ctx, workspace, nil, nil, env, orchestration.RunMode) + require.NoError(t, err) + require.NoError(t, flow.ConfigurationManager.Load(ctx, env.Runtime())) + flow.WithRuntimeContext(resources.RuntimeContextNative) + flows := engine.NewFlowManager() + require.NoError(t, flows.Register("configuration", flow)) + server, err := NewServer(&Configuration{}, workspace, flows) + require.NoError(t, err) + response, err := server.GetConfiguration(ctx, &cli.GetConfigurationRequest{Module: "consumer", Service: "caller"}) + require.NoError(t, err) + values := map[string]string{} + for _, variable := range response.ProcessVariables { + values[variable.Key] = variable.Value + } + require.Equal(t, "local", values[resources.EnvironmentPrefix]) + require.Equal(t, "visible", values["CODEFLY__WORKSPACE_CONFIGURATION__DECLARED__SETTING"]) + require.Equal(t, "declared-test-value", values["CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DECLARED__CREDENTIAL"]) + require.Equal(t, "inherited", values["CODEFLY__WORKSPACE_CONFIGURATION__ROOT__SETTING"]) + require.NotContains(t, values, "CODEFLY__WORKSPACE_SECRET_CONFIGURATION__ROOT__CREDENTIAL") +} + +func TestConfigurationRPCDeliversOnlyTheSelectedWorkloadCarriers(t *testing.T) { + ctx := t.Context() + workspace, err := resources.LoadWorkspaceFromDir(ctx, filepath.Join("..", "..", "orchestration", "testdata", "module-layout")) + require.NoError(t, err) + flow, err := orchestration.NewFlow(ctx, workspace, nil, nil, &environments.Environment{Name: "local"}, orchestration.RunMode) + require.NoError(t, err) + flow.WithRuntimeContext(resources.RuntimeContextNative) + projected := filepath.Join(t.TempDir(), "organization") + flow.World().RuntimeOverrides = func(service *resources.Service) map[string]string { + require.Equal(t, "organization", service.Name) + return map[string]string{"CODEFLY__WORKLOAD_TOKEN_FILE": projected} + } + flows := engine.NewFlowManager() + require.NoError(t, flows.Register("configuration", flow)) + server, err := NewServer(&Configuration{}, workspace, flows) + require.NoError(t, err) + response, err := server.GetConfiguration(ctx, &cli.GetConfigurationRequest{Module: "management", Service: "organization"}) + require.NoError(t, err) + values := map[string]string{} + for _, variable := range response.ProcessVariables { + values[variable.Key] = variable.Value + } + require.Equal(t, map[string]string{resources.EnvironmentPrefix: "local", "CODEFLY__WORKLOAD_TOKEN_FILE": projected}, values) +} diff --git a/pkg/web/go-grpc/server.go b/pkg/web/go-grpc/server.go index 53a30214..2a510098 100644 --- a/pkg/web/go-grpc/server.go +++ b/pkg/web/go-grpc/server.go @@ -185,12 +185,35 @@ func (s *Server) GetConfiguration(ctx context.Context, req *cli.GetConfiguration if err != nil { return nil, status.Error(codes.Internal, err.Error()) } - return &cli.GetConfigurationResponse{ - Configuration: conf, - ProcessVariables: []*basev0.ConfigurationValue{{ - Key: resources.EnvironmentPrefix, Value: environment.Name, - }}, - }, nil + // Workspace groups are already narrowed by the composition resolver. Like + // a running service, the SDK receives them directly, without the backend + // filter used for configurations published by dependency agents. + workspace, err := flow.WorkspaceConfigurationsFor(ctx, svc) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + variables := []*basev0.ConfigurationValue{} + runtime := flow.DependencyProcessVariables(svc) + keys := make([]string, 0, len(runtime)) + for key := range runtime { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + variables = append(variables, &basev0.ConfigurationValue{Key: key, Value: runtime[key]}) + } + for _, group := range workspace { + for _, secret := range []bool{false, true} { + resolved, err := resources.ConfigurationAsEnvironmentVariables(group, environment.Name, secret) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + for _, variable := range resolved { + variables = append(variables, &basev0.ConfigurationValue{Key: variable.Key, Value: variable.ValueAsString(), Secret: secret}) + } + } + } + return &cli.GetConfigurationResponse{Configuration: conf, ProcessVariables: variables}, nil } func (s *Server) GetDependenciesConfigurations(ctx context.Context, req *cli.GetConfigurationRequest) (*cli.GetConfigurationsResponse, error) {