package main import ( "context" "crypto/subtle" "encoding/json" "errors" "net/http" "strings" "git.cin.su/m/Assistant/adapters/postgres" commitcontract "git.cin.su/m/Assistant/contracts/commit" commitdomain "git.cin.su/m/Assistant/modules/commit/domain" "git.cin.su/m/Assistant/modules/commit/ports" ) var ( errActionAlreadyAttempted = errors.New("action already has an execution attempt") errActionDoesNotNeedReconcile = errors.New("action does not require reconciliation") ) type executionRepository interface { LoadAuthorized(context.Context, commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) LoadLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, error) BeginAttempt(context.Context, commitcontract.ActionAttempt) error FinishAttempt(context.Context, commitcontract.ActionAttempt) error } type attemptRequest struct { AttemptID commitcontract.ActionAttemptID } func registerExecutionRoutes(mux *http.ServeMux, repository executionRepository, executor ports.Executor, reconciler ports.Reconciler, token string) { mux.HandleFunc("POST /v1/internal/authorized-actions/{id}/execute", requireCommitToken(token, func(w http.ResponseWriter, r *http.Request) { action, err := repository.LoadAuthorized(r.Context(), commitcontract.AuthorizedActionID(r.PathValue("id"))) if errors.Is(err, postgres.ErrAuthorizedActionNotFound) { http.Error(w, "authorized action not found", http.StatusNotFound) return } if err != nil { http.Error(w, "authorized action unavailable", http.StatusInternalServerError) return } request, ok := decodeAttemptRequest(w, r) if !ok { return } attempt, executeErr := executeAuthorized(r.Context(), repository, executor, action, request.AttemptID) if errors.Is(executeErr, errActionAlreadyAttempted) { http.Error(w, "action already has an execution attempt", http.StatusConflict) return } if executeErr != nil && !errors.Is(executeErr, commitdomain.ErrUnknownOutcome) { http.Error(w, "execute authorized action", http.StatusInternalServerError) return } writeAttempt(w, attempt, executeErr) })) mux.HandleFunc("POST /v1/internal/authorized-actions/{id}/reconcile", requireCommitToken(token, func(w http.ResponseWriter, r *http.Request) { action, err := repository.LoadAuthorized(r.Context(), commitcontract.AuthorizedActionID(r.PathValue("id"))) if errors.Is(err, postgres.ErrAuthorizedActionNotFound) { http.Error(w, "authorized action not found", http.StatusNotFound) return } if err != nil { http.Error(w, "authorized action unavailable", http.StatusInternalServerError) return } request, ok := decodeAttemptRequest(w, r) if !ok { return } attempt, reconcileErr := reconcileAuthorized(r.Context(), repository, reconciler, action, request.AttemptID) if errors.Is(reconcileErr, errActionDoesNotNeedReconcile) { http.Error(w, "action does not require reconciliation", http.StatusConflict) return } if reconcileErr != nil && !errors.Is(reconcileErr, commitdomain.ErrUnknownOutcome) { http.Error(w, "reconcile authorized action", http.StatusInternalServerError) return } writeAttempt(w, attempt, reconcileErr) })) } func executeAuthorized(ctx context.Context, repository executionRepository, executor ports.Executor, action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID) (commitcontract.ActionAttempt, error) { if _, err := repository.LoadLatestAttempt(ctx, action.ID); err == nil { return commitcontract.ActionAttempt{}, errActionAlreadyAttempted } else if !errors.Is(err, postgres.ErrActionAttemptNotFound) { return commitcontract.ActionAttempt{}, err } begun := commitcontract.ActionAttempt{ID: attemptID, AuthorizedID: action.ID, IntentID: action.Proposal.IntentID, State: commitcontract.ActionExecuting} if err := repository.BeginAttempt(ctx, begun); err != nil { return commitcontract.ActionAttempt{}, err } attempt, operationErr := commitdomain.Execute(action, attemptID, executor) if err := repository.FinishAttempt(ctx, attempt); err != nil { return commitcontract.ActionAttempt{}, err } return attempt, operationErr } func reconcileAuthorized(ctx context.Context, repository executionRepository, reconciler ports.Reconciler, action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID) (commitcontract.ActionAttempt, error) { previous, err := repository.LoadLatestAttempt(ctx, action.ID) if err != nil { return commitcontract.ActionAttempt{}, errActionDoesNotNeedReconcile } if previous.State == commitcontract.ActionExecuting { previous.State = commitcontract.ActionUnknownOutcome if err = repository.FinishAttempt(ctx, previous); err != nil { return commitcontract.ActionAttempt{}, err } } if previous.State != commitcontract.ActionUnknownOutcome { return commitcontract.ActionAttempt{}, errActionDoesNotNeedReconcile } begun := commitcontract.ActionAttempt{ID: attemptID, AuthorizedID: action.ID, IntentID: action.Proposal.IntentID, State: commitcontract.ActionExecuting} if err = repository.BeginAttempt(ctx, begun); err != nil { return commitcontract.ActionAttempt{}, err } attempt, operationErr := commitdomain.Reconcile(action, previous, attemptID, reconciler) if err = repository.FinishAttempt(ctx, attempt); err != nil { return commitcontract.ActionAttempt{}, err } return attempt, operationErr } func decodeAttemptRequest(w http.ResponseWriter, r *http.Request) (attemptRequest, bool) { var request attemptRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request); err != nil || request.AttemptID == "" { http.Error(w, "invalid attempt request", http.StatusBadRequest) return attemptRequest{}, false } return request, true } func writeAttempt(w http.ResponseWriter, attempt commitcontract.ActionAttempt, operationErr error) { w.Header().Set("Content-Type", "application/json") if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) { w.WriteHeader(http.StatusAccepted) } _ = json.NewEncoder(w).Encode(attempt) } func requireCommitToken(expected string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if expected == "" || len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { http.Error(w, "commit executor denied", http.StatusUnauthorized) return } next(w, r) } }