451 lines
12 KiB
Go
451 lines
12 KiB
Go
package localcontrol
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// StatusSnapshot is the narrow, client-neutral projection returned by the host.
|
|
// Implementations must not place private paths, credentials, or raw subprocess
|
|
// output in these fields.
|
|
type StatusSnapshot struct {
|
|
SubjectID string
|
|
State string
|
|
Summary string
|
|
Entries []StatusEntry
|
|
}
|
|
|
|
type StatusEntry struct {
|
|
Kind string
|
|
SubjectID string
|
|
State string
|
|
Summary string
|
|
}
|
|
|
|
type ProjectCommand struct {
|
|
CommandID string
|
|
ProjectID string
|
|
WorkspaceID string
|
|
MilestoneID string
|
|
}
|
|
|
|
type MutationResult struct {
|
|
SubjectID string
|
|
State string
|
|
Summary string
|
|
}
|
|
|
|
// StateReader exposes only coherent host projections required by the protocol.
|
|
type StateReader interface {
|
|
RuntimeStatus(context.Context) (StatusSnapshot, error)
|
|
ProjectStatus(context.Context, string) (StatusSnapshot, error)
|
|
OverlayStatus(context.Context, string, string) (StatusSnapshot, error)
|
|
IntegrationStatus(context.Context, string, string) (StatusSnapshot, error)
|
|
BlockerList(context.Context, string) (StatusSnapshot, error)
|
|
ProcessStatus(context.Context, string) (StatusSnapshot, error)
|
|
}
|
|
|
|
// ProjectController delegates lifecycle ownership to the shared runtime host.
|
|
type ProjectController interface {
|
|
StartProject(context.Context, ProjectCommand) (MutationResult, error)
|
|
StopProject(context.Context, ProjectCommand) (MutationResult, error)
|
|
ResumeProject(context.Context, ProjectCommand) (MutationResult, error)
|
|
}
|
|
|
|
type Service struct {
|
|
reader StateReader
|
|
controller ProjectController
|
|
ledger *Ledger
|
|
clientOps *ClientOperations
|
|
}
|
|
|
|
const maxStatusEntries = 256
|
|
|
|
func NewService(
|
|
reader StateReader,
|
|
controller ProjectController,
|
|
ledger *Ledger,
|
|
) (*Service, error) {
|
|
if reader == nil {
|
|
return nil, fmt.Errorf("localcontrol: state reader is required")
|
|
}
|
|
if controller == nil {
|
|
return nil, fmt.Errorf("localcontrol: project controller is required")
|
|
}
|
|
if ledger == nil {
|
|
return nil, fmt.Errorf("localcontrol: ledger is required")
|
|
}
|
|
return &Service{
|
|
reader: reader,
|
|
controller: controller,
|
|
ledger: ledger,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) WithClientOperations(ops *ClientOperations) *Service {
|
|
s.clientOps = ops
|
|
return s
|
|
}
|
|
|
|
// Handle applies authorization and protocol validation before touching a read
|
|
// or mutation port. Server sessions pass authorized=true only after the kernel
|
|
// peer credential check succeeds.
|
|
func (s *Service) Handle(
|
|
ctx context.Context,
|
|
authorized bool,
|
|
request *iop.AgentLocalEnvelope,
|
|
) *iop.AgentLocalEnvelope {
|
|
if !authorized {
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorPermissionDenied, "local-control peer is not authorized"),
|
|
)
|
|
}
|
|
if failure := ValidateRequest(request); failure != nil {
|
|
return errorEnvelope(request, failure)
|
|
}
|
|
|
|
replayed, view, failure := s.requestReplay(ctx, request.GetRequest())
|
|
if failure != nil {
|
|
return errorEnvelope(request, failure)
|
|
}
|
|
|
|
switch {
|
|
case isReadOperation(request.GetOperation()):
|
|
return s.handleRead(ctx, request, replayed, view)
|
|
case isProjectMutation(request.GetOperation()):
|
|
return s.handleProjectMutation(ctx, request, replayed)
|
|
case isClientMutation(request.GetOperation()):
|
|
if s.clientOps != nil {
|
|
return s.clientOps.Handle(ctx, authorized, request)
|
|
}
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(
|
|
ErrorUnsupportedOperation,
|
|
"client process operations are not enabled by this service",
|
|
),
|
|
)
|
|
default:
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorUnsupportedOperation, "local-control operation is not supported"),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *Service) requestReplay(
|
|
ctx context.Context,
|
|
request *iop.AgentLocalRequest,
|
|
) ([]*iop.AgentLocalEvent, LedgerView, *ProtocolError) {
|
|
if request.ReplayAfterSequence == nil {
|
|
view, failure := s.ledger.View(ctx)
|
|
return nil, view, failure
|
|
}
|
|
return s.ledger.Replay(
|
|
ctx,
|
|
request.GetReplayDaemonId(),
|
|
request.GetReplayAfterSequence(),
|
|
)
|
|
}
|
|
|
|
func (s *Service) handleRead(
|
|
ctx context.Context,
|
|
request *iop.AgentLocalEnvelope,
|
|
replayed []*iop.AgentLocalEvent,
|
|
view LedgerView,
|
|
) *iop.AgentLocalEnvelope {
|
|
arguments := request.GetRequest().GetRead()
|
|
var (
|
|
snapshot StatusSnapshot
|
|
err error
|
|
)
|
|
switch request.GetOperation() {
|
|
case OperationRuntimeStatus:
|
|
snapshot, err = s.reader.RuntimeStatus(ctx)
|
|
case OperationProjectStatus:
|
|
snapshot, err = s.reader.ProjectStatus(ctx, arguments.GetProjectId())
|
|
case OperationOverlayStatus:
|
|
snapshot, err = s.reader.OverlayStatus(
|
|
ctx,
|
|
arguments.GetProjectId(),
|
|
arguments.GetWorkUnitId(),
|
|
)
|
|
case OperationIntegrationStatus:
|
|
snapshot, err = s.reader.IntegrationStatus(
|
|
ctx,
|
|
arguments.GetProjectId(),
|
|
arguments.GetWorkUnitId(),
|
|
)
|
|
case OperationBlockerList:
|
|
snapshot, err = s.reader.BlockerList(ctx, arguments.GetProjectId())
|
|
case OperationProcessStatus:
|
|
snapshot, err = s.reader.ProcessStatus(ctx, arguments.GetClientKind())
|
|
}
|
|
if err != nil {
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorInvalidState, "requested host state is unavailable"),
|
|
)
|
|
}
|
|
wireSnapshot, failure := encodeStatusSnapshot(snapshot, view)
|
|
if failure != nil {
|
|
return errorEnvelope(request, failure)
|
|
}
|
|
response := &iop.AgentLocalEnvelope{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE,
|
|
MessageId: derivedMessageID("response", request.GetMessageId()),
|
|
CorrelationId: request.GetMessageId(),
|
|
Operation: request.GetOperation(),
|
|
Payload: &iop.AgentLocalEnvelope_Response{
|
|
Response: &iop.AgentLocalResponse{
|
|
StateRevision: view.StateRevision,
|
|
SnapshotMarker: snapshotMarker(
|
|
view.DaemonID,
|
|
view.StateRevision,
|
|
),
|
|
ReplayDaemonId: view.DaemonID,
|
|
ReplayCursor: view.ReplayCursor,
|
|
Payload: &iop.AgentLocalResponse_Snapshot{
|
|
Snapshot: wireSnapshot,
|
|
},
|
|
ReplayEvents: cloneEvents(replayed),
|
|
},
|
|
},
|
|
}
|
|
if proto.Size(response) > MaxFrameBytes {
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorInternal, "host snapshot exceeds the local-control frame limit"),
|
|
)
|
|
}
|
|
return response
|
|
}
|
|
|
|
func (s *Service) handleProjectMutation(
|
|
ctx context.Context,
|
|
request *iop.AgentLocalEnvelope,
|
|
replayed []*iop.AgentLocalEvent,
|
|
) *iop.AgentLocalEnvelope {
|
|
commandID := request.GetRequest().GetCommandId()
|
|
requestHash, err := immutableCommandHash(
|
|
request.GetOperation(),
|
|
request.GetRequest(),
|
|
)
|
|
if err != nil {
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorInternal, "command arguments could not be validated"),
|
|
)
|
|
}
|
|
accepted := mutationResponse(
|
|
request,
|
|
replayed,
|
|
&iop.AgentLocalMutationResult{
|
|
Accepted: true,
|
|
SubjectId: request.GetRequest().GetProject().GetProjectId(),
|
|
State: "accepted",
|
|
Summary: "project command accepted",
|
|
},
|
|
)
|
|
for {
|
|
begin, failure := s.ledger.BeginCommand(
|
|
ctx,
|
|
commandID,
|
|
requestHash,
|
|
accepted,
|
|
)
|
|
if failure != nil {
|
|
return errorEnvelope(request, failure)
|
|
}
|
|
switch begin.ownership {
|
|
case commandCompleted:
|
|
return begin.response
|
|
case commandWaiter:
|
|
if failure := s.ledger.WaitCommand(ctx, begin.flight); failure != nil {
|
|
return errorEnvelope(request, failure)
|
|
}
|
|
continue
|
|
case commandOwner:
|
|
defer s.ledger.ReleaseCommandOwner(commandID, begin.flight)
|
|
default:
|
|
return errorEnvelope(
|
|
request,
|
|
protocolError(ErrorInternal, "command ownership is invalid"),
|
|
)
|
|
}
|
|
break
|
|
}
|
|
|
|
arguments := request.GetRequest().GetProject()
|
|
command := ProjectCommand{
|
|
CommandID: commandID,
|
|
ProjectID: arguments.GetProjectId(),
|
|
WorkspaceID: arguments.GetWorkspaceId(),
|
|
MilestoneID: arguments.GetMilestoneId(),
|
|
}
|
|
var result MutationResult
|
|
switch request.GetOperation() {
|
|
case OperationProjectStart:
|
|
result, err = s.controller.StartProject(ctx, command)
|
|
case OperationProjectStop:
|
|
result, err = s.controller.StopProject(ctx, command)
|
|
case OperationProjectResume:
|
|
result, err = s.controller.ResumeProject(ctx, command)
|
|
}
|
|
if err != nil {
|
|
final, finishFailure := s.ledger.FinishCommand(
|
|
ctx,
|
|
commandID,
|
|
requestHash,
|
|
errorEnvelope(
|
|
request,
|
|
protocolError(ErrorInvalidState, "project command was not applied"),
|
|
),
|
|
nil,
|
|
)
|
|
if finishFailure != nil {
|
|
return errorEnvelope(request, finishFailure)
|
|
}
|
|
return final
|
|
}
|
|
wireResult, failure := encodeMutationResult(result)
|
|
if failure != nil {
|
|
final, finishFailure := s.ledger.FinishCommand(
|
|
ctx,
|
|
commandID,
|
|
requestHash,
|
|
errorEnvelope(request, failure),
|
|
nil,
|
|
)
|
|
if finishFailure != nil {
|
|
return errorEnvelope(request, finishFailure)
|
|
}
|
|
return final
|
|
}
|
|
wireResult.Accepted = true
|
|
response := mutationResponse(request, replayed, wireResult)
|
|
event := &iop.AgentLocalEvent{
|
|
EventType: request.GetOperation(),
|
|
SubjectId: wireResult.GetSubjectId(),
|
|
Mutation: wireResult,
|
|
}
|
|
final, finishFailure := s.ledger.FinishCommand(
|
|
ctx,
|
|
commandID,
|
|
requestHash,
|
|
response,
|
|
event,
|
|
)
|
|
if finishFailure != nil {
|
|
return errorEnvelope(request, finishFailure)
|
|
}
|
|
return final
|
|
}
|
|
|
|
func mutationResponse(
|
|
request *iop.AgentLocalEnvelope,
|
|
replayed []*iop.AgentLocalEvent,
|
|
result *iop.AgentLocalMutationResult,
|
|
) *iop.AgentLocalEnvelope {
|
|
return &iop.AgentLocalEnvelope{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE,
|
|
MessageId: derivedMessageID("response", request.GetMessageId()),
|
|
CorrelationId: request.GetMessageId(),
|
|
Operation: request.GetOperation(),
|
|
Payload: &iop.AgentLocalEnvelope_Response{
|
|
Response: &iop.AgentLocalResponse{
|
|
Payload: &iop.AgentLocalResponse_Mutation{
|
|
Mutation: result,
|
|
},
|
|
ReplayEvents: cloneEvents(replayed),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func encodeStatusSnapshot(
|
|
snapshot StatusSnapshot,
|
|
view LedgerView,
|
|
) (*iop.AgentLocalSnapshot, *ProtocolError) {
|
|
if !validOptionalIdentifier(snapshot.SubjectID) ||
|
|
!validOptionalHostText(snapshot.State) ||
|
|
!validOptionalHostText(snapshot.Summary) ||
|
|
len(snapshot.Entries) > maxStatusEntries {
|
|
return nil, protocolError(ErrorInternal, "host snapshot contains invalid data")
|
|
}
|
|
entries := make([]*iop.AgentLocalStatusEntry, 0, len(snapshot.Entries))
|
|
for _, entry := range snapshot.Entries {
|
|
if !validOptionalIdentifier(entry.Kind) ||
|
|
!validOptionalIdentifier(entry.SubjectID) ||
|
|
!validOptionalHostText(entry.State) ||
|
|
!validOptionalHostText(entry.Summary) {
|
|
return nil, protocolError(ErrorInternal, "host snapshot contains invalid data")
|
|
}
|
|
entries = append(entries, &iop.AgentLocalStatusEntry{
|
|
Kind: entry.Kind,
|
|
SubjectId: entry.SubjectID,
|
|
State: entry.State,
|
|
Summary: entry.Summary,
|
|
})
|
|
}
|
|
return &iop.AgentLocalSnapshot{
|
|
DaemonId: view.DaemonID,
|
|
StateRevision: view.StateRevision,
|
|
ReplayCursor: view.ReplayCursor,
|
|
SubjectId: snapshot.SubjectID,
|
|
State: snapshot.State,
|
|
Summary: snapshot.Summary,
|
|
Entries: entries,
|
|
}, nil
|
|
}
|
|
|
|
func encodeMutationResult(
|
|
result MutationResult,
|
|
) (*iop.AgentLocalMutationResult, *ProtocolError) {
|
|
if !validIdentifier(result.SubjectID, maxIdentifierBytes) ||
|
|
!validOptionalHostText(result.State) ||
|
|
!validOptionalHostText(result.Summary) {
|
|
return nil, protocolError(ErrorInternal, "host mutation result contains invalid data")
|
|
}
|
|
return &iop.AgentLocalMutationResult{
|
|
SubjectId: result.SubjectID,
|
|
State: result.State,
|
|
Summary: result.Summary,
|
|
}, nil
|
|
}
|
|
|
|
func validOptionalHostText(value string) bool {
|
|
return value == "" || validBoundedText(value, maxSafeTextBytes)
|
|
}
|
|
|
|
func cloneEvents(events []*iop.AgentLocalEvent) []*iop.AgentLocalEvent {
|
|
if len(events) == 0 {
|
|
return nil
|
|
}
|
|
clones := make([]*iop.AgentLocalEvent, 0, len(events))
|
|
for _, event := range events {
|
|
if event == nil {
|
|
continue
|
|
}
|
|
clones = append(clones, &iop.AgentLocalEvent{
|
|
EventSequence: event.GetEventSequence(),
|
|
EventType: event.GetEventType(),
|
|
SubjectId: event.GetSubjectId(),
|
|
StateRevision: event.GetStateRevision(),
|
|
Mutation: &iop.AgentLocalMutationResult{
|
|
Accepted: event.GetMutation().GetAccepted(),
|
|
SubjectId: event.GetMutation().GetSubjectId(),
|
|
State: event.GetMutation().GetState(),
|
|
Summary: event.GetMutation().GetSummary(),
|
|
},
|
|
})
|
|
}
|
|
return clones
|
|
}
|