feat: persist and dispatch client command input
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/rvbox/rvbox/internal/agentproto"
|
||||
"github.com/rvbox/rvbox/internal/client/spool"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// PersistDispatch admits a server dispatch to the durable spool before any
|
||||
@@ -30,5 +31,9 @@ func PersistDispatch(ctx context.Context, store *spool.Store, session Session, d
|
||||
if immutable == [32]byte{} {
|
||||
return spool.Acceptance{}, errors.New("empty dispatch immutable hash")
|
||||
}
|
||||
return store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: immutable, Revision: dispatch.GetCommandRevision(), Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED)}, now)
|
||||
spec, err := proto.MarshalOptions{Deterministic: true}.Marshal(dispatch.GetSpec())
|
||||
if err != nil {
|
||||
return spool.Acceptance{}, err
|
||||
}
|
||||
return store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: immutable, Revision: dispatch.GetCommandRevision(), Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: spec}, now)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
@@ -92,7 +93,8 @@ func TestPersistDispatchUsesImmutableHash_HP_DISPATCH_04(t *testing.T) {
|
||||
defer store.Close()
|
||||
dispatch := &rvboxv1.CommandDispatch{IssueUuid: "019c46f1-1d02-7000-8000-000000000064", CommandRevision: 1, TargetSessionGeneration: 7, IssueTime: timestamppb.Now(), ImmutableRequestSha256: []byte("12345678901234567890123456789012"), Spec: &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_POWERSHELL, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "Write-Output ok"}}}
|
||||
accepted, err := PersistDispatch(context.Background(), store, Session{Generation: 7}, dispatch, time.Now(), agentproto.DefaultLimits())
|
||||
if err != nil || accepted.Duplicate {
|
||||
encodedSpec, marshalErr := proto.MarshalOptions{Deterministic: true}.Marshal(dispatch.GetSpec())
|
||||
if err != nil || marshalErr != nil || accepted.Duplicate || !bytes.Equal(accepted.Command.ExecutionSpec, encodedSpec) {
|
||||
t.Fatalf("PersistDispatch = %#v, %v", accepted, err)
|
||||
}
|
||||
duplicate, err := PersistDispatch(context.Background(), store, Session{Generation: 7}, dispatch, time.Now(), agentproto.DefaultLimits())
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package spool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
@@ -16,6 +19,10 @@ type Command struct {
|
||||
Revision uint64
|
||||
Phase uint32
|
||||
Terminal bool
|
||||
// ExecutionSpec is the deterministic protobuf payload received from the
|
||||
// server. It is retained as raw protobuf bytes so the runtime can validate
|
||||
// and execute exactly the admitted request after a restart.
|
||||
ExecutionSpec []byte
|
||||
}
|
||||
|
||||
type Acceptance struct {
|
||||
@@ -55,12 +62,28 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
|
||||
if !validUUID(command.IssueUUID) || command.Revision == 0 || command.Phase == 0 || command.Phase > 11 || command.Terminal != isTerminalPhase(command.Phase) || acceptedAt.IsZero() {
|
||||
return Acceptance{}, errors.New("invalid command acceptance")
|
||||
}
|
||||
var storedSpec []byte
|
||||
var specCharge uint64
|
||||
if len(command.ExecutionSpec) > 0 {
|
||||
if uint64(len(command.ExecutionSpec)) > store.maxExecutionSpecBytes {
|
||||
return Acceptance{}, errors.New("execution specification exceeds client limit")
|
||||
}
|
||||
var err error
|
||||
storedSpec, err = compressExecutionSpec(command.ExecutionSpec)
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
specCharge, err = EstimateCharge(ChargeInput{EncodedBytes: uint64(len(storedSpec)), SQLiteRows: 1, IndexEntries: 1})
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
existing, found, err := commandByUUID(ctx, tx, command.IssueUUID)
|
||||
existing, found, err := commandByUUID(ctx, tx, command.IssueUUID, store.maxExecutionSpecBytes)
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
@@ -68,6 +91,9 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
|
||||
if existing.ImmutableSHA256 != command.ImmutableSHA256 {
|
||||
return Acceptance{}, ErrCommandConflict
|
||||
}
|
||||
if len(command.ExecutionSpec) > 0 && !bytes.Equal(existing.ExecutionSpec, command.ExecutionSpec) {
|
||||
return Acceptance{}, ErrCommandConflict
|
||||
}
|
||||
return Acceptance{Duplicate: true, Command: existing}, nil
|
||||
}
|
||||
var tombstoneHash []byte
|
||||
@@ -81,7 +107,7 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
|
||||
if err != sql.ErrNoRows {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
charge, err := EstimateCharge(ChargeInput{SQLiteRows: 1, IndexEntries: 2})
|
||||
baseCharge, err := EstimateCharge(ChargeInput{SQLiteRows: 1, IndexEntries: 2})
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
@@ -89,23 +115,51 @@ func (store *Store) AcceptCommand(ctx context.Context, command Command, accepted
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
charge, overflow := addChecked(baseCharge, specCharge)
|
||||
if overflow {
|
||||
return Acceptance{}, &CapacityError{Tier: CapacityTierHardMaximum, Requested: ^uint64(0), Available: store.quotaLimits.HardAllocationBytes}
|
||||
}
|
||||
decision, err := CheckReservation(store.quotaLimits, ReservationState{ClientTotalCharged: clientTotal, CloseoutRemaining: store.quotaLimits.CloseoutReserveBytes}, ReservationRequest{ChargedBytes: charge})
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO commands(issue_uuid, immutable_sha256, command_revision, phase, terminal, base_charged_bytes, total_charged_bytes, closeout_remaining_bytes, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.IssueUUID[:], command.ImmutableSHA256[:], command.Revision, command.Phase, boolInt(command.Terminal), charge, decision.CommandTotalCharged, decision.CloseoutRemaining, acceptedAt.UnixNano())
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO commands(issue_uuid, immutable_sha256, command_revision, phase, terminal, base_charged_bytes, total_charged_bytes, closeout_remaining_bytes, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.IssueUUID[:], command.ImmutableSHA256[:], command.Revision, command.Phase, boolInt(command.Terminal), baseCharge, decision.CommandTotalCharged, decision.CloseoutRemaining, acceptedAt.UnixNano())
|
||||
if err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
if err := updateClientTotalCharge(ctx, tx, decision.ClientTotalCharged); err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
if len(storedSpec) > 0 {
|
||||
digest := immutableDigest(storedSpec)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO command_specs(issue_uuid, raw_bytes, stored_bytes, compression, payload, payload_sha256, charged_bytes) VALUES (?, ?, ?, 2, ?, ?, ?)`, command.IssueUUID[:], len(command.ExecutionSpec), len(storedSpec), storedSpec, digest[:], specCharge); err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Acceptance{}, err
|
||||
}
|
||||
return Acceptance{Command: command}, nil
|
||||
}
|
||||
|
||||
// GetCommand returns the durable command metadata and its immutable execution
|
||||
// specification. The returned protobuf bytes are a copy and can be decoded or
|
||||
// modified by the runtime without changing the spool's source of truth.
|
||||
func (store *Store) GetCommand(ctx context.Context, issueUUID domain.UUID) (Command, error) {
|
||||
if !validUUID(issueUUID) {
|
||||
return Command{}, ErrUnknownCommand
|
||||
}
|
||||
command, found, err := commandByUUID(ctx, store.db, issueUUID, store.maxExecutionSpecBytes)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
if !found {
|
||||
return Command{}, ErrUnknownCommand
|
||||
}
|
||||
command.ExecutionSpec = bytes.Clone(command.ExecutionSpec)
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// AppendEvent assigns command-local order only. EventSeq remains zero until
|
||||
// AssignSendWindow makes the durable event eligible for a wire send.
|
||||
func (store *Store) AppendEvent(ctx context.Context, issueUUID domain.UUID, input EventInput) (Event, error) {
|
||||
@@ -448,11 +502,15 @@ func eventsBySequence(ctx context.Context, query queryer, issueUUID domain.UUID,
|
||||
|
||||
func commandByUUID(ctx context.Context, query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}, issueUUID domain.UUID) (Command, bool, error) {
|
||||
}, issueUUID domain.UUID, maxSpecBytes uint64) (Command, bool, error) {
|
||||
var command Command
|
||||
var hash []byte
|
||||
var terminal int
|
||||
err := query.QueryRowContext(ctx, `SELECT immutable_sha256, command_revision, phase, terminal FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&hash, &command.Revision, &command.Phase, &terminal)
|
||||
var stored, digest []byte
|
||||
var rawBytes, storedBytes, compression sql.NullInt64
|
||||
err := query.QueryRowContext(ctx, `SELECT c.immutable_sha256, c.command_revision, c.phase, c.terminal,
|
||||
s.raw_bytes, s.stored_bytes, s.compression, s.payload, s.payload_sha256
|
||||
FROM commands c LEFT JOIN command_specs s ON s.issue_uuid = c.issue_uuid WHERE c.issue_uuid = ?`, issueUUID[:]).Scan(&hash, &command.Revision, &command.Phase, &terminal, &rawBytes, &storedBytes, &compression, &stored, &digest)
|
||||
if err == sql.ErrNoRows {
|
||||
return Command{}, false, nil
|
||||
}
|
||||
@@ -465,9 +523,45 @@ func commandByUUID(ctx context.Context, query interface {
|
||||
copy(command.ImmutableSHA256[:], hash)
|
||||
command.IssueUUID = issueUUID
|
||||
command.Terminal = terminal != 0
|
||||
if rawBytes.Valid {
|
||||
computed := immutableDigest(stored)
|
||||
if rawBytes.Int64 <= 0 || storedBytes.Int64 <= 0 || compression.Int64 != 2 || len(digest) != sha256.Size || uint64(rawBytes.Int64) > maxSpecBytes || uint64(storedBytes.Int64) != uint64(len(stored)) || !bytes.Equal(computed[:], digest) {
|
||||
return Command{}, false, fmt.Errorf("invalid stored execution specification")
|
||||
}
|
||||
decoded, err := decompressExecutionSpec(stored, uint64(rawBytes.Int64), maxSpecBytes)
|
||||
if err != nil {
|
||||
return Command{}, false, err
|
||||
}
|
||||
command.ExecutionSpec = decoded
|
||||
}
|
||||
return command, true, nil
|
||||
}
|
||||
|
||||
func compressExecutionSpec(raw []byte) ([]byte, error) {
|
||||
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer encoder.Close()
|
||||
return encoder.EncodeAll(raw, nil), nil
|
||||
}
|
||||
|
||||
func decompressExecutionSpec(stored []byte, rawBytes, maximum uint64) ([]byte, error) {
|
||||
if rawBytes == 0 || rawBytes > maximum {
|
||||
return nil, errors.New("invalid stored execution specification size")
|
||||
}
|
||||
decoder, err := zstd.NewReader(bytes.NewReader(stored), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(maximum+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid stored execution specification")
|
||||
}
|
||||
defer decoder.Close()
|
||||
decoded, err := decoder.DecodeAll(stored, nil)
|
||||
if err != nil || uint64(len(decoded)) != rawBytes || uint64(len(decoded)) > maximum {
|
||||
return nil, errors.New("invalid stored execution specification")
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func tombstonesToTrim(ctx context.Context, tx *sql.Tx, limit uint64) (uint64, error) {
|
||||
var count uint64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM command_tombstones`).Scan(&count); err != nil {
|
||||
|
||||
@@ -13,7 +13,10 @@ type migration struct {
|
||||
sql string
|
||||
}
|
||||
|
||||
var migrations = []migration{{version: 1, sql: schemaV1}}
|
||||
var migrations = []migration{
|
||||
{version: 1, sql: schemaV1},
|
||||
{version: 2, sql: schemaV2},
|
||||
}
|
||||
|
||||
func applyMigrations(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -113,3 +116,19 @@ CREATE TABLE command_tombstones (
|
||||
) STRICT;
|
||||
CREATE INDEX tombstones_fifo ON command_tombstones(acknowledged_at, issue_uuid);
|
||||
`
|
||||
|
||||
// schemaV2 adds the immutable execution specification retained with every
|
||||
// accepted dispatch. Keeping it in its own table lets old commands created by
|
||||
// early development builds (which have no specification) remain readable and
|
||||
// makes the payload's quota charge independently auditable.
|
||||
const schemaV2 = `
|
||||
CREATE TABLE command_specs (
|
||||
issue_uuid BLOB PRIMARY KEY REFERENCES commands(issue_uuid) ON DELETE CASCADE CHECK(length(issue_uuid) = 16),
|
||||
raw_bytes INTEGER NOT NULL CHECK(raw_bytes > 0),
|
||||
stored_bytes INTEGER NOT NULL CHECK(stored_bytes > 0),
|
||||
compression INTEGER NOT NULL CHECK(compression = 2),
|
||||
payload BLOB NOT NULL,
|
||||
payload_sha256 BLOB NOT NULL CHECK(length(payload_sha256) = 32),
|
||||
charged_bytes INTEGER NOT NULL CHECK(charged_bytes > 0)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
`
|
||||
|
||||
@@ -19,6 +19,7 @@ var (
|
||||
type RecoveryReport struct {
|
||||
EventsChecked uint64
|
||||
ScriptsChecked uint64
|
||||
SpecsChecked uint64
|
||||
}
|
||||
|
||||
// Check verifies state that SQLite's own structural check cannot express. The
|
||||
@@ -34,7 +35,7 @@ func (store *Store) Check(ctx context.Context) (RecoveryReport, error) {
|
||||
if err := checkQuotaCounters(ctx, store.db); err != nil {
|
||||
return RecoveryReport{}, err
|
||||
}
|
||||
return checkPayloads(ctx, store.db, store.maxScriptBytes)
|
||||
return checkPayloads(ctx, store.db, store.maxScriptBytes, store.maxExecutionSpecBytes)
|
||||
}
|
||||
|
||||
func quickCheck(ctx context.Context, database *sql.DB) error {
|
||||
@@ -65,6 +66,7 @@ func checkQuotaCounters(ctx context.Context, database *sql.DB) error {
|
||||
err := database.QueryRowContext(ctx, `SELECT count(*) FROM commands AS c
|
||||
WHERE c.total_charged_bytes != c.base_charged_bytes +
|
||||
COALESCE((SELECT sum(charged_bytes) FROM events WHERE issue_uuid = c.issue_uuid), 0) +
|
||||
COALESCE((SELECT charged_bytes FROM command_specs WHERE issue_uuid = c.issue_uuid), 0) +
|
||||
COALESCE((SELECT charged_bytes FROM scripts WHERE issue_uuid = c.issue_uuid), 0)
|
||||
OR c.output_charged_bytes !=
|
||||
COALESCE((SELECT sum(charged_bytes) FROM events WHERE issue_uuid = c.issue_uuid AND output = 1), 0)`).Scan(&invalid)
|
||||
@@ -81,7 +83,7 @@ OR c.output_charged_bytes !=
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes uint64) (RecoveryReport, error) {
|
||||
func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes, maxExecutionSpecBytes uint64) (RecoveryReport, error) {
|
||||
var report RecoveryReport
|
||||
rows, err := database.QueryContext(ctx, `SELECT issue_uuid, event_kind, raw_bytes, payload, payload_sha256 FROM events ORDER BY issue_uuid, local_ordinal`)
|
||||
if err != nil {
|
||||
@@ -119,6 +121,35 @@ func checkPayloads(ctx context.Context, database *sql.DB, maxScriptBytes uint64)
|
||||
if err := rows.Close(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
specs, err := database.QueryContext(ctx, `SELECT issue_uuid, raw_bytes, stored_bytes, compression, payload, payload_sha256 FROM command_specs ORDER BY issue_uuid`)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
for specs.Next() {
|
||||
var owner, payload, digest []byte
|
||||
var rawBytes, storedBytes uint64
|
||||
var compression uint32
|
||||
if err := specs.Scan(&owner, &rawBytes, &storedBytes, &compression, &payload, &digest); err != nil {
|
||||
_ = specs.Close()
|
||||
return report, err
|
||||
}
|
||||
computed := immutableDigest(payload)
|
||||
if _, valid := copiedUUID(owner); !valid || compression != 2 || rawBytes == 0 || rawBytes > maxExecutionSpecBytes || storedBytes != uint64(len(payload)) || len(digest) != 32 || !bytes.Equal(computed[:], digest) {
|
||||
_ = specs.Close()
|
||||
return report, ErrStoredPayloadChecksum
|
||||
}
|
||||
if _, err := decompressExecutionSpec(payload, rawBytes, maxExecutionSpecBytes); err != nil {
|
||||
_ = specs.Close()
|
||||
return report, ErrStoredPayloadChecksum
|
||||
}
|
||||
report.SpecsChecked++
|
||||
}
|
||||
if err := specs.Close(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := specs.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
scripts, err := database.QueryContext(ctx, `SELECT issue_uuid, received_raw_bytes, stored_bytes, stored_data FROM scripts ORDER BY issue_uuid`)
|
||||
if err != nil {
|
||||
return report, err
|
||||
|
||||
@@ -37,18 +37,23 @@ type Options struct {
|
||||
BusyTimeout time.Duration
|
||||
TombstoneLimit uint64
|
||||
QuotaLimits QuotaLimits
|
||||
MaxScriptBytes uint64
|
||||
// MaxExecutionSpecBytes bounds the decompressed protobuf retained for an
|
||||
// accepted command. It is separate from MaxScriptBytes because a script
|
||||
// body is uploaded in a different table and lifecycle.
|
||||
MaxExecutionSpecBytes uint64
|
||||
MaxScriptBytes uint64
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
unlock func() error
|
||||
dataDir string
|
||||
identity domain.UUID
|
||||
tombstoneLimit uint64
|
||||
quotaLimits QuotaLimits
|
||||
maxScriptBytes uint64
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
unlock func() error
|
||||
dataDir string
|
||||
identity domain.UUID
|
||||
tombstoneLimit uint64
|
||||
quotaLimits QuotaLimits
|
||||
maxScriptBytes uint64
|
||||
maxExecutionSpecBytes uint64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Open recovers an existing spool or creates an empty one. The caller must
|
||||
@@ -76,6 +81,15 @@ func Open(ctx context.Context, options Options) (*Store, error) {
|
||||
options.MaxScriptBytes = options.QuotaLimits.CommandTotalBytes
|
||||
}
|
||||
}
|
||||
if options.MaxExecutionSpecBytes == 0 {
|
||||
options.MaxExecutionSpecBytes = 768 << 10
|
||||
if options.MaxExecutionSpecBytes > options.QuotaLimits.CommandTotalBytes {
|
||||
options.MaxExecutionSpecBytes = options.QuotaLimits.CommandTotalBytes
|
||||
}
|
||||
}
|
||||
if options.MaxExecutionSpecBytes > options.QuotaLimits.CommandTotalBytes {
|
||||
return nil, errors.New("execution specification maximum exceeds client command quota")
|
||||
}
|
||||
if options.MaxScriptBytes > options.QuotaLimits.CommandTotalBytes {
|
||||
return nil, errors.New("script maximum exceeds client command quota")
|
||||
}
|
||||
@@ -110,7 +124,7 @@ func Open(ctx context.Context, options Options) (*Store, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit, quotaLimits: options.QuotaLimits, maxScriptBytes: options.MaxScriptBytes}
|
||||
store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit, quotaLimits: options.QuotaLimits, maxScriptBytes: options.MaxScriptBytes, maxExecutionSpecBytes: options.MaxExecutionSpecBytes}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, fmt.Errorf("open client spool SQLite: %w", err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package spool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
@@ -12,6 +13,69 @@ import (
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
directory := filepath.Join(t.TempDir(), "spool")
|
||||
store := openTestStore(t, ctx, directory, DefaultTombstoneLimit)
|
||||
issue := testUUID(t, "019c46f1-1d02-7000-8000-000000000002")
|
||||
spec := []byte("deterministic execution specification")
|
||||
command := testCommand(issue, []byte("spec-hash"))
|
||||
command.ExecutionSpec = spec
|
||||
acceptedAt := time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)
|
||||
accepted, err := store.AcceptCommand(ctx, command, acceptedAt)
|
||||
if err != nil || accepted.Duplicate {
|
||||
t.Fatalf("spec acceptance = %#v, %v", accepted, err)
|
||||
}
|
||||
if !bytes.Equal(accepted.Command.ExecutionSpec, spec) {
|
||||
t.Fatalf("accepted spec = %q, want %q", accepted.Command.ExecutionSpec, spec)
|
||||
}
|
||||
var base, total, specCharge uint64
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT base_charged_bytes, total_charged_bytes FROM commands WHERE issue_uuid = ?`, issue[:]).Scan(&base, &total); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT charged_bytes FROM command_specs WHERE issue_uuid = ?`, issue[:]).Scan(&specCharge); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if specCharge == 0 || total != base+specCharge {
|
||||
t.Fatalf("spec charge accounting = base=%d total=%d spec=%d", base, total, specCharge)
|
||||
}
|
||||
duplicate, err := store.AcceptCommand(ctx, command, acceptedAt.Add(time.Second))
|
||||
if err != nil || !duplicate.Duplicate || !bytes.Equal(duplicate.Command.ExecutionSpec, spec) {
|
||||
t.Fatalf("spec replay = %#v, %v", duplicate, err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store = openTestStore(t, ctx, directory, DefaultTombstoneLimit)
|
||||
reopened, err := store.AcceptCommand(ctx, command, acceptedAt.Add(2*time.Second))
|
||||
if err != nil || !reopened.Duplicate || !bytes.Equal(reopened.Command.ExecutionSpec, spec) {
|
||||
t.Fatalf("spec replay after reopen = %#v, %v", reopened, err)
|
||||
}
|
||||
report, err := store.Check(ctx)
|
||||
if err != nil || report.SpecsChecked != 1 {
|
||||
t.Fatalf("spec recovery report = %#v, %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionSpecCorruptionMarksSpoolDirty_BH_DISPATCH_07(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t, ctx, filepath.Join(t.TempDir(), "spool"), DefaultTombstoneLimit)
|
||||
issue := testUUID(t, "019c46f1-1d02-7000-8000-000000000003")
|
||||
command := testCommand(issue, []byte("spec-corrupt"))
|
||||
command.ExecutionSpec = []byte("payload")
|
||||
if _, err := store.AcceptCommand(ctx, command, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE command_specs SET payload = ? WHERE issue_uuid = ?`, []byte("tampered"), issue[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Check(ctx); !errors.Is(err, ErrStoredPayloadChecksum) {
|
||||
t.Fatalf("corrupt execution spec Check error = %v, want ErrStoredPayloadChecksum", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDurableIdentityAndSingleInstance_HP_CLIENT_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package supervisor defines the narrow process-control seam shared by the
|
||||
// client runtime and platform implementations. It intentionally contains no
|
||||
// operating-system handles or process APIs.
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStartSpec = errors.New("invalid supervisor start specification")
|
||||
ErrUnsupported = errors.New("supervisor operation is unsupported")
|
||||
)
|
||||
|
||||
type SignalKind uint8
|
||||
|
||||
const (
|
||||
SignalTerm SignalKind = iota + 1
|
||||
SignalKill
|
||||
)
|
||||
|
||||
// StartSpec is already validated at the protocol boundary. The supervisor
|
||||
// still checks the identity/revision/source invariants because it is a second
|
||||
// durability boundary and may be called after a restart.
|
||||
type StartSpec struct {
|
||||
IssueUUID domain.UUID
|
||||
CommandRevision uint64
|
||||
Execution *rvboxv1.ExecutionSpec
|
||||
WorkingDirectory string
|
||||
Environment map[string]string
|
||||
ExecutionProfiles []string
|
||||
}
|
||||
|
||||
func (spec StartSpec) Validate() error {
|
||||
if _, err := domain.ParseUUIDv7(spec.IssueUUID.String()); err != nil || spec.CommandRevision == 0 || spec.Execution == nil || spec.Execution.Source == nil {
|
||||
return ErrInvalidStartSpec
|
||||
}
|
||||
if spec.WorkingDirectory == "" {
|
||||
return ErrInvalidStartSpec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveIdentity is immutable process evidence captured before launch.
|
||||
// Empty user/session fields mean a Session 0 service context.
|
||||
type EffectiveIdentity struct {
|
||||
Context string
|
||||
SessionID uint32
|
||||
UserSID string
|
||||
LogonSID string
|
||||
Elevated bool
|
||||
Integrity string
|
||||
}
|
||||
|
||||
type Process interface {
|
||||
IssueUUID() domain.UUID
|
||||
Identity() EffectiveIdentity
|
||||
Wait(context.Context) (ExitStatus, error)
|
||||
WriteStdin(context.Context, []byte, bool) error
|
||||
CloseStdin(context.Context) error
|
||||
}
|
||||
|
||||
type ExitStatus struct {
|
||||
Code int32
|
||||
Signaled bool
|
||||
Signal SignalKind
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
OutputDrained bool
|
||||
Output bool
|
||||
}
|
||||
|
||||
type SignalOutcome struct {
|
||||
Delivered bool
|
||||
Escalated bool
|
||||
Detail string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type ResourceSnapshot struct {
|
||||
CPUTime time.Duration
|
||||
ResidentBytes uint64
|
||||
IOReadBytes uint64
|
||||
IOWriteBytes uint64
|
||||
ProcessCount uint64
|
||||
ObservedAt time.Time
|
||||
Complete bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Supervisor interface {
|
||||
Start(context.Context, StartSpec) (Process, error)
|
||||
Signal(context.Context, Process, SignalKind) (SignalOutcome, error)
|
||||
Snapshot(context.Context, Process) (ResourceSnapshot, error)
|
||||
StopAll(context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
func TestStartSpecValidation_BH_SUPERVISOR_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-000000000091")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []StartSpec{
|
||||
{},
|
||||
{IssueUUID: issue, CommandRevision: 1, WorkingDirectory: `C:\work`},
|
||||
{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{}, WorkingDirectory: `C:\work`},
|
||||
{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}},
|
||||
}
|
||||
for index, spec := range cases {
|
||||
if !errors.Is(spec.Validate(), ErrInvalidStartSpec) {
|
||||
t.Fatalf("case %d validation = %v, want ErrInvalidStartSpec", index, spec.Validate())
|
||||
}
|
||||
}
|
||||
valid := StartSpec{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}, WorkingDirectory: `C:\work`}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEnvironmentKey = errors.New("invalid Windows environment key")
|
||||
ErrInvalidEnvironmentValue = errors.New("invalid Windows environment value")
|
||||
ErrDuplicateEnvironmentKey = errors.New("duplicate Windows environment key")
|
||||
ErrEnvironmentTooLarge = errors.New("Windows environment block is too large")
|
||||
)
|
||||
|
||||
// EnvironmentEntry is an ordered, case-preserving environment assignment.
|
||||
// Windows compares names case-insensitively, so Folded is never emitted and is
|
||||
// used only to make duplicate handling deterministic.
|
||||
type EnvironmentEntry struct {
|
||||
Key string
|
||||
Value string
|
||||
Folded string
|
||||
}
|
||||
|
||||
// MergeEnvironment overlays request values onto the token-derived base
|
||||
// environment. Shell resolution happens before this function is called; an
|
||||
// override therefore cannot redirect the configured executable.
|
||||
func MergeEnvironment(base, overrides map[string]string) ([]EnvironmentEntry, error) {
|
||||
entries := make(map[string]EnvironmentEntry, len(base)+len(overrides))
|
||||
add := func(key, value string, override bool) error {
|
||||
if !validEnvironmentKey(key, override) {
|
||||
return ErrInvalidEnvironmentKey
|
||||
}
|
||||
if strings.IndexByte(value, 0) >= 0 || !utf8.ValidString(value) {
|
||||
return ErrInvalidEnvironmentValue
|
||||
}
|
||||
folded := strings.ToUpper(key)
|
||||
if _, exists := entries[folded]; exists {
|
||||
if !override {
|
||||
return ErrDuplicateEnvironmentKey
|
||||
}
|
||||
delete(entries, folded)
|
||||
}
|
||||
entries[folded] = EnvironmentEntry{Key: key, Value: value, Folded: folded}
|
||||
return nil
|
||||
}
|
||||
baseKeys := make([]string, 0, len(base))
|
||||
for key := range base {
|
||||
baseKeys = append(baseKeys, key)
|
||||
}
|
||||
sort.Strings(baseKeys)
|
||||
for _, key := range baseKeys {
|
||||
if err := add(key, base[key], false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
overrideKeys := make([]string, 0, len(overrides))
|
||||
for key := range overrides {
|
||||
overrideKeys = append(overrideKeys, key)
|
||||
}
|
||||
sort.Strings(overrideKeys)
|
||||
for _, key := range overrideKeys {
|
||||
if err := add(key, overrides[key], true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result := make([]EnvironmentEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
result = append(result, entry)
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
if result[left].Folded == result[right].Folded {
|
||||
return result[left].Key < result[right].Key
|
||||
}
|
||||
return result[left].Folded < result[right].Folded
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BuildEnvironmentBlock converts the merged entries to the UTF-16 block
|
||||
// expected by CreateProcessAsUser. The final two NUL code units are included.
|
||||
func BuildEnvironmentBlock(base, overrides map[string]string) ([]uint16, error) {
|
||||
entries, err := MergeEnvironment(base, overrides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var units []uint16
|
||||
for _, entry := range entries {
|
||||
units = append(units, utf16.Encode([]rune(entry.Key+"="+entry.Value))...)
|
||||
units = append(units, 0)
|
||||
if len(units) > 32767 {
|
||||
return nil, ErrEnvironmentTooLarge
|
||||
}
|
||||
}
|
||||
units = append(units, 0)
|
||||
return units, nil
|
||||
}
|
||||
|
||||
func validEnvironmentKey(key string, override bool) bool {
|
||||
if key == "" || strings.IndexByte(key, 0) >= 0 || !utf8.ValidString(key) {
|
||||
return false
|
||||
}
|
||||
if override && (strings.ContainsRune(key, '=') || strings.HasPrefix(key, "=")) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(key, "=") {
|
||||
// CreateEnvironmentBlock may return drive-current-directory pseudo
|
||||
// variables such as =C:=C:\\work. They are accepted only as base data.
|
||||
return !override && strings.Count(key, "=") == 1
|
||||
}
|
||||
return !strings.ContainsRune(key, '=')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvironmentOverlayIsCaseInsensitiveAndDeterministic_HP_WINENV_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := map[string]string{"Path": `C:\Windows`, "TEMP": `C:\Temp`, "=C:": `C:\Windows`}
|
||||
overrides := map[string]string{"PATH": `C:\Pinned`, "Name": "rvbox"}
|
||||
entries, err := MergeEnvironment(base, overrides)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 4 || entries[0].Folded != "=C:" || entries[1].Folded != "NAME" || entries[2].Folded != "PATH" || entries[2].Value != `C:\Pinned` {
|
||||
t.Fatalf("merged entries = %#v", entries)
|
||||
}
|
||||
block, err := BuildEnvironmentBlock(base, overrides)
|
||||
if err != nil || len(block) == 0 || block[len(block)-1] != 0 || block[len(block)-2] != 0 {
|
||||
t.Fatalf("environment block = len=%d err=%v", len(block), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverlayRejectsMalformedAndDuplicateBase_BH_WINENV_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := MergeEnvironment(map[string]string{"Path": "one", "PATH": "two"}, nil); !errors.Is(err, ErrDuplicateEnvironmentKey) {
|
||||
t.Fatalf("duplicate base error = %v", err)
|
||||
}
|
||||
if _, err := MergeEnvironment(nil, map[string]string{"BAD=KEY": "value"}); !errors.Is(err, ErrInvalidEnvironmentKey) {
|
||||
t.Fatalf("invalid override key error = %v", err)
|
||||
}
|
||||
if _, err := MergeEnvironment(nil, map[string]string{"NUL": "bad\x00value"}); !errors.Is(err, ErrInvalidEnvironmentValue) {
|
||||
t.Fatalf("invalid override value error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user