feat: add execution history queries and preserve bounded JSON payloads
This commit is contained in:
18
internal/jsonwire/marshal.go
Normal file
18
internal/jsonwire/marshal.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Package jsonwire 统一 JSON 传输与回执编码;响应以 application/json 提供,不嵌入 HTML。
|
||||
package jsonwire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Marshal 保留 RawMessage 数字文本,避免 HTML 转义把合法载荷放大到传输上限之外。
|
||||
func Marshal(value any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimSuffix(buffer.Bytes(), []byte{'\n'}), nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 证据分页按父尝试、创建时间和唯一标识查找,不扫描整个命名空间的业务正文。
|
||||
ALTER TABLE pulse_attempt_evidence ADD KEY ix_evidence_history(namespace_id,attempt_id,created_at,id) COMMENT '执行证据按尝试和时间稳定分页';
|
||||
224
internal/storage/mysql/task_history.go
Normal file
224
internal/storage/mysql/task_history.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// historyCursor 只定位已授权父资源中的后续位置,不作为身份或授权证明。
|
||||
type historyCursor struct {
|
||||
Version int `json:"v"`
|
||||
Kind string `json:"k"`
|
||||
Namespace core.ID `json:"n"`
|
||||
Task core.ID `json:"t"`
|
||||
Attempt core.ID `json:"a,omitempty"`
|
||||
Epoch uint64 `json:"e,omitempty,string"`
|
||||
Created *time.Time `json:"c,omitempty"`
|
||||
ID core.ID `json:"i,omitempty"`
|
||||
}
|
||||
|
||||
func decodeHistoryCursor(value string, ns, task, attempt core.ID) (historyCursor, error) {
|
||||
var c historyCursor
|
||||
raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
if err != nil || len(raw) > 768 {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil || fields == nil {
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
for k := range fields {
|
||||
switch k {
|
||||
case "v", "k", "n", "t", "a", "e", "c", "i":
|
||||
default:
|
||||
return c, fault.Invalid("invalid history cursor")
|
||||
}
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt {
|
||||
return c, fault.Invalid("cursor does not match this history query")
|
||||
}
|
||||
if attempt == "" {
|
||||
if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil {
|
||||
return c, fault.Invalid("invalid attempts cursor")
|
||||
}
|
||||
} else {
|
||||
if c.Kind != "evidence" || c.Epoch != 0 || c.ID.Validate() != nil || c.Created == nil || c.Created.Year() < 2000 || c.Created.Year() > 9998 || c.Created.Nanosecond()%1000 != 0 {
|
||||
return c, fault.Invalid("invalid evidence cursor")
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func encodeHistoryCursor(c historyCursor) (string, error) {
|
||||
raw, err := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(raw), err
|
||||
}
|
||||
func authorizeHistoryTask(ctx context.Context, tx *sql.Tx, ns, task core.ID, p auth.Permissions) error {
|
||||
var queue core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT queue_id FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, task).Scan(&queue)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.AllowsQueue(queue) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func historyAttemptExists(ctx context.Context, tx *sql.Tx, ns, task, attempt core.ID) error {
|
||||
var found core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_attempts WHERE namespace_id=? AND task_id=? AND id=?`, ns, task, attempt).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const attemptHistoryColumns = `a.id,a.task_id,a.epoch,a.state,a.revision,a.lease_until,a.authorized_at,a.ended_at,COALESCE(a.error_code,''),a.created_at,a.updated_at`
|
||||
|
||||
func scanAttemptSummary(row interface{ Scan(...any) error }) (service.AttemptSummary, error) {
|
||||
var a service.AttemptSummary
|
||||
err := row.Scan(&a.ID, &a.TaskID, &a.Epoch, &a.State, &a.Revision, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.CreatedAt, &a.UpdatedAt)
|
||||
return a, err
|
||||
}
|
||||
func (s *TaskStore) ListTaskAttempts(ctx context.Context, t auth.Token, task core.ID, in service.HistoryPageInput) (service.AttemptPage, error) {
|
||||
out := service.AttemptPage{Items: []service.AttemptSummary{}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT ` + attemptHistoryColumns + ` FROM pulse_attempts a WHERE a.namespace_id=? AND a.task_id=?`
|
||||
args := []any{t.NamespaceID, task}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND a.epoch<?`
|
||||
args = append(args, c.Epoch)
|
||||
}
|
||||
q += ` ORDER BY a.epoch DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
a, err := scanAttemptSummary(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "attempts", Namespace: t.NamespaceID, Task: task, Epoch: last.Epoch})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) GetTaskAttempt(ctx context.Context, t auth.Token, task, attempt core.ID) (service.AttemptView, error) {
|
||||
var out service.AttemptView
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
var result []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT `+attemptHistoryColumns+`,a.session_id,w.worker_instance_id,a.result FROM pulse_attempts a JOIN pulse_worker_sessions w ON w.namespace_id=a.namespace_id AND w.id=a.session_id WHERE a.namespace_id=? AND a.task_id=? AND a.id=?`, t.NamespaceID, task, attempt).Scan(&out.ID, &out.TaskID, &out.Epoch, &out.State, &out.Revision, &out.LeaseUntil, &out.AuthorizedAt, &out.EndedAt, &out.ErrorCode, &out.CreatedAt, &out.UpdatedAt, &out.SessionID, &out.WorkerInstanceID, &result)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
out.Result = result
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) ListAttemptEvidence(ctx context.Context, t auth.Token, task, attempt core.ID, in service.HistoryPageInput) (service.EvidencePage, error) {
|
||||
out := service.EvidencePage{Items: []service.EvidenceSummary{}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=?`
|
||||
args := []any{t.NamespaceID, attempt}
|
||||
if in.Cursor != "" {
|
||||
c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (created_at<? OR (created_at=? AND id<?))`
|
||||
args = append(args, *c.Created, *c.Created, c.ID)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC,id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
e := service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"}
|
||||
if err := rows.Scan(&e.ID, &e.AttemptID, &e.Outcome, &e.ErrorCode, &e.Reason, &e.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "evidence", Namespace: t.NamespaceID, Task: task, Attempt: attempt, Created: &last.CreatedAt, ID: last.ID})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) GetAttemptEvidence(ctx context.Context, t auth.Token, task, attempt, evidence core.ID) (service.EvidenceView, error) {
|
||||
out := service.EvidenceView{EvidenceSummary: service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"}}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, task, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
var payload []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at,message_id,payload FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=? AND id=?`, t.NamespaceID, attempt, evidence).Scan(&out.ID, &out.AttemptID, &out.Outcome, &out.ErrorCode, &out.Reason, &out.CreatedAt, &out.MessageID, &payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
out.Payload = payload
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
207
internal/storage/mysql/task_history_integration_test.go
Normal file
207
internal/storage/mysql/task_history_integration_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||
)
|
||||
|
||||
func TestIntegrationTaskHistory(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
other := newExecutionFixture(t, ctx, db)
|
||||
task := f.submit(3)
|
||||
_, first := f.claim()
|
||||
_, err = f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: first, Outcome: "UNKNOWN", ErrorCode: "EARLY_REPORT", Result: json.RawMessage(`{"source":"early"}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.expire(first)
|
||||
if _, err := f.storeA.Recover(ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
|
||||
_, second := f.claim()
|
||||
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := json.RawMessage(`{"value":9007199254740993,"ratio":0.1234567890123456789012345}`)
|
||||
finish := worker.FinishInput{Ref: second, Outcome: "SUCCEEDED", Result: result}
|
||||
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), finish); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
message := executionID(t)
|
||||
finish.Result = json.RawMessage(`{"claimed_value":9007199254740995}`)
|
||||
if _, err := f.a.Finish(ctx, f.token, f.binding, message, finish); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.a.Finish(ctx, f.token, f.binding, message, finish); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同一条 INSERT 中两条证据具有相同微秒时间,验证 ID 是稳定的分页次序补充。
|
||||
e1, e2 := executionID(t), executionID(t)
|
||||
f.exec(`INSERT INTO pulse_attempt_evidence(namespace_id,id,attempt_id,message_id,outcome,payload,request_digest,reason,created_at) VALUES (?,?,?,?,'SUCCEEDED','{"tie":1}',?,'RESULT_CONFLICT',UTC_TIMESTAMP(6)),(?,?,?,?,'UNKNOWN','{"tie":2}',?,'LEASE_LOST',UTC_TIMESTAMP(6))`, f.resources.NamespaceID, e1, second.AttemptID, executionID(t), core.Digest{}, f.resources.NamespaceID, e2, second.AttemptID, executionID(t), core.Digest{})
|
||||
server := httptest.NewServer(httpserver.New("", func(context.Context) error { return nil }, f.tasks).Handler)
|
||||
defer server.Close()
|
||||
call := func(path, token string, status int) json.RawMessage {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != status {
|
||||
t.Fatalf("history HTTP %d wanted %d: %s", resp.StatusCode, status, raw)
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-store" || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("missing history privacy headers")
|
||||
}
|
||||
var envelope struct {
|
||||
RequestID core.ID `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &envelope) != nil || envelope.RequestID.Validate() != nil || resp.Header.Get("X-Request-ID") != string(envelope.RequestID) {
|
||||
t.Fatal("invalid response envelope")
|
||||
}
|
||||
for _, secret := range []string{first.LeaseSecret, second.LeaseSecret, f.hello.InstanceSecret, "token_digest", "secret_digest", "request_digest", "result_digest"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatal("execution history exposed an internal proof")
|
||||
}
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
base := "/v1/tasks/" + string(task.ID) + "/attempts"
|
||||
var page service.AttemptPage
|
||||
if err := json.Unmarshal(call(base+"?limit=1", f.resources.Token, 200), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].ID != second.AttemptID || page.Items[0].Epoch != 2 || page.NextCursor == "" {
|
||||
t.Fatal("wrong first attempt page")
|
||||
}
|
||||
if strings.Contains(string(call(base, f.resources.Token, 200)), `"result"`) {
|
||||
t.Fatal("attempt list included large result")
|
||||
}
|
||||
attemptCursor := page.NextCursor
|
||||
page = service.AttemptPage{}
|
||||
if err := json.Unmarshal(call(base+"?cursor="+url.QueryEscape(attemptCursor), f.resources.Token, 200), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].ID != first.AttemptID || page.Items[0].AuthorizedAt != nil || page.Items[0].EndedAt == nil || page.NextCursor != "" {
|
||||
t.Fatal("wrong expired attempt history")
|
||||
}
|
||||
var detail service.AttemptView
|
||||
if err := json.Unmarshal(call(base+"/"+string(second.AttemptID), f.resources.Token, 200), &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.WorkerInstanceID != f.hello.InstanceID || detail.SessionID != f.binding.SessionID || detail.AuthorizedAt == nil || detail.EndedAt == nil || string(detail.Result) != string(result) {
|
||||
t.Fatal("attempt detail lost owner, times or precise result")
|
||||
}
|
||||
evidenceBase := base + "/" + string(second.AttemptID) + "/evidence"
|
||||
seen := map[core.ID]bool{}
|
||||
cursor := ""
|
||||
var evidenceCursor string
|
||||
for {
|
||||
var ep service.EvidencePage
|
||||
raw := call(evidenceBase+"?limit=1&cursor="+url.QueryEscape(cursor), f.resources.Token, 200)
|
||||
if json.Unmarshal(raw, &ep) != nil || len(ep.Items) != 1 {
|
||||
t.Fatal("invalid evidence page")
|
||||
}
|
||||
if strings.Contains(string(raw), `"payload"`) {
|
||||
t.Fatal("evidence page included business payload")
|
||||
}
|
||||
e := ep.Items[0]
|
||||
if seen[e.ID] || e.Disposition != "EVIDENCE_ONLY" || e.TaskID != task.ID || e.AttemptID != second.AttemptID {
|
||||
t.Fatal("evidence pagination duplicated or crossed identity")
|
||||
}
|
||||
seen[e.ID] = true
|
||||
var ev service.EvidenceView
|
||||
if json.Unmarshal(call(evidenceBase+"/"+string(e.ID), f.resources.Token, 200), &ev) != nil || !json.Valid(ev.Payload) {
|
||||
t.Fatal("invalid evidence detail")
|
||||
}
|
||||
if ev.MessageID == message && string(ev.Payload) != string(finish.Result) {
|
||||
t.Fatal("evidence numeric precision lost")
|
||||
}
|
||||
if ep.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = ep.NextCursor
|
||||
evidenceCursor = cursor
|
||||
}
|
||||
if len(seen) != 3 || !seen[e1] || !seen[e2] {
|
||||
t.Fatal("same-time pagination or duplicate evidence identity failed")
|
||||
}
|
||||
earlyBase := base + "/" + string(first.AttemptID) + "/evidence"
|
||||
var early service.EvidencePage
|
||||
if json.Unmarshal(call(earlyBase, f.resources.Token, 200), &early) != nil || len(early.Items) != 1 || early.Items[0].Reason != "NOT_STARTED" {
|
||||
t.Fatal("early report is missing")
|
||||
}
|
||||
call(earlyBase+"/"+string(e1), f.resources.Token, 404)
|
||||
call(earlyBase+"?cursor="+url.QueryEscape(evidenceCursor), f.resources.Token, 400)
|
||||
call(base+"?cursor="+url.QueryEscape(evidenceCursor), f.resources.Token, 400)
|
||||
call(evidenceBase+"?cursor="+url.QueryEscape(attemptCursor), f.resources.Token, 400)
|
||||
newTask := f.submit(1)
|
||||
emptyBase := "/v1/tasks/" + string(newTask.ID) + "/attempts"
|
||||
var empty service.AttemptPage
|
||||
if json.Unmarshal(call(emptyBase, f.resources.Token, 200), &empty) != nil || empty.Items == nil || len(empty.Items) != 0 {
|
||||
t.Fatal("empty history must be []")
|
||||
}
|
||||
call(emptyBase+"/"+string(second.AttemptID), f.resources.Token, 404)
|
||||
call(emptyBase+"?cursor="+url.QueryEscape(attemptCursor), f.resources.Token, 400)
|
||||
for _, path := range []string{base, base + "/" + string(second.AttemptID), evidenceBase, evidenceBase + "/" + string(e1)} {
|
||||
call(path, other.resources.Token, 404)
|
||||
}
|
||||
for _, query := range []string{"limit=0", "limit=101", "limit=1&limit=2", "cursor=%zz", "cursor=invalid", "unknown=1", "limit=1;limit=2", "cursor=" + strings.Repeat("a", 1025)} {
|
||||
call(base+"?"+query, f.resources.Token, 400)
|
||||
}
|
||||
call(base+"/"+string(second.AttemptID)+"?cursor=x", f.resources.Token, 400)
|
||||
call("/v1/tasks/invalid/attempts", f.resources.Token, 400)
|
||||
permissions, _ := json.Marshal(auth.Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(executionID(t))}})
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(permissions), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
for _, path := range []string{base, base + "/" + string(second.AttemptID), evidenceBase, evidenceBase + "/" + string(e1)} {
|
||||
call(path, f.resources.Token, 404)
|
||||
}
|
||||
permissions, _ = json.Marshal(auth.Permissions{Actions: []string{"queues:read"}, Queues: []string{"*"}})
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(permissions), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
call(base, f.resources.Token, 403)
|
||||
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
call(base, f.resources.Token, 401)
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='pulse_attempt_evidence' AND index_name='ix_evidence_history'`).Scan(&count); err != nil || count != 4 {
|
||||
t.Fatal("history index migration missing")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
@@ -108,7 +109,7 @@ func reserveIdentity(ctx context.Context, tx *sql.Tx, ns core.ID, operation, sco
|
||||
return storedID, response, nil
|
||||
}
|
||||
func saveIdentity(ctx context.Context, tx *sql.Tx, ns, id core.ID, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
raw, err := jsonwire.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
65
internal/transport/httpserver/history.go
Normal file
65
internal/transport/httpserver/history.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
func historyQuery(r *http.Request, paged bool) (service.HistoryPageInput, error) {
|
||||
var in service.HistoryPageInput
|
||||
q, err := url.ParseQuery(r.URL.RawQuery)
|
||||
if err != nil {
|
||||
return in, fault.Invalid("invalid query encoding")
|
||||
}
|
||||
for key, values := range q {
|
||||
if !paged || len(values) != 1 || (key != "limit" && key != "cursor") {
|
||||
return in, fault.Invalid("invalid history query parameter")
|
||||
}
|
||||
}
|
||||
if q.Has("limit") {
|
||||
in.Limit, err = strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || in.Limit < 1 {
|
||||
return in, fault.Invalid("invalid history limit")
|
||||
}
|
||||
}
|
||||
in.Cursor = q.Get("cursor")
|
||||
return in, nil
|
||||
}
|
||||
func registerHistory(mux *http.ServeMux, s *service.Service) {
|
||||
mux.HandleFunc("GET /v1/tasks/{task}/attempts", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
in, err := historyQuery(r, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.ListTaskAttempts(r.Context(), t, core.ID(r.PathValue("task")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/tasks/{task}/attempts/{attempt}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.GetTaskAttempt(r.Context(), t, core.ID(r.PathValue("task")), core.ID(r.PathValue("attempt")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/tasks/{task}/attempts/{attempt}/evidence", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
in, err := historyQuery(r, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.ListAttemptEvidence(r.Context(), t, core.ID(r.PathValue("task")), core.ID(r.PathValue("attempt")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/tasks/{task}/attempts/{attempt}/evidence/{evidence}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.GetAttemptEvidence(r.Context(), t, core.ID(r.PathValue("task")), core.ID(r.PathValue("attempt")), core.ID(r.PathValue("evidence")))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
||||
})
|
||||
if len(tasks) > 0 && tasks[0] != nil {
|
||||
registerTasks(mux, tasks[0])
|
||||
registerHistory(mux, tasks[0])
|
||||
}
|
||||
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
@@ -149,7 +150,7 @@ func wrap(next endpoint) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
raw, err := json.Marshal(body)
|
||||
raw, err := jsonwire.Marshal(body)
|
||||
if err != nil {
|
||||
status = 503
|
||||
raw = []byte(`{"error":{"code":"UNAVAILABLE","message":"operation unavailable"}}`)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||
@@ -154,7 +155,7 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_ = writeWorkerFrame(ctx, conn, workerFrame{Version: "1", Type: "error", MessageID: requestID, ReplyTo: hello.MessageID, Error: publicWorkerError(err)})
|
||||
return
|
||||
}
|
||||
welcomeRaw, err := json.Marshal(welcome)
|
||||
welcomeRaw, err := jsonwire.Marshal(welcome)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -219,7 +220,7 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(frame)
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -320,7 +321,7 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
reply.Type = "error"
|
||||
reply.Error = publicWorkerError(commandErr)
|
||||
} else {
|
||||
reply.Body, commandErr = json.Marshal(body)
|
||||
reply.Body, commandErr = jsonwire.Marshal(body)
|
||||
if commandErr != nil {
|
||||
return
|
||||
}
|
||||
@@ -354,7 +355,7 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if assignment == nil {
|
||||
break
|
||||
}
|
||||
raw, err := json.Marshal(assignment)
|
||||
raw, err := jsonwire.Marshal(assignment)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -374,7 +375,7 @@ func publicWorkerError(err error) *fault.Error {
|
||||
return fault.Unavailable
|
||||
}
|
||||
func writeWorkerFrame(ctx context.Context, conn *websocket.Conn, frame workerFrame) error {
|
||||
raw, err := json.Marshal(frame)
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user