iop/apps/agent/internal/localcontrol/service.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
}