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
3 changes: 2 additions & 1 deletion cmd/run/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions cmd/run/launcher_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
35 changes: 34 additions & 1 deletion cmd/run/launcher_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion cmd/run/native_delivery_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/run/native_delivery_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/module-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
27 changes: 27 additions & 0 deletions docs/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
60 changes: 52 additions & 8 deletions pkg/orchestration/excluded_root_output_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
48 changes: 48 additions & 0 deletions pkg/orchestration/excluded_root_output_env_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
33 changes: 26 additions & 7 deletions pkg/orchestration/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions pkg/orchestration/flow_stop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading
Loading