Files
2026-08-16 16:24:02 +03:00

156 lines
6.4 KiB
Go

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)
}
}