- services/attest-reconciler/ as its own go.mod (admin-api internal/ is not importable; shared code gets extracted when a 3rd consumer appears). - Scheduler accepts "@every <dur>" or a bare Go duration; context-cancellation stops the loop cleanly. - Store client defines PendingRateStore + Postgres skeleton for SELECT ... WHERE rate_change_attestation_ref IS NULL. - Orchestrator + HTTPSigner + tlog.HTTPSubmitter mirror admin-api byte-for-byte so the TLog idempotency key collides on resubmits (kein Doppelsignieren). - Prometheus text-format /metrics endpoint, dependency-free: attest_reconciler_dirty_rows_total, _reconciliation_attempts_total, _reconciliation_failures_total, _idempotent_noop_total. - Tests with fake store + fake signer + fake submitter cover: happy path, already-attested rows are NOT re-signed, TLog idempotency across ticks, TLog-down failure path, pre-cancelled ctx. - Top-level README reflects the new service; CI auto-discovers services/*/go.mod so no workflow change needed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
75 lines
2.1 KiB
Go
75 lines
2.1 KiB
Go
package attest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// HTTPSigner calls POST /api/v1/sign/dsse on the Signing Service (ADR-0005).
|
|
// Wire shape matches admin-api/internal/attest.HTTPSigner so the two services
|
|
// are drop-in interchangeable against the same Signing Service deployment.
|
|
type HTTPSigner struct {
|
|
BaseURL string
|
|
Client *http.Client
|
|
TokenSource TokenSource
|
|
}
|
|
|
|
type signRequest struct {
|
|
KeyRef string `json:"key_ref"`
|
|
Statement Statement `json:"statement"`
|
|
}
|
|
|
|
type signResponse struct {
|
|
Envelope Envelope `json:"envelope"`
|
|
ContentHash string `json:"content_hash"`
|
|
SignedAt time.Time `json:"signed_at"`
|
|
}
|
|
|
|
func (s HTTPSigner) SignDSSE(ctx context.Context, keyRef string, stmt Statement) (Envelope, string, error) {
|
|
if s.BaseURL == "" {
|
|
return Envelope{}, "", errors.New("attest.HTTPSigner: BaseURL is empty")
|
|
}
|
|
cli := s.Client
|
|
if cli == nil {
|
|
cli = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt})
|
|
if err != nil {
|
|
return Envelope{}, "", fmt.Errorf("marshal: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/api/v1/sign/dsse", bytes.NewReader(body))
|
|
if err != nil {
|
|
return Envelope{}, "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if s.TokenSource != nil {
|
|
tok, err := s.TokenSource.Token(ctx)
|
|
if err != nil {
|
|
return Envelope{}, "", fmt.Errorf("token: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
resp, err := cli.Do(req)
|
|
if err != nil {
|
|
return Envelope{}, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Envelope{}, "", fmt.Errorf("signer: status %d: %s", resp.StatusCode, string(raw))
|
|
}
|
|
var out signResponse
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return Envelope{}, "", fmt.Errorf("decode: %w", err)
|
|
}
|
|
if out.ContentHash == "" {
|
|
return Envelope{}, "", errors.New("signer: response missing content_hash")
|
|
}
|
|
return out.Envelope, out.ContentHash, nil
|
|
}
|