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
19 changes: 19 additions & 0 deletions .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,25 @@ def test_kernel_runtime_has_no_production_consumer_yet(self) -> None:
"migration T5 has not connected the generic kernel runtime to production code",
)

def test_invocation_runtime_is_domain_neutral(self) -> None:
invocation = REPO / "boatstack" / "invocation"
files = sorted(invocation.glob("*.go"))
self.assertTrue(files)
self.assertEqual([], domain_vocabulary_hits(files))
boatstack_packages = "github.com/operatorstack/boatstack/boatstack/"
allowed = {
boatstack_packages + "controlprogram",
boatstack_packages + "invocation",
}
for path, metadata in zip(files, go_source_metadata(files), strict=True):
invalid = [
import_path
for import_path in metadata["imports"]
if import_path.startswith(boatstack_packages)
and import_path not in allowed
]
self.assertEqual([], invalid, f"invocation dependency direction: {path}")

def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None:
documents = [
REPO / "README.md",
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
working-directory: boatstack
env:
BOATSTACK_REQUIRE_FLOW_FRONTEND: '1'
run: go test ./controlprogram -run TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint
run: go test ./controlprogram -run 'TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint|TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime'

component:
name: component-${{ matrix.name }}
Expand Down
2 changes: 1 addition & 1 deletion boatstack/cmd/boatstack-helper/control_bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func bindControlBundle(ctx context.Context, repository string, transitionID cata
if !exists {
return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref")
}
resolvedRevision, resolveErr := boatstackruntime.ResolveCommitRevision(ctx, repository, baseRef)
resolvedRevision, resolveErr := boatstackruntime.ResolveWorkspaceBaseRevision(ctx, repository, baseRef)
if resolveErr != nil {
return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve base_ref %q: %w", baseRef, resolveErr)
}
Expand Down
589 changes: 589 additions & 0 deletions boatstack/cmd/boatstack-helper/declarative_flow.go

Large diffs are not rendered by default.

418 changes: 418 additions & 0 deletions boatstack/cmd/boatstack-helper/declarative_flow_test.go

Large diffs are not rendered by default.

119 changes: 111 additions & 8 deletions boatstack/cmd/boatstack-helper/delegation_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,23 +102,51 @@ func runFlowAuthorize(arguments []string) error {
return loadErr
}
now := time.Now().UTC()
record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now)
record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now, bound.delegationReprojection)
if err != nil {
return err
}
if changed {
if existing != nil && existing.RequestFingerprint != record.RequestFingerprint {
archivePath, archivePathErr := delegation.SupersededPath(layout.FlowRoot, bound.runID, existing.RequestFingerprint)
if archivePathErr != nil {
return archivePathErr
}
if archiveErr := effects.ArchiveDelegationRecord(archivePath, *existing); archiveErr != nil {
return archiveErr
}
}
if err := effects.StoreDelegationRecord(recordPath, record); err != nil {
return err
}
}
return printDelegationRecord(record)
}

