Files
stream.api/internal/video/runtime/grpc/server.go
2026-03-13 02:17:18 +00:00

362 lines
11 KiB
Go

package grpc
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"sync"
"time"
grpcpkg "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"stream.api/internal/video/runtime/domain"
"stream.api/internal/video/runtime/proto"
"stream.api/internal/video/runtime/services"
)
type Server struct {
proto.UnimplementedWoodpeckerServer
proto.UnimplementedWoodpeckerAuthServer
jobService *services.JobService
agentManager *AgentManager
agentSecret string
sessions sync.Map
agentJobs sync.Map
onAgentEvent func(string, *services.AgentWithStats)
}
func NewServer(jobService *services.JobService, agentSecret string) *Server {
return &Server{jobService: jobService, agentManager: NewAgentManager(), agentSecret: agentSecret}
}
func (s *Server) SetAgentEventHandler(handler func(string, *services.AgentWithStats)) {
s.onAgentEvent = handler
}
func (s *Server) Register(grpcServer grpcpkg.ServiceRegistrar) {
proto.RegisterWoodpeckerServer(grpcServer, s)
proto.RegisterWoodpeckerAuthServer(grpcServer, s)
}
func (s *Server) SendCommand(agentID string, cmd string) bool {
return s.agentManager.SendCommand(agentID, cmd)
}
func (s *Server) ListAgents() []*domain.Agent { return s.agentManager.ListAll() }
func (s *Server) ListAgentsWithStats() []*services.AgentWithStats {
agents := s.agentManager.ListAll()
result := make([]*services.AgentWithStats, 0, len(agents))
for _, agent := range agents {
result = append(result, &services.AgentWithStats{Agent: agent, ActiveJobCount: int64(len(s.getAgentJobs(agent.ID)))})
}
return result
}
func (s *Server) getAgentWithStats(agentID string) *services.AgentWithStats {
for _, agent := range s.ListAgentsWithStats() {
if agent != nil && agent.Agent != nil && agent.ID == agentID {
return agent
}
}
return nil
}
func (s *Server) Version(context.Context, *proto.Empty) (*proto.VersionResponse, error) {
return &proto.VersionResponse{GrpcVersion: 15, ServerVersion: "stream.api"}, nil
}
func generateToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func generateAgentID() string {
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
func (s *Server) getAgentIDFromContext(ctx context.Context) (string, string, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", "", false
}
tokens := md.Get("token")
if len(tokens) == 0 {
return "", "", false
}
token := tokens[0]
if id, ok := s.sessions.Load(token); ok {
return id.(string), token, true
}
return "", "", false
}
func (s *Server) Next(context.Context, *proto.NextRequest) (*proto.NextResponse, error) {
return nil, status.Error(codes.Unimplemented, "use StreamJobs")
}
func (s *Server) StreamJobs(_ *proto.StreamOptions, stream grpcpkg.ServerStreamingServer[proto.Workflow]) error {
ctx := stream.Context()
agentID, _, ok := s.getAgentIDFromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "invalid or missing token")
}
s.agentManager.UpdateHeartbeat(agentID)
cancelCh, _ := s.jobService.SubscribeCancel(ctx, agentID)
commandCh, _ := s.agentManager.GetCommandChannel(agentID)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case cmd := <-commandCh:
payload, _ := json.Marshal(map[string]any{"image": "alpine", "commands": []string{"echo 'System Command'"}, "environment": map[string]string{}, "action": cmd})
if err := stream.Send(&proto.Workflow{Id: fmt.Sprintf("cmd-%s-%d", agentID, time.Now().UnixNano()), Timeout: 300, Payload: payload}); err != nil {
return err
}
case jobID := <-cancelCh:
if s.isJobAssigned(agentID, jobID) {
if err := stream.Send(&proto.Workflow{Id: jobID, Cancel: true}); err != nil {
return err
}
}
case <-ctx.Done():
return nil
case <-ticker.C:
s.agentManager.UpdateHeartbeat(agentID)
jobCtx, cancel := context.WithTimeout(ctx, time.Second)
job, err := s.jobService.GetNextJob(jobCtx)
cancel()
if err != nil || job == nil {
continue
}
s.trackJobAssignment(agentID, job.ID)
if err := s.jobService.AssignJob(ctx, job.ID, agentID); err != nil {
s.untrackJobAssignment(agentID, job.ID)
continue
}
var config map[string]any
if err := json.Unmarshal([]byte(job.Config), &config); err != nil {
_ = s.jobService.UpdateJobStatus(ctx, job.ID, domain.JobStatusFailure)
s.untrackJobAssignment(agentID, job.ID)
continue
}
image, _ := config["image"].(string)
if image == "" {
image = "alpine"
}
commands := []string{"echo 'No commands specified'"}
if raw, ok := config["commands"].([]any); ok && len(raw) > 0 {
commands = commands[:0]
for _, item := range raw {
if text, ok := item.(string); ok {
commands = append(commands, text)
}
}
if len(commands) == 0 {
commands = []string{"echo 'No commands specified'"}
}
}
payload, _ := json.Marshal(map[string]any{"image": image, "commands": commands, "environment": map[string]string{}})
if err := stream.Send(&proto.Workflow{Id: job.ID, Timeout: job.TimeLimit, Payload: payload}); err != nil {
_ = s.jobService.UpdateJobStatus(ctx, job.ID, domain.JobStatusPending)
s.untrackJobAssignment(agentID, job.ID)
return err
}
}
}
}
func (s *Server) SubmitStatus(stream grpcpkg.ClientStreamingServer[proto.StatusUpdate, proto.Empty]) error {
ctx := stream.Context()
agentID, _, ok := s.getAgentIDFromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "invalid or missing token")
}
for {
update, err := stream.Recv()
if err != nil {
return stream.SendAndClose(&proto.Empty{})
}
switch update.Type {
case 0, 1:
_ = s.jobService.ProcessLog(ctx, update.StepUuid, update.Data)
case 4:
var progress float64
fmt.Sscanf(string(update.Data), "%f", &progress)
_ = s.jobService.UpdateJobProgress(ctx, update.StepUuid, progress)
case 5:
var stats struct {
CPU float64 `json:"cpu"`
RAM float64 `json:"ram"`
}
if json.Unmarshal(update.Data, &stats) == nil {
s.agentManager.UpdateResources(agentID, stats.CPU, stats.RAM)
if s.onAgentEvent != nil {
s.onAgentEvent("agent_update", s.getAgentWithStats(agentID))
}
}
_ = s.jobService.PublishSystemResources(ctx, agentID, update.Data)
}
}
}
func (s *Server) Init(ctx context.Context, req *proto.InitRequest) (*proto.Empty, error) {
if err := s.jobService.UpdateJobStatus(ctx, req.Id, domain.JobStatusRunning); err != nil {
return nil, status.Error(codes.Internal, "failed to update job status")
}
return &proto.Empty{}, nil
}
func (s *Server) Wait(context.Context, *proto.WaitRequest) (*proto.WaitResponse, error) {
return &proto.WaitResponse{Canceled: false}, nil
}
func (s *Server) Done(ctx context.Context, req *proto.DoneRequest) (*proto.Empty, error) {
agentID, _, ok := s.getAgentIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "invalid session")
}
jobStatus := domain.JobStatusSuccess
if req.State != nil && req.State.Error != "" {
jobStatus = domain.JobStatusFailure
}
if err := s.jobService.UpdateJobStatus(ctx, req.Id, jobStatus); err != nil {
return nil, status.Error(codes.Internal, "failed to update job status")
}
s.untrackJobAssignment(agentID, req.Id)
return &proto.Empty{}, nil
}
func (s *Server) Update(context.Context, *proto.UpdateRequest) (*proto.Empty, error) {
return &proto.Empty{}, nil
}
func (s *Server) Log(ctx context.Context, req *proto.LogRequest) (*proto.Empty, error) {
if _, _, ok := s.getAgentIDFromContext(ctx); !ok {
return nil, status.Error(codes.Unauthenticated, "invalid session")
}
for _, entry := range req.LogEntries {
if entry.StepUuid != "" {
_ = s.jobService.ProcessLog(ctx, entry.StepUuid, entry.Data)
}
}
return &proto.Empty{}, nil
}
func (s *Server) Extend(context.Context, *proto.ExtendRequest) (*proto.Empty, error) {
return &proto.Empty{}, nil
}
func (s *Server) RegisterAgent(ctx context.Context, req *proto.RegisterAgentRequest) (*proto.RegisterAgentResponse, error) {
if req.Info == nil {
return nil, status.Error(codes.InvalidArgument, "connection info is required")
}
id, _, ok := s.getAgentIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "invalid session")
}
hostname := ""
if req.Info.CustomLabels != nil {
hostname = req.Info.CustomLabels["hostname"]
}
name := hostname
if name == "" {
name = fmt.Sprintf("agent-%s", id)
}
s.agentManager.Register(id, name, req.Info.Platform, req.Info.Backend, req.Info.Version, req.Info.Capacity)
if s.onAgentEvent != nil {
s.onAgentEvent("agent_update", s.getAgentWithStats(id))
}
return &proto.RegisterAgentResponse{AgentId: id}, nil
}
func (s *Server) UnregisterAgent(ctx context.Context, _ *proto.Empty) (*proto.Empty, error) {
agentID, token, ok := s.getAgentIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "invalid session")
}
for _, jobID := range s.getAgentJobs(agentID) {
_ = s.jobService.UpdateJobStatus(ctx, jobID, domain.JobStatusFailure)
s.untrackJobAssignment(agentID, jobID)
}
s.sessions.Delete(token)
s.agentJobs.Delete(agentID)
agent := s.getAgentWithStats(agentID)
s.agentManager.Unregister(agentID)
if s.onAgentEvent != nil {
s.onAgentEvent("agent_update", agent)
}
return &proto.Empty{}, nil
}
func (s *Server) ReportHealth(ctx context.Context, _ *proto.ReportHealthRequest) (*proto.Empty, error) {
agentID, _, ok := s.getAgentIDFromContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "invalid session")
}
s.agentManager.UpdateHeartbeat(agentID)
return &proto.Empty{}, nil
}
func (s *Server) Auth(ctx context.Context, req *proto.AuthRequest) (*proto.AuthResponse, error) {
if s.agentSecret != "" && req.AgentToken != s.agentSecret {
return nil, status.Error(codes.Unauthenticated, "invalid agent secret")
}
agentID := req.AgentId
if len(agentID) > 6 && agentID[:6] == "agent-" {
agentID = agentID[6:]
}
if agentID == "" {
agentID = generateAgentID()
}
accessToken := generateToken()
s.sessions.Store(accessToken, agentID)
return &proto.AuthResponse{Status: "ok", AgentId: agentID, AccessToken: accessToken}, nil
}
func (s *Server) trackJobAssignment(agentID, jobID string) {
jobSetInterface, _ := s.agentJobs.LoadOrStore(agentID, &sync.Map{})
if jobSet, ok := jobSetInterface.(*sync.Map); ok {
jobSet.Store(jobID, true)
}
}
func (s *Server) untrackJobAssignment(agentID, jobID string) {
if jobSetInterface, ok := s.agentJobs.Load(agentID); ok {
if jobSet, ok := jobSetInterface.(*sync.Map); ok {
jobSet.Delete(jobID)
}
}
}
func (s *Server) isJobAssigned(agentID, jobID string) bool {
if jobSetInterface, ok := s.agentJobs.Load(agentID); ok {
if jobSet, ok := jobSetInterface.(*sync.Map); ok {
_, found := jobSet.Load(jobID)
return found
}
}
return false
}
func (s *Server) getAgentJobs(agentID string) []string {
jobs := []string{}
if jobSetInterface, ok := s.agentJobs.Load(agentID); ok {
if jobSet, ok := jobSetInterface.(*sync.Map); ok {
jobSet.Range(func(key, _ any) bool {
if jobID, ok := key.(string); ok {
jobs = append(jobs, jobID)
}
return true
})
}
}
return jobs
}