This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
if _, err = repository.LoadLatestAttempt(r.Context(), action.ID); err == nil {
|
||||
http.Error(w, "action already has an execution attempt", http.StatusConflict)
|
||||
return
|
||||
} else if !errors.Is(err, postgres.ErrActionAttemptNotFound) {
|
||||
http.Error(w, "action attempt unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAttemptRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
begun := commitcontract.ActionAttempt{ID: request.AttemptID, AuthorizedID: action.ID, IntentID: action.Proposal.IntentID, State: commitcontract.ActionExecuting}
|
||||
if err = repository.BeginAttempt(r.Context(), begun); err != nil {
|
||||
http.Error(w, "begin execution attempt", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
attempt, executeErr := commitdomain.Execute(action, request.AttemptID, executor)
|
||||
if err = repository.FinishAttempt(r.Context(), attempt); err != nil {
|
||||
http.Error(w, "finish execution attempt", 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
|
||||
}
|
||||
previous, err := repository.LoadLatestAttempt(r.Context(), action.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown execution attempt not found", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if previous.State == commitcontract.ActionExecuting {
|
||||
previous.State = commitcontract.ActionUnknownOutcome
|
||||
if err = repository.FinishAttempt(r.Context(), previous); err != nil {
|
||||
http.Error(w, "recover interrupted attempt", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if previous.State != commitcontract.ActionUnknownOutcome {
|
||||
http.Error(w, "action does not require reconciliation", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAttemptRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
begun := commitcontract.ActionAttempt{ID: request.AttemptID, AuthorizedID: action.ID, IntentID: action.Proposal.IntentID, State: commitcontract.ActionExecuting}
|
||||
if err = repository.BeginAttempt(r.Context(), begun); err != nil {
|
||||
http.Error(w, "begin reconciliation attempt", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
attempt, reconcileErr := commitdomain.Reconcile(action, previous, request.AttemptID, reconciler)
|
||||
if err = repository.FinishAttempt(r.Context(), attempt); err != nil {
|
||||
http.Error(w, "finish reconciliation attempt", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeAttempt(w, attempt, reconcileErr)
|
||||
}))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/modules/commit/ports"
|
||||
)
|
||||
|
||||
type fakeExecutionRepository struct {
|
||||
action commitcontract.AuthorizedAction
|
||||
attempts []commitcontract.ActionAttempt
|
||||
}
|
||||
|
||||
func (f *fakeExecutionRepository) LoadAuthorized(context.Context, commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) {
|
||||
return f.action, nil
|
||||
}
|
||||
func (f *fakeExecutionRepository) LoadLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, error) {
|
||||
if len(f.attempts) == 0 {
|
||||
return commitcontract.ActionAttempt{}, postgres.ErrActionAttemptNotFound
|
||||
}
|
||||
return f.attempts[len(f.attempts)-1], nil
|
||||
}
|
||||
func (f *fakeExecutionRepository) BeginAttempt(_ context.Context, attempt commitcontract.ActionAttempt) error {
|
||||
f.attempts = append(f.attempts, attempt)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeExecutionRepository) FinishAttempt(_ context.Context, attempt commitcontract.ActionAttempt) error {
|
||||
if len(f.attempts) == 0 || f.attempts[len(f.attempts)-1].ID != attempt.ID {
|
||||
return errors.New("attempt not begun")
|
||||
}
|
||||
f.attempts[len(f.attempts)-1] = attempt
|
||||
return nil
|
||||
}
|
||||
|
||||
type knownExecutor struct{ calls int }
|
||||
|
||||
func (e *knownExecutor) Execute(commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
e.calls++
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
}
|
||||
func (e *knownExecutor) Reconcile(commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
}
|
||||
|
||||
func TestExecutionIsDurablyBegunAndCannotBeBlindlyRepeated(t *testing.T) {
|
||||
repository := &fakeExecutionRepository{action: commitcontract.AuthorizedAction{ID: "authorized-1", State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{IntentID: "intent-1"}}}
|
||||
executor := &knownExecutor{}
|
||||
mux := http.NewServeMux()
|
||||
registerExecutionRoutes(mux, repository, executor, executor, "secret")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/authorized-actions/authorized-1/execute", strings.NewReader(`{"AttemptID":"attempt-1"}`))
|
||||
request.Header.Set("Authorization", "Bearer secret")
|
||||
result := httptest.NewRecorder()
|
||||
mux.ServeHTTP(result, request)
|
||||
if result.Code != http.StatusOK || executor.calls != 1 || repository.attempts[0].State != commitcontract.ActionSucceeded {
|
||||
t.Fatalf("status=%d calls=%d attempts=%#v", result.Code, executor.calls, repository.attempts)
|
||||
}
|
||||
|
||||
repeated := httptest.NewRequest(http.MethodPost, "/v1/internal/authorized-actions/authorized-1/execute", strings.NewReader(`{"AttemptID":"attempt-2"}`))
|
||||
repeated.Header.Set("Authorization", "Bearer secret")
|
||||
repeatedResult := httptest.NewRecorder()
|
||||
mux.ServeHTTP(repeatedResult, repeated)
|
||||
if repeatedResult.Code != http.StatusConflict || executor.calls != 1 {
|
||||
t.Fatalf("repeat status=%d calls=%d", repeatedResult.Code, executor.calls)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/continuityexecutor"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
@@ -52,8 +54,17 @@ func main() {
|
||||
if coreURL == "" {
|
||||
log.Fatal("CORE_RUNTIME_URL is required")
|
||||
}
|
||||
executorToken := os.Getenv("COMMIT_EXECUTOR_TOKEN")
|
||||
if executorToken == "" {
|
||||
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
|
||||
}
|
||||
executor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
registerExecutionRoutes(mux, repository, executor, executor, executorToken)
|
||||
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request authorizationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
@@ -80,7 +91,7 @@ func main() {
|
||||
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
action, err := commitdomain.Authorize(request.Proposal, commitdomain.AuthorizationContext{Binding: binding, Scope: scope, Grant: grant}, authorityValidator{}, request.AuthorizedID)
|
||||
action, err := commitdomain.Authorize(request.Proposal, commitdomain.AuthorizationContext{Binding: binding, Scope: scope, Grant: grant}, authorityValidator{}, request.AuthorizedID, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "authorization denied", http.StatusConflict)
|
||||
return
|
||||
@@ -113,6 +124,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadJSON[T any](ctx context.Context, endpoint string) (T, error) {
|
||||
var value T
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
|
||||
Reference in New Issue
Block a user