156 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|