func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time) (delegation.Record, bool, error) {
func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time, allowReprojection bool) (delegation.Record, bool, error) {
if expiresIn < 0 {
return delegation.Record{}, false, fmt.Errorf("flow authorize --expires-in cannot be negative")
}
computedFingerprint, err := request.Fingerprint()
if err != nil || computedFingerprint != requestFingerprint {
return delegation.Record{}, false, fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request")
}
if existing != nil {
if allowReprojection && existing.RequestFingerprint != requestFingerprint {
if existing.Status != "active" && existing.Status != "revoked" {
return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: reconciled run authorization is %s", existing.Status)
}
record := delegation.Record{
Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision,
Request: request, RequestFingerprint: requestFingerprint,
ReceiptID: authorizationReceiptID(requestFingerprint, actor, existing.Revision+1, now), Actor: actor,
AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active",
}
if expiresIn > 0 {
record.ExpiresAt = now.Add(expiresIn)
}
return record, true, nil
}
if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.Status != "active" {
return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status")
}
Expand Down Expand Up @@ -226,6 +254,12 @@ func runFlowContinuation(arguments []string) error {
if options.programID == "" || options.entryID == "" {
return fmt.Errorf("flow run requires --flow and --entry")
}
if handled, declarativeErr := tryRunDeclarativeFlow(context.Background(), options); handled || declarativeErr != nil {
return declarativeErr
}
if len(options.entryInputs) != 0 {
return fmt.Errorf("FLOW_INPUT_INVALID: --input is available only to declarative Flow entries")
}
var response surfaces.Response
for step := 0; step < 256; step++ {
response, err = executeContinuationStep(context.Background(), options)
Expand Down Expand Up @@ -260,6 +294,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
if err != nil {
return surfaces.Response{}, err
}
programChangeResponse, err := preflightDelegatedProgramChange(ctx, resolveRequest)
if err != nil {
return surfaces.Response{}, err
}
if programChangeResponse != nil {
return *programChangeResponse, nil
}
_, delegationResponse, err := prepareDelegation(ctx, &resolveRequest)
if err != nil {
return surfaces.Response{}, err
Expand All @@ -284,6 +325,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil {
err = settleErr
}
if err == nil {
resolveRequest, resolved, _, err = stabilizeRepositoryPrescription(ctx, resolveRequest, resolved)
}
if err != nil || resolved.Prescription == nil {
if err != nil {
return resolved, err
Expand All @@ -305,6 +349,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
if err != nil {
return surfaces.Response{}, err
}
programChangeResponse, err = preflightDelegatedProgramChange(ctx, resolveRequest)
if err != nil {
return surfaces.Response{}, err
}
if programChangeResponse != nil {
return *programChangeResponse, nil
}
_, delegationResponse, err = prepareDelegation(ctx, &resolveRequest)
if err != nil {
return surfaces.Response{}, err
Expand Down Expand Up @@ -340,6 +391,19 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
if resolved.Admission != nil {
applyRequest.IdempotencyKey = resolved.Admission.IdempotencyKey
}
lease, err := acquireFlowExecutionLease(applyRequest)
if err != nil {
return surfaces.Response{}, err
}
defer lease.Release()
applyRequest.Parameters, applyRequest.InvocationEvidence, applyRequest.InputRequest = nil, nil, nil
applyRequest, err = bindRPCFlowEntry(ctx, applyRequest)
if err != nil {
return surfaces.Response{}, err
}
if applyRequest.InputRequest != nil {
return surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, ProgramID: applyRequest.ProgramID, EntryID: applyRequest.EntryID, RunID: applyRequest.FlowID, InputRequest: applyRequest.InputRequest}, nil
}
delegationLock, delegationResponse, err := prepareDelegation(ctx, &applyRequest)
if err != nil {
return surfaces.Response{}, err
Expand All @@ -350,11 +414,6 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
if delegationResponse != nil {
return *delegationResponse, nil
}
lease, err := acquireFlowExecutionLease(applyRequest)
if err != nil {
return surfaces.Response{}, err
}
defer lease.Release()
if err := verifyTrustedRequestControlBundle(applyRequest); err != nil {
return surfaces.Response{}, err
}
Expand Down Expand Up @@ -383,6 +442,48 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
return applied, nil
}

// stabilizeRepositoryPrescription ensures that selection and parameter
// materialization are one resolved identity before a prescription can leave
// the command boundary. It is shared by next, RPC, and Flow continuation.
func stabilizeRepositoryPrescription(ctx context.Context, request surfaces.Request, response surfaces.Response) (surfaces.Request, surfaces.Response, bool, error) {
rebound, changed, err := bindPrescribedRepositoryInvocation(ctx, request, response)
if err != nil || !changed {
return request, response, changed, err
}
if rebound.InputRequest != nil {
return rebound, surfaces.Response{
SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve,
ProgramID: rebound.ProgramID, EntryID: rebound.EntryID, RunID: rebound.FlowID,
InputRequest: rebound.InputRequest,
}, true, nil
}
lease, err := acquireFlowExecutionLease(rebound)
if err != nil {
return surfaces.Request{}, surfaces.Response{}, true, err
}
defer lease.Release()
if err := verifyTrustedRequestControlBundle(rebound); err != nil {
return surfaces.Request{}, surfaces.Response{}, true, err
}
kernel, err := standardKernel(ctx, rebound)
if err != nil {
return surfaces.Request{}, surfaces.Response{}, true, err
}
stabilized, err := kernel.Handle(ctx, rebound)
if err != nil {
return rebound, stabilized, true, err
}
if stabilized.Prescription != nil {
if rebound.InvocationEvidence == nil {
return surfaces.Request{}, surfaces.Response{}, true, fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: stabilized repository prescription has no invocation evidence")
}
if err := stabilized.Prescription.ValidateInvocation(rebound.InvocationEvidence.InvocationFingerprint); err != nil {
return surfaces.Request{}, surfaces.Response{}, true, err
}
}
return rebound, stabilized, true, nil
}

func bindTrustedProviderCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) {
if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil ||
(response.Decision.Kind != supervisor.DecisionFrontier && response.Decision.Kind != supervisor.DecisionCandidate) ||
Expand Down Expand Up @@ -427,7 +528,7 @@ func bindContinuationCandidate(ctx context.Context, bound commandOptions, respon
if err != nil {
return commandOptions{}, false, err
}
if len(rebound.parameters) <= len(bound.parameters) {
if rebound.inputRequest == nil && len(rebound.parameters) <= len(bound.parameters) {
return bound, false, nil
}
return rebound, true, nil
Expand Down Expand Up @@ -460,5 +561,7 @@ func advanceContinuation(options *commandOptions, response surfaces.Response) er
options.effectiveCapabilities = nil
options.idempotencyKey = ""
options.trustedAuthorityReceipts = nil
options.invocationEvidence = nil
options.inputRequest = nil
return nil
}
88 changes: 84 additions & 4 deletions boatstack/cmd/boatstack-helper/delegation_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,26 @@ import (
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces"
)

func canReprojectDelegation(layout ports.ControllerLayout, invocation model.InvocationContext, prior, current delegation.Request) (bool, error) {
if prior.RunID != current.RunID || prior.ProgramID != current.ProgramID || prior.EntryID != current.EntryID ||
prior.TargetID != current.TargetID || prior.ObjectiveID != current.ObjectiveID || prior.DeliveryID != current.DeliveryID ||
prior.RepositoryID != current.RepositoryID || prior.GitCommonID != current.GitCommonID {
return false, nil
}
if prior.ControlBundleFingerprint == current.ControlBundleFingerprint {
return false, nil
}
return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint)
}

func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) {
if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 {
return nil, nil, nil
Expand Down Expand Up @@ -68,17 +81,28 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo
if request.Operation == surfaces.OperationExplain {
return nil, nil, nil
}
return nil, &surfaces.Response{
SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective,
Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"},
}, nil
return nil, delegationRequiredResponse(*request), nil
}
if err != nil {
releaseOnError()
return nil, nil, err
}
if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint {
reprojected, reprojectErr := canReprojectDelegation(layout, invocation, record.Request, delegation.Request{
RunID: request.FlowID, ProgramID: request.ProgramID, ProgramFingerprint: request.ProgramFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint,
EntryID: request.EntryID, TargetID: string(request.Objective.TargetID), ObjectiveID: request.Objective.ID, DeliveryID: request.Objective.DeliveryID,
RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, BindingFingerprint: request.DelegationBindingFingerprint,
})
releaseOnError()
if reprojectErr != nil {
return nil, nil, reprojectErr
}
if reprojected {
if request.Operation == surfaces.OperationExplain {
return nil, nil, nil
}
return nil, delegationRequiredResponse(*request), nil
}
return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context")
}
initial := invocation
Expand Down Expand Up @@ -121,6 +145,62 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo
return lock, nil, nil
}

func delegationRequiredResponse(request surfaces.Request) *surfaces.Response {
return &surfaces.Response{
SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective,
Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"},
}
}

// preflightDelegatedProgramChange observes the selected program before any
// product delegation is requested. Reconciliation changes the control bundle,
// so authorizing against the prior bundle would create an authorization that
// must be rejected immediately after the accepted maintenance transition.
func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Request) (*surfaces.Response, error) {
if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 || request.Operation == surfaces.OperationExplain {
return nil, nil
}
probe := request
probe.Operation = surfaces.OperationExplain
probe.Prescription = protocol.Prescription{}
probe.IdempotencyKey = ""
probe.InvocationEvidence = nil
probe.InputRequest = nil
lease, err := acquireFlowExecutionLease(probe)
if err != nil {
return nil, err
}
defer lease.Release()
if err := verifyTrustedRequestControlBundle(probe); err != nil {
return nil, err
}
kernel, err := standardKernel(ctx, probe)
if err != nil {
return nil, err
}
response, err := kernel.Handle(ctx, probe)
if err != nil {
return nil, err
}
if !isExactProgramChangeSuspension(response) {
return nil, nil
}
response.Operation = request.Operation
return &response, nil
}

func isExactProgramChangeSuspension(response surfaces.Response) bool {
return response.Decision != nil &&
response.Decision.Kind == supervisor.DecisionUnresolved &&
response.Decision.Reason == supervisor.ReasonProgramDrift &&
response.ProgramChange != nil &&
response.ProgramChange.PriorProgramFingerprint != "" &&
response.ProgramChange.CandidateProgramFingerprint != "" &&
response.ProgramChange.ProgramDeltaFingerprint != "" &&
response.ProgramChange.RequiredTransition == "installation.reconcile-update" &&
response.ProgramChange.AcceptanceFlag == "--accept-program-change"
}

func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response, targetSatisfied, lockHeld bool) error {
terminalDecision := response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal
committedTarget := response.Receipt != nil && targetSatisfied
Expand Down
Loading
Loading