From 0060a310ff769bbe859a76cd059ac79c64fa0abb Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:13:42 +0200 Subject: [PATCH 1/6] Expose settlement and network denial observations and rehearse abuse response Add bounded payment-owned backlog metrics and session-scoped policy observations with explicit freshness and unavailable states. Exercise authenticated destination denial and credential recovery on owned local fixtures, and document report handling and remaining operational acceptance. GitHub issues #116 and #19. --- docs/README.md | 1 + docs/operations/abuse-response.md | 91 ++++++ docs/operations/metrics.md | 46 ++- internal/dispatcher/metrics.go | 3 + internal/dispatcher/metrics_health.go | 10 +- internal/dispatcher/metrics_health_test.go | 36 +++ internal/dispatcher/payments/metrics.go | 111 +++++++ internal/dispatcher/payments/metrics_test.go | 128 ++++++++ .../transport/api/handlers_metrics.go | 21 +- .../transport/api/handlers_metrics_test.go | 22 ++ internal/dispatcher/vantage.go | 4 + internal/executor/abuse_drill_test.go | 156 ++++++++++ internal/executor/capabilities.go | 4 + internal/executor/capabilities_test.go | 3 + .../executor/debuglet/netpolicy/admission.go | 10 +- .../debuglet/netpolicy/observations_test.go | 49 +++ .../executor/debuglet/netpolicy/policy.go | 29 +- internal/executor/destination_control_test.go | 22 +- protocol/protocol.pb.go | 280 +++++++++++------- protocol/protocol.proto | 8 + 20 files changed, 918 insertions(+), 116 deletions(-) create mode 100644 docs/operations/abuse-response.md create mode 100644 internal/dispatcher/payments/metrics.go create mode 100644 internal/dispatcher/payments/metrics_test.go create mode 100644 internal/executor/abuse_drill_test.go create mode 100644 internal/executor/debuglet/netpolicy/observations_test.go diff --git a/docs/README.md b/docs/README.md index 080a5997..e612346c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ This directory is the versioned source for the [Debuglet documentation site](htt - [Local validation checks](operations/local-checks.md) - [Signed releases and offline verification](operations/releases.md) - [External executor pilot](operations/external-pilot.md) — steps, records and support for an independently operated executor. +- [Abuse response](operations/abuse-response.md) — report handling, destination denials, credential recovery and owned local drills. Use the [deployment guide](../deploy/README.md) for the maintained Ansible procedures and upgrade inputs. diff --git a/docs/operations/abuse-response.md b/docs/operations/abuse-response.md new file mode 100644 index 00000000..36eb1476 --- /dev/null +++ b/docs/operations/abuse-response.md @@ -0,0 +1,91 @@ +# Abuse reports and destination opt-outs + +Before operating a deployment, name an accountable reporting contact, a private +intake channel and the people allowed to read incident evidence. Agree who can +approve destination denials, revoke credentials and escalate an unresolved +report. The public issue tracker is suitable for requesting that contact, not +for packet captures, addresses, credentials or personal information. This +procedure does not designate a reporting contact or establish that the team has +agreed an evidence-access policy. + +## Intake and approval + +Open a private incident record with a time, incident identifier, report source, +claimed destination, requested action, reviewer and access list. Verify control +of the destination through the deployment's agreed private channel. An account +login by itself proves neither destination ownership nor authority to approve +an opt-out. Record the approval and the basis for that decision before changing +policy. Use the existing [security reporting policy](../../SECURITY.md) for a +suspected vulnerability. + +## Apply and verify a destination denial + +An authenticated operator submits `PATCH /destination` with the destination, +`"denied": true` and a concise reason, then reads `GET /destinations`. Preserve +the returned actor, revision, reason, timestamps, recipient count and delivery +state in the incident record. Keep sensitive evidence outside the reason field. +The denial persists across dispatcher restarts and refuses new work. A bandwidth +limit of zero is not a substitute for a denial. + +HTTP 204 confirms delivery to the relevant executor recipients. A recorded but +unconfirmed change remains an incident action requiring observation; it does not +prove traffic stopped. A peer that cannot receive the denial is retired after +the bounded delivery attempt, and a lost control lease stops its active work. +Allow the delivery bound and the configured lease to expire before classifying +that recovery path. Read the recorded delivery result again after reconnect. + +Verify the destination's actual receiver observations separately from the +acknowledgment. For owned fixtures, establish nonzero TCP and UDP traffic before +the denial, record the approval and delivery times, then record TCP termination +and a bounded quiet interval after queued UDP datagrams drain. Record those +durations and packet/byte counts, not an unqualified claim that all traffic +stopped forever. An attachment-presence observation, a closed socket or a quiet +interval alone does not validate every protocol, network path or packet counter. + +Escalate persistent traffic or unconfirmed delivery to the named operator. Stop +the affected executor or revoke its enrollment under the deployment's approved +incident procedure if the denial cannot be established. Preserve the original +record and record further actions and observations; do not overwrite a failed +attempt with a later success. + +## Copied credentials + +Follow [Respond to a copied credential](authentication.md#respond-to-a-copied-credential). +From an uncompromised browser session, identify and revoke the affected +credential, or all account credentials if its identity is uncertain. Verify +that a formerly successful authenticated request with the old credential is now +refused. Record only its identifier, never the secret. When the legacy account +key is exposed, use the separately retained recovery code, confirm the old key +and old sessions are refused, and sign in with the replacement key. Account +recovery does not establish recovery of an external identity provider. + +Credential revocation prevents subsequent authenticated requests. It does not +cancel admitted measurements: handle active work and observe the receiver +separately. Preserve the account identifier, credential identifier, times, +actions, observed results, reviewer and record-access policy privately. + +## Owned local rehearsal + +On supported Linux, the existing control-path fixture and credential drill run +without an outside host, identity provider or chain: + +```sh +go test -race ./internal/executor \ + -run 'TestDestinationDenyControlPathAndReconnect|TestCredentialCompromiseDrill' \ + -count=1 -v +``` + +The destination fixture uses real local SQLite, HTTP authentication and role +checks, dispatcher/executor control channels, and loopback TCP/UDP receivers. It +exercises acknowledged delivery and lost policy delivery while ordinary control +probes still succeed. Its runtime adapter sends through admitted and registered +sockets; it is not an independently operated deployment or an arbitrary guest +validation. The credential drill exercises the real API's issue, use, revoke, +refuse and recover transitions with a local account. Neither drill uses the +local authentication bypass. The log records identifiers and observations, not +tokens, account keys, recovery codes or packet contents. + +Keep the exact source revision, command, complete result, observed traffic +counts and timing in the private incident record. This technical rehearsal does +not replace a rehearsal with the actual reporting contact and agreed handling +and evidence-access policy. diff --git a/docs/operations/metrics.md b/docs/operations/metrics.md index 3fbb7174..11234357 100644 --- a/docs/operations/metrics.md +++ b/docs/operations/metrics.md @@ -70,11 +70,39 @@ structured interruption reason. Accordingly `queue_age_seconds`, unsupported. The supported scheduled-start overdue gauge does not substitute for these observations. -Denied traffic, independently verified enforcement, TESLA clock uncertainty -and settlement backlog remain unsupported. Disclosure delivery lag is observed +Denied packet/byte counts, independently verified enforcement and independently +measured TESLA clock uncertainty remain unsupported. Policy refusal and socket +revocation observations are available below. Disclosure delivery lag is observed at the dispatcher only (see executor health below). Collection never changes admission, packet enforcement, terminal state, payment or readiness decisions. +## Settlement backlog + +The payment subsystem reads its durable obligations without contacting a chain +or attempting settlement. `settlement_pending_orders{state="credit|refund"}` +counts outstanding orders whose terminal run has an observed exit or a recorded +cancellation and no covering refund transfer. It uses the same eligibility and +decision as the settlement sweep. Unclaimed paid orders and admitted runs that +are still active do not represent a pending terminal settlement. + +`settlement_transfers{state="reserved|sent|unknown|failed"}` counts retained +transfer rows in each state. Confirmed transfers are excluded. A failed transfer +requires operator attention; it is not an automatic retry. A sent or unknown +transfer does not establish that funds moved. Counts include stored chain +obligations while payments are disabled, so disabling payments cannot produce a +misleading empty backlog. These are row counts, never currency amounts, and +cannot be summed into money owed. Pending orders and transfers are separate +stages; the covering-transfer exclusion prevents counting the same refund in +both stages. + +`observation_available{observation="settlement_backlog",reason="..."}` reports +availability for the entire group. The payment reader uses one SQLite snapshot, +a two-second context and at most 10,001 scalar rows for each stage. More than +10,000 eligible orders or unconfirmed transfers yields `reason="limit"`; a +database failure or timeout yields `reason="storage"`. All settlement numbers +are then omitted. No receiver, transaction, executor or account labels are +exported. A successful empty snapshot reports known zeros. + ## Executor health The existing authenticated control reports also provide the following aggregate @@ -92,6 +120,8 @@ counter, not evidence that the kernel still enforces every packet. | `executor_state_available_bytes_min` | Least space available to unprivileged writes on any executor database filesystem. | | `executor_state_capacity_bytes_min` | Smallest such filesystem capacity. | | `executor_state_available_ratio_min` | Lowest available/capacity ratio, calculated per executor before aggregation. Quotas and inode exhaustion require host monitoring. | +| `executor_network_refused_admissions_max` | Largest count in a current executor session of final network-policy refusals at outbound destination and resolved-peer admission. | +| `executor_network_revoked_sockets_max` | Largest count in a current executor session of sockets actually closed by destination revocation. | | `executors_attribution_state{state="..."}` | Counts for `available`, `epoch_zero`, `chain_exhausted`, `refresh_failing`, `disclosure_held`, `clock_unready`, `clock_drift` and `unknown`. | | `executors_clock_readiness{state="..."}` | Counts for `ready`, `degraded` and `unknown`, from the kernel clock report and its configured error threshold. | | `executors_schedule_unknown` | Executors without a usable fresh schedule observation. | @@ -140,6 +170,18 @@ maximum/minimum is withheld rather than appearing to cover the whole registry. These resource and attachment details are available only through operator metrics, not public executor discovery. Older executors omit them and count as unknown. +The two network observations use the same freshness and completeness rules and +also export `_unknown`. They are gauges of current executor sessions; +reconnect, restart or removal of an executor can reduce them. Do not use +`rate()` or interpret them as lifetime fleet counters. One refused admission +increments once, even if several candidate addresses were refused. If another +candidate is admitted, that operation is not counted. Transport/policy/untagged +refusals are counted; a target lookup or connection failure is not a denial. +Revocation counts the sockets the close operation reports, after closing them. +These observations do not count denied packets or bytes, prove successful +receiver-side traffic termination, or validate packet-counter coverage. A +denial before these two policy admission boundaries is outside this metric. + ## Collection limits Reports are cached for one second. Another request arriving while collection is diff --git a/internal/dispatcher/metrics.go b/internal/dispatcher/metrics.go index 053a674a..5f3781f1 100644 --- a/internal/dispatcher/metrics.go +++ b/internal/dispatcher/metrics.go @@ -9,6 +9,7 @@ import ( "github.com/netsec-ethz/debuglet/internal/controlsession" "github.com/netsec-ethz/debuglet/internal/dispatcher/models" + "github.com/netsec-ethz/debuglet/internal/dispatcher/payments" ) const ( @@ -27,6 +28,7 @@ type ControlMetrics struct { RegistryUnavailable string Health ExecutorHealthMetrics Runs RetainedRunMetrics + Settlement payments.SettlementMetrics } type RetainedRunMetrics struct { @@ -71,6 +73,7 @@ func (d *Dispatcher) CollectMetrics(ctx context.Context) ControlMetrics { } } d.mu.RUnlock() + report.Settlement = d.Payment.CollectMetrics(ctx) if report.RegistryUnavailable != "" { report.Runs.Unavailable = "registry" return report diff --git a/internal/dispatcher/metrics_health.go b/internal/dispatcher/metrics_health.go index 6ae6a570..ee4c326d 100644 --- a/internal/dispatcher/metrics_health.go +++ b/internal/dispatcher/metrics_health.go @@ -37,7 +37,8 @@ type ExecutorHealthMetrics struct { ScheduleRemainingSeconds *float64 // DisclosureLag is the maximum disclosure delivery lag in seconds. - DisclosureLag ExecutorResourceMetric + DisclosureLag ExecutorResourceMetric + RefusedAdmissions, RevokedSockets ExecutorResourceMetric } // A numeric aggregate is publishable only when all registered observations are @@ -67,6 +68,13 @@ func (m *ExecutorHealthMetrics) observe(e *executorEntry, now time.Time, connect resources = e.vantage.resources } m.observeResources(resources) + var refused, revoked *float64 + if e.vantage != nil && fresh(e.vantageObserved) && e.vantage.networkDenials != nil { + r, c := float64(e.vantage.networkDenials.RefusedAdmissions), float64(e.vantage.networkDenials.RevokedSockets) + refused, revoked = &r, &c + } + m.RefusedAdmissions.observe(refused, false) + m.RevokedSockets.observe(revoked, false) switch { case e.Capabilities == nil || !fresh(e.capabilityObserved): m.AttachmentUnknown++ diff --git a/internal/dispatcher/metrics_health_test.go b/internal/dispatcher/metrics_health_test.go index d52a3332..c2c2785c 100644 --- a/internal/dispatcher/metrics_health_test.go +++ b/internal/dispatcher/metrics_health_test.go @@ -15,6 +15,42 @@ import ( pb "github.com/netsec-ethz/debuglet/protocol" ) +func TestMetricsNetworkDenialsRequireFreshCompleteObservations(t *testing.T) { + now := time.Now().UTC() + e := &executorEntry{RegisteredExecutor: &RegisteredExecutor{}} + read := func(denials *pb.NetworkDenials, at time.Time, connected bool) ExecutorHealthMetrics { + e.vantage, e.vantageObserved = vantageFromReport(&pb.VantagePointReport{SchemaVersion: 1, NetworkDenials: denials}), at + var h ExecutorHealthMetrics + h.observe(e, now, connected) + return h + } + for _, tc := range []struct { + name string + denials *pb.NetworkDenials + at time.Time + connected bool + }{ + {"older executor", nil, now, true}, + {"stale", &pb.NetworkDenials{}, now.Add(-capabilityLifetime), true}, + {"future", &pb.NetworkDenials{}, now.Add(time.Second), true}, + {"disconnected", &pb.NetworkDenials{}, now, false}, + {"overflow", &pb.NetworkDenials{RefusedAdmissions: 1 << 54}, now, true}, + } { + h := read(tc.denials, tc.at, tc.connected) + if h.RefusedAdmissions.Unknown != 1 || h.RevokedSockets.Unknown != 1 || h.RefusedAdmissions.Value != nil || h.RevokedSockets.Value != nil { + t.Fatalf("%s: %+v", tc.name, h) + } + } + h := read(&pb.NetworkDenials{RefusedAdmissions: 7, RevokedSockets: 2}, now, true) + if h.RefusedAdmissions.Unknown != 0 || h.RevokedSockets.Unknown != 0 || *h.RefusedAdmissions.Value != 7 || *h.RevokedSockets.Value != 2 { + t.Fatalf("fresh: %+v", h) + } + h = read(&pb.NetworkDenials{}, now, true) + if h.RefusedAdmissions.Value == nil || *h.RefusedAdmissions.Value != 0 || h.RevokedSockets.Value == nil || *h.RevokedSockets.Value != 0 { + t.Fatalf("new session zero: %+v", h) + } +} + func TestMetricsHealthUsesFreshReportsAndExpiresWithoutHeartbeat(t *testing.T) { f := newTGFixture(t, nil) observed := time.Now().UTC() diff --git a/internal/dispatcher/payments/metrics.go b/internal/dispatcher/payments/metrics.go new file mode 100644 index 00000000..a6fd84e4 --- /dev/null +++ b/internal/dispatcher/payments/metrics.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package payments + +import ( + "context" + "database/sql" + "time" + + "github.com/netsec-ethz/debuglet/internal/dispatcher/models" +) + +const metricsRowLimit = 10000 + +// SettlementMetrics is a read-only observation of durable payment obligations. +// Pending orders and transfers are separate stages, not amounts or proof of a +// chain outcome. Failed transfers require operator attention; they are not +// automatically retried. Counts include records retained while payments are +// disabled. Unavailable discards the entire observation, never a partial total. +type SettlementMetrics struct { + Unavailable string + PendingCredit, PendingRefund int + Reserved, Sent, Unknown, Failed int +} + +// CollectMetrics reads bounded scalar rows in one SQLite snapshot without +// contacting a chain, starting settlement, or holding any runtime guard. +func (p *PaymentHandler) CollectMetrics(ctx context.Context) SettlementMetrics { + if p == nil || p.db == nil { + return SettlementMetrics{Unavailable: "unavailable"} + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + tx, err := p.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return SettlementMetrics{Unavailable: "storage"} + } + defer tx.Rollback() + // Keep eligibility identical to ListPendingSettlements, the settlement + // sweep's contract. Select only its decision, not payloads or credentials. + rows, err := tx.QueryContext(ctx, `SELECT e.exit_code IS NOT NULL AND e.exit_code = 0 + FROM debuglet_order o JOIN debuglets d ON d.id = o.debuglet_id + AND d.transaction_id = o.transaction_id AND d.order_id = o.order_id + LEFT JOIN measurement_execution e ON e.debuglet_id = d.id + LEFT JOIN debuglet_cancellations c ON c.debuglet_id = d.id + WHERE o.state = ? AND d.state = ? + AND (e.exit_code IS NOT NULL OR c.terminal_recorded_at IS NOT NULL) + AND NOT EXISTS (SELECT 1 FROM chain_transfers t + WHERE t.kind = 'refund' AND t.transaction_id = o.transaction_id + AND (t.order_id IS NULL OR t.order_id = o.order_id)) + LIMIT ?`, models.Outstanding, models.RunStateExited, metricsRowLimit+1) + if err != nil { + return SettlementMetrics{Unavailable: "storage"} + } + result := SettlementMetrics{} + for rows.Next() { + var credit bool + if err := rows.Scan(&credit); err != nil { + rows.Close() + return SettlementMetrics{Unavailable: "storage"} + } + if credit { + result.PendingCredit++ + } else { + result.PendingRefund++ + } + if result.PendingCredit+result.PendingRefund > metricsRowLimit { + rows.Close() + return SettlementMetrics{Unavailable: "limit"} + } + } + err = rows.Err() + rows.Close() + if err != nil { + return SettlementMetrics{Unavailable: "storage"} + } + rows, err = tx.QueryContext(ctx, `SELECT substr(state, 1, 16) FROM chain_transfers + WHERE state <> 'confirmed' LIMIT ?`, metricsRowLimit+1) + if err != nil { + return SettlementMetrics{Unavailable: "storage"} + } + defer rows.Close() + count := 0 + for rows.Next() { + var state string + if err := rows.Scan(&state); err != nil { + return SettlementMetrics{Unavailable: "storage"} + } + count++ + if count > metricsRowLimit { + return SettlementMetrics{Unavailable: "limit"} + } + switch state { + case transferReserved: + result.Reserved++ + case transferSent: + result.Sent++ + case transferUnknown: + result.Unknown++ + case transferFailed: + result.Failed++ + default: + return SettlementMetrics{Unavailable: "storage"} + } + } + if rows.Err() != nil { + return SettlementMetrics{Unavailable: "storage"} + } + return result +} diff --git a/internal/dispatcher/payments/metrics_test.go b/internal/dispatcher/payments/metrics_test.go new file mode 100644 index 00000000..c5fb27fc --- /dev/null +++ b/internal/dispatcher/payments/metrics_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package payments + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/internal/dispatcher/models" +) + +func TestSettlementMetricsFollowDurableObligationsWhileDisabled(t *testing.T) { + for _, cancellation := range []bool{false, true} { + t.Run(map[bool]string{false: "reported_exit", true: "recorded_cancellation"}[cancellation], func(t *testing.T) { + db := newRefundDatabase(t) + h, rec := newDisabledHandler(t, db, true, true) + if got := h.CollectMetrics(t.Context()); got != (SettlementMetrics{}) { + t.Fatalf("empty: %+v", got) + } + var run database.Debuglet + if cancellation { + run = seedCancelledUSDCRun(t, db) + } else { + run = seedFailedUSDCRun(t, db) + } + pending, err := database.New(db).ListPendingSettlements(t.Context(), database.ListPendingSettlementsParams{ + OutstandingState: int64(models.Outstanding), ExitedState: models.RunStateExited, RowLimit: 100, + }) + if err != nil || len(pending) != 1 { + t.Fatalf("settlement owner: %v, %v", pending, err) + } + if got := h.CollectMetrics(t.Context()); got != (SettlementMetrics{PendingRefund: 1}) { + t.Fatalf("refund: %+v", got) + } + if !cancellation { + if _, err := db.Exec("UPDATE measurement_execution SET exit_code=0 WHERE debuglet_id=?", run.ID); err != nil { + t.Fatal(err) + } + if got := h.CollectMetrics(t.Context()); got != (SettlementMetrics{PendingCredit: 1}) { + t.Fatalf("credit: %+v", got) + } + } + // Once a refund reservation exists the sweep must no longer own it, + // including failed/uncertain outcomes and a transaction-wide refund. + for _, state := range []string{transferReserved, transferSent, transferUnknown, transferFailed, transferConfirmed} { + if _, err := db.Exec("DELETE FROM chain_transfers"); err != nil { + t.Fatal(err) + } + metricsTransfer(t, db, transferRefund, testTxID, state) + got := h.CollectMetrics(t.Context()) + want := SettlementMetrics{} + switch state { + case transferReserved: + want.Reserved = 1 + case transferSent: + want.Sent = 1 + case transferUnknown: + want.Unknown = 1 + case transferFailed: + want.Failed = 1 + } + if got != want { + t.Fatalf("%s: %+v, want %+v", state, got, want) + } + } + // Deleting the transfer returns ownership to the pending sweep. + // Settle locally in TEST and observe the actual durable transition. + if _, err := db.Exec("DELETE FROM chain_transfers"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec("UPDATE debuglet_order SET currency='TEST'"); err != nil { + t.Fatal(err) + } + settled, failed, deferred, _, err := h.SettlePendingOrders(t.Context(), 0, 100) + if err != nil || settled != 1 || failed != 0 || deferred != 0 { + t.Fatalf("settle: %d %d %d %v", settled, failed, deferred, err) + } + if got := h.CollectMetrics(t.Context()); got != (SettlementMetrics{}) { + t.Fatalf("settled order remains pending: %+v", got) + } + if calls := rec.chain.Calls(); len(calls) != 0 { + t.Fatalf("metrics reached chain: %v", calls) + } + }) + + } +} + +func metricsTransfer(t *testing.T, db *sql.DB, kind, transaction, state string) { + t.Helper() + _, err := database.New(db).InsertChainTransfer(t.Context(), database.InsertChainTransferParams{ + Kind: kind, TransactionID: transaction, Amount: 1, Currency: "USDC", Receiver: "not-read-by-metrics", State: state, + CreatedAt: models.NewUTCTime(time.Now()), UpdatedAt: models.NewUTCTime(time.Now()), SignedTransaction: []byte{}, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSettlementMetricsOmitIncompleteCounts(t *testing.T) { + db := newRefundDatabase(t) + h, _ := newDisabledHandler(t, db, true, true) + seedCancelledUSDCRun(t, db) + _, err := db.Exec(`WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x401", time.Now().UTC().Format(time.RFC3339Nano), a.account.ID, issued.CredentialID) + // A separately retained account recovery code is the recovery route when + // the legacy account key itself is exposed. It invalidates old sessions. + recovered, err := a.client.Recover(t.Context(), a.account.RecoveryCode) + if err != nil { + t.Fatal(err) + } + a.expect(t, http.MethodGet, "/me", a.session.Token, false, nil, http.StatusUnauthorized) + a.expect(t, http.MethodPost, "/auth/login", "", false, api.LoginRequest{AccountKey: a.account.AccountKey}, http.StatusUnauthorized) + newSession, err := a.client.Login(t.Context(), recovered.AccountKey) + if err != nil { + t.Fatal(err) + } + a.expect(t, http.MethodGet, "/me", newSession.Token, false, nil, http.StatusOK) + t.Logf("incident=account-key at=%s account=%s actions=recover observed=old-key-401,old-session-401,new-session-200", time.Now().UTC().Format(time.RFC3339Nano), a.account.ID) +} + +func (a *abuseDrillAPI) deny(ctx context.Context) error { + status, _, err := a.request(ctx, http.MethodPatch, "/destination", a.session.Token, false, + api.DestinationLimitRequest{Destination: "127.0.0.1", Denied: true, Reason: "owned destination operator opt-out drill"}) + if err != nil { + return err + } + if status != http.StatusNoContent { + return fmt.Errorf("deny answered HTTP %d", status) + } + return nil +} diff --git a/internal/executor/capabilities.go b/internal/executor/capabilities.go index 7b83925b..996836e3 100644 --- a/internal/executor/capabilities.go +++ b/internal/executor/capabilities.go @@ -66,6 +66,10 @@ func (e *Executor) capabilityReport(ctx context.Context, initial bool) (*pb.Exec stateDir = filepath.Dir(e.cfg.Database.Path) } vantage.Resources = hostResources(observability.CollectHost(stateDir)) + if e.revoked != nil { + refused, closed := e.revoked.DenialObservations() + vantage.NetworkDenials = &pb.NetworkDenials{RefusedAdmissions: refused, RevokedSockets: closed} + } vantage.CounterAttachment = "unknown" if counter, ok := e.packetCount.(interface{ AttachmentState() string }); ok { vantage.CounterAttachment = counter.AttachmentState() diff --git a/internal/executor/capabilities_test.go b/internal/executor/capabilities_test.go index 653a537c..e5358347 100644 --- a/internal/executor/capabilities_test.go +++ b/internal/executor/capabilities_test.go @@ -79,6 +79,9 @@ func TestCapabilityReportsUseLocalRuntimeObservations(t *testing.T) { if v := p.GetVantagePoint(); v.GetResources() == nil || v.GetCounterAttachment() != "unknown" { t.Fatal("resource observation missing or fallback claimed an attached counter") } + if v := p.GetVantagePoint().GetNetworkDenials(); v == nil || v.RefusedAdmissions != 0 || v.RevokedSockets != 0 { + t.Fatal("new executor session must report known zero denial observations") + } calls := daemon.calls.Load() for i := 0; i < 20; i++ { if caps, vantage := scion.capabilityReport(t.Context(), false); caps != nil || vantage != nil { diff --git a/internal/executor/debuglet/netpolicy/admission.go b/internal/executor/debuglet/netpolicy/admission.go index a28d24ec..0674b888 100644 --- a/internal/executor/debuglet/netpolicy/admission.go +++ b/internal/executor/debuglet/netpolicy/admission.go @@ -256,7 +256,10 @@ type Match struct { // resolver, applies the operator's rules to the result, and returns the // address the caller must connect to. Nothing is sent to the target here: a // refusal happens before any socket is connected. -func (p *Policy) AdmitDestination(ctx context.Context, t Transport, target string) (Destination, error) { +func (p *Policy) AdmitDestination(ctx context.Context, t Transport, target string) (result Destination, err error) { + if p != nil { + defer func() { p.op.revoked.observeAdmission(err) }() + } if err := p.Available(t); err != nil { return Destination{}, err } @@ -339,7 +342,10 @@ func (p *Policy) AdmitDestination(ctx context.Context, t Transport, target strin // the sender of a datagram, or a SCION destination this executor parsed. The // source port of an inbound peer is whatever it happened to pick, so the // permitted destination ports are not applied to it. -func (p *Policy) AdmitAddr(ctx context.Context, t Transport, addr netip.AddrPort) (Match, error) { +func (p *Policy) AdmitAddr(ctx context.Context, t Transport, addr netip.AddrPort) (result Match, err error) { + if p != nil { + defer func() { p.op.revoked.observeAdmission(err) }() + } if err := p.Available(t); err != nil { return Match{}, err } diff --git a/internal/executor/debuglet/netpolicy/observations_test.go b/internal/executor/debuglet/netpolicy/observations_test.go new file mode 100644 index 00000000..e22c3e39 --- /dev/null +++ b/internal/executor/debuglet/netpolicy/observations_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package netpolicy + +import ( + "errors" + "net/netip" + "sync" + "testing" +) + +func TestDenialObservationsCountFinalDecisions(t *testing.T) { + r := NewRevocations() + spec := Defaults() + spec.DeniedDestinations = "93.184.216.7" + p := New(mustParse(t, spec).WithRevocations(r), Run{Addresses: []string{"mixed.example", "93.184.216.8"}}, + WithResolver(newResolver(map[string][]string{"mixed.example": {"93.184.216.7", "93.184.216.8"}}))) + // One denied DNS candidate does not turn an admitted operation into a + // refusal. A failed target lookup is not a policy decision. + if _, err := p.AdmitDestination(t.Context(), TCP, "mixed.example:443"); err != nil { + t.Fatal(err) + } + _, _ = p.AdmitDestination(t.Context(), TCP, "unknown.example:443") + if refused, closed := r.DenialObservations(); refused != 0 || closed != 0 { + t.Fatalf("success/errors counted: %d/%d", refused, closed) + } + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + _, err := p.AdmitDestination(t.Context(), TCP, "93.184.216.7:443") + if !errors.Is(err, ErrDenied) { + t.Errorf("denied operation: %v", err) + } + }) + } + wg.Wait() + if _, err := p.AdmitAddr(t.Context(), TCP, netip.MustParseAddrPort("93.184.216.9:443")); !errors.Is(err, ErrNotInPolicy) { + t.Fatal(err) + } + if refused, closed := r.DenialObservations(); refused != 21 || closed != 0 { + t.Fatalf("final refusals: %d/%d", refused, closed) + } + // No watcher means no socket-close claim, regardless of policy entries. + r.Update([]string{"93.184.216.8"}, true) + if _, closed := r.DenialObservations(); closed != 0 { + t.Fatal("policy mutation counted as socket closure") + } +} diff --git a/internal/executor/debuglet/netpolicy/policy.go b/internal/executor/debuglet/netpolicy/policy.go index b46f44b7..658f7b00 100644 --- a/internal/executor/debuglet/netpolicy/policy.go +++ b/internal/executor/debuglet/netpolicy/policy.go @@ -413,9 +413,11 @@ func embeddedV4(addr netip.Addr) (netip.Addr, bool) { // own sockets here, so the update that denies a destination closes the active // connections to it before it returns. type Revocations struct { - mu sync.Mutex - denied map[string]struct{} // DestinationKey of each denied destination - watchers map[*revocationWatch]struct{} + mu sync.Mutex + denied map[string]struct{} // DestinationKey of each denied destination + watchers map[*revocationWatch]struct{} + refusedAdmissions uint64 + revokedSockets uint64 } type revocationWatch struct { @@ -460,11 +462,32 @@ func (r *Revocations) Update(destinations []string, replace bool) []Revoked { for _, watch := range watchers { if closed := watch.close(); closed > 0 { revoked = append(revoked, Revoked{Run: watch.run, Sockets: closed}) + r.mu.Lock() + r.revokedSockets += uint64(closed) + r.mu.Unlock() } } return revoked } +// DenialObservations counts final network policy admission refusals and sockets +// actually closed by revocation during this executor session. These are not +// denied packet/byte counters or evidence about packets already in transit. +func (r *Revocations) DenialObservations() (refusedAdmissions, revokedSockets uint64) { + r.mu.Lock() + defer r.mu.Unlock() + return r.refusedAdmissions, r.revokedSockets +} + +func (r *Revocations) observeAdmission(err error) { + if r == nil || !(errors.Is(err, ErrDenied) || errors.Is(err, ErrNotInPolicy) || errors.Is(err, ErrUntagged) || errors.Is(err, ErrTransportUnavailable)) { + return + } + r.mu.Lock() + r.refusedAdmissions++ + r.mu.Unlock() +} + // watch registers close for run until the returned stop is called. func (r *Revocations) watch(run string, close func() int) (stop func()) { if r == nil { diff --git a/internal/executor/destination_control_test.go b/internal/executor/destination_control_test.go index a4774137..dc28b96e 100644 --- a/internal/executor/destination_control_test.go +++ b/internal/executor/destination_control_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net" + "net/http" "path/filepath" "sync/atomic" "testing" @@ -94,6 +95,14 @@ func TestDestinationDenyControlPathAndReconnect(t *testing.T) { t.Fatal(err) } t.Cleanup(d.Close) + operator := newAbuseDrillAPI(t, d, db) + // A report does not carry authority. The account must receive the + // operator role through the normal host administration path first. + operator.expect(t, http.MethodGet, "/destinations", "", false, nil, http.StatusUnauthorized) + operator.expect(t, http.MethodGet, "/destinations", operator.session.Token, false, nil, http.StatusForbidden) + if _, err := db.Exec("UPDATE users SET role='operator' WHERE uuid=?", operator.account.ID); err != nil { + t.Fatal(err) + } f := newRecoveryHarnessWithServer(t, newOperationPeer(), func(f *recoveryHarness) { f.server.Close() d.Bidi.Close() @@ -229,7 +238,7 @@ func TestDestinationDenyControlPathAndReconnect(t *testing.T) { started := time.Now() result := make(chan error, 1) go func() { - result <- d.SetDestinationPolicy(t.Context(), "operator", "127.0.0.1", dispatcher.DestinationPolicyChange{Denied: true, Reason: "local opt-out fixture"}) + result <- operator.deny(t.Context()) }() if lost { select { @@ -269,10 +278,21 @@ func TestDestinationDenyControlPathAndReconnect(t *testing.T) { if udpPackets.Load() != quiet { t.Fatal("UDP traffic continued after TCP termination") } + t.Logf("incident=destination-opt-out at=%s actor=%s delivery_lost=%v stop_elapsed=%s tcp_bytes=%d udp_packets=%d quiet_window=150ms", time.Now().UTC().Format(time.RFC3339Nano), operator.account.ID, lost, time.Since(started), tcpBytes.Load(), quiet) policies, err := d.ListDestinationPolicies(t.Context()) if err != nil || len(policies) != 1 || policies[0].Recipients != 1 || (policies[0].Unconfirmed != 0) != lost { t.Fatalf("delivery accounting: %+v, %v", policies, err) } + if policies[0].Actor != operator.account.ID { + t.Fatal("policy omitted the authenticated approval actor") + } + operator.expect(t, http.MethodGet, "/destinations", operator.session.Token, false, nil, http.StatusOK) + if !lost { + _, report := s.executor.capabilityReport(t.Context(), true) + if report.GetNetworkDenials().GetRevokedSockets() == 0 { + t.Fatal("acknowledged live-socket revocation missing from executor observations") + } + } s.Stop(nil) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() diff --git a/protocol/protocol.pb.go b/protocol/protocol.pb.go index f3d1807e..2904f314 100644 --- a/protocol/protocol.pb.go +++ b/protocol/protocol.pb.go @@ -3131,10 +3131,11 @@ type VantagePointReport struct { AddressOptOut bool `protobuf:"varint,13,opt,name=address_opt_out,json=addressOptOut,proto3" json:"address_opt_out,omitempty"` // Host tags from the executor's configuration, from a fixed vocabulary // (pkg/wire HostTags). Read at registration; a malformed list is dropped. - HostTags []string `protobuf:"bytes,14,rep,name=host_tags,json=hostTags,proto3" json:"host_tags,omitempty"` - AddressCheck *AddressSelfCheck `protobuf:"bytes,15,opt,name=address_check,json=addressCheck,proto3" json:"address_check,omitempty"` // Optional; absent is unknown. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + HostTags []string `protobuf:"bytes,14,rep,name=host_tags,json=hostTags,proto3" json:"host_tags,omitempty"` + AddressCheck *AddressSelfCheck `protobuf:"bytes,15,opt,name=address_check,json=addressCheck,proto3" json:"address_check,omitempty"` // Optional; absent is unknown. + NetworkDenials *NetworkDenials `protobuf:"bytes,16,opt,name=network_denials,json=networkDenials,proto3" json:"network_denials,omitempty"` // Operator-only policy observations; absent is unknown. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VantagePointReport) Reset() { @@ -3272,6 +3273,67 @@ func (x *VantagePointReport) GetAddressCheck() *AddressSelfCheck { return nil } +func (x *VantagePointReport) GetNetworkDenials() *NetworkDenials { + if x != nil { + return x.NetworkDenials + } + return nil +} + +// Counts for this executor session, observed at network policy boundaries. +// These do not count denied packets/bytes or prove end-to-end enforcement. +type NetworkDenials struct { + state protoimpl.MessageState `protogen:"open.v1"` + RefusedAdmissions uint64 `protobuf:"varint,1,opt,name=refused_admissions,json=refusedAdmissions,proto3" json:"refused_admissions,omitempty"` + RevokedSockets uint64 `protobuf:"varint,2,opt,name=revoked_sockets,json=revokedSockets,proto3" json:"revoked_sockets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkDenials) Reset() { + *x = NetworkDenials{} + mi := &file_protocol_protocol_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkDenials) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkDenials) ProtoMessage() {} + +func (x *NetworkDenials) ProtoReflect() protoreflect.Message { + mi := &file_protocol_protocol_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkDenials.ProtoReflect.Descriptor instead. +func (*NetworkDenials) Descriptor() ([]byte, []int) { + return file_protocol_protocol_proto_rawDescGZIP(), []int{47} +} + +func (x *NetworkDenials) GetRefusedAdmissions() uint64 { + if x != nil { + return x.RefusedAdmissions + } + return 0 +} + +func (x *NetworkDenials) GetRevokedSockets() uint64 { + if x != nil { + return x.RevokedSockets + } + return 0 +} + // What the executor learned from its last address observation round, reduced // to booleans so that no local address leaves the host. type AddressSelfCheck struct { @@ -3289,7 +3351,7 @@ type AddressSelfCheck struct { func (x *AddressSelfCheck) Reset() { *x = AddressSelfCheck{} - mi := &file_protocol_protocol_proto_msgTypes[47] + mi := &file_protocol_protocol_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3301,7 +3363,7 @@ func (x *AddressSelfCheck) String() string { func (*AddressSelfCheck) ProtoMessage() {} func (x *AddressSelfCheck) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[47] + mi := &file_protocol_protocol_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3314,7 +3376,7 @@ func (x *AddressSelfCheck) ProtoReflect() protoreflect.Message { // Deprecated: Use AddressSelfCheck.ProtoReflect.Descriptor instead. func (*AddressSelfCheck) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{47} + return file_protocol_protocol_proto_rawDescGZIP(), []int{48} } func (x *AddressSelfCheck) GetIpv4LocalPrivate() bool { @@ -3348,7 +3410,7 @@ type HostResourceValue struct { func (x *HostResourceValue) Reset() { *x = HostResourceValue{} - mi := &file_protocol_protocol_proto_msgTypes[48] + mi := &file_protocol_protocol_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3360,7 +3422,7 @@ func (x *HostResourceValue) String() string { func (*HostResourceValue) ProtoMessage() {} func (x *HostResourceValue) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[48] + mi := &file_protocol_protocol_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3373,7 +3435,7 @@ func (x *HostResourceValue) ProtoReflect() protoreflect.Message { // Deprecated: Use HostResourceValue.ProtoReflect.Descriptor instead. func (*HostResourceValue) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{48} + return file_protocol_protocol_proto_rawDescGZIP(), []int{49} } func (x *HostResourceValue) GetValue() uint64 { @@ -3403,7 +3465,7 @@ type HostResources struct { func (x *HostResources) Reset() { *x = HostResources{} - mi := &file_protocol_protocol_proto_msgTypes[49] + mi := &file_protocol_protocol_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3415,7 +3477,7 @@ func (x *HostResources) String() string { func (*HostResources) ProtoMessage() {} func (x *HostResources) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[49] + mi := &file_protocol_protocol_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3428,7 +3490,7 @@ func (x *HostResources) ProtoReflect() protoreflect.Message { // Deprecated: Use HostResources.ProtoReflect.Descriptor instead. func (*HostResources) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{49} + return file_protocol_protocol_proto_rawDescGZIP(), []int{50} } func (x *HostResources) GetProcessRssBytes() *HostResourceValue { @@ -3469,7 +3531,7 @@ type ReflectAddressRequest struct { func (x *ReflectAddressRequest) Reset() { *x = ReflectAddressRequest{} - mi := &file_protocol_protocol_proto_msgTypes[50] + mi := &file_protocol_protocol_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3481,7 +3543,7 @@ func (x *ReflectAddressRequest) String() string { func (*ReflectAddressRequest) ProtoMessage() {} func (x *ReflectAddressRequest) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[50] + mi := &file_protocol_protocol_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3494,7 +3556,7 @@ func (x *ReflectAddressRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReflectAddressRequest.ProtoReflect.Descriptor instead. func (*ReflectAddressRequest) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{50} + return file_protocol_protocol_proto_rawDescGZIP(), []int{51} } func (x *ReflectAddressRequest) GetExecutorId() string { @@ -3520,7 +3582,7 @@ type ReflectAddressResponse struct { func (x *ReflectAddressResponse) Reset() { *x = ReflectAddressResponse{} - mi := &file_protocol_protocol_proto_msgTypes[51] + mi := &file_protocol_protocol_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3532,7 +3594,7 @@ func (x *ReflectAddressResponse) String() string { func (*ReflectAddressResponse) ProtoMessage() {} func (x *ReflectAddressResponse) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[51] + mi := &file_protocol_protocol_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3545,7 +3607,7 @@ func (x *ReflectAddressResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReflectAddressResponse.ProtoReflect.Descriptor instead. func (*ReflectAddressResponse) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{51} + return file_protocol_protocol_proto_rawDescGZIP(), []int{52} } func (x *ReflectAddressResponse) GetAddress() string { @@ -3566,7 +3628,7 @@ type EgressTest struct { func (x *EgressTest) Reset() { *x = EgressTest{} - mi := &file_protocol_protocol_proto_msgTypes[52] + mi := &file_protocol_protocol_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3578,7 +3640,7 @@ func (x *EgressTest) String() string { func (*EgressTest) ProtoMessage() {} func (x *EgressTest) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[52] + mi := &file_protocol_protocol_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3591,7 +3653,7 @@ func (x *EgressTest) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressTest.ProtoReflect.Descriptor instead. func (*EgressTest) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{52} + return file_protocol_protocol_proto_rawDescGZIP(), []int{53} } func (x *EgressTest) GetState() string { @@ -3626,7 +3688,7 @@ type ListenerChallenge struct { func (x *ListenerChallenge) Reset() { *x = ListenerChallenge{} - mi := &file_protocol_protocol_proto_msgTypes[53] + mi := &file_protocol_protocol_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3638,7 +3700,7 @@ func (x *ListenerChallenge) String() string { func (*ListenerChallenge) ProtoMessage() {} func (x *ListenerChallenge) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[53] + mi := &file_protocol_protocol_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3651,7 +3713,7 @@ func (x *ListenerChallenge) ProtoReflect() protoreflect.Message { // Deprecated: Use ListenerChallenge.ProtoReflect.Descriptor instead. func (*ListenerChallenge) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{53} + return file_protocol_protocol_proto_rawDescGZIP(), []int{54} } func (x *ListenerChallenge) GetTransport() string { @@ -3689,7 +3751,7 @@ type ConnectivityReport struct { func (x *ConnectivityReport) Reset() { *x = ConnectivityReport{} - mi := &file_protocol_protocol_proto_msgTypes[54] + mi := &file_protocol_protocol_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3701,7 +3763,7 @@ func (x *ConnectivityReport) String() string { func (*ConnectivityReport) ProtoMessage() {} func (x *ConnectivityReport) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[54] + mi := &file_protocol_protocol_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3714,7 +3776,7 @@ func (x *ConnectivityReport) ProtoReflect() protoreflect.Message { // Deprecated: Use ConnectivityReport.ProtoReflect.Descriptor instead. func (*ConnectivityReport) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{54} + return file_protocol_protocol_proto_rawDescGZIP(), []int{55} } func (x *ConnectivityReport) GetIpv4() *EgressTest { @@ -3774,7 +3836,7 @@ type ClockState struct { func (x *ClockState) Reset() { *x = ClockState{} - mi := &file_protocol_protocol_proto_msgTypes[55] + mi := &file_protocol_protocol_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3786,7 +3848,7 @@ func (x *ClockState) String() string { func (*ClockState) ProtoMessage() {} func (x *ClockState) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[55] + mi := &file_protocol_protocol_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3799,7 +3861,7 @@ func (x *ClockState) ProtoReflect() protoreflect.Message { // Deprecated: Use ClockState.ProtoReflect.Descriptor instead. func (*ClockState) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{55} + return file_protocol_protocol_proto_rawDescGZIP(), []int{56} } func (x *ClockState) GetState() string { @@ -3859,7 +3921,7 @@ type HostPlatform struct { func (x *HostPlatform) Reset() { *x = HostPlatform{} - mi := &file_protocol_protocol_proto_msgTypes[56] + mi := &file_protocol_protocol_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3871,7 +3933,7 @@ func (x *HostPlatform) String() string { func (*HostPlatform) ProtoMessage() {} func (x *HostPlatform) ProtoReflect() protoreflect.Message { - mi := &file_protocol_protocol_proto_msgTypes[56] + mi := &file_protocol_protocol_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3884,7 +3946,7 @@ func (x *HostPlatform) ProtoReflect() protoreflect.Message { // Deprecated: Use HostPlatform.ProtoReflect.Descriptor instead. func (*HostPlatform) Descriptor() ([]byte, []int) { - return file_protocol_protocol_proto_rawDescGZIP(), []int{56} + return file_protocol_protocol_proto_rawDescGZIP(), []int{57} } func (x *HostPlatform) GetOs() string { @@ -4170,7 +4232,7 @@ const file_protocol_protocol_proto_rawDesc = "" + "\x12disclosure_held_ms\x18\a \x01(\x03H\x02R\x10disclosureHeldMs\x88\x01\x01B\x12\n" + "\x10_installed_epochB\x16\n" + "\x14_last_refresh_age_msB\x15\n" + - "\x13_disclosure_held_ms\"\xeb\x05\n" + + "\x13_disclosure_held_ms\"\xb7\x06\n" + "\x12VantagePointReport\x12%\n" + "\x0eschema_version\x18\x01 \x01(\rR\rschemaVersion\x12 \n" + "\fscion_isd_as\x18\x02 \x01(\tR\n" + @@ -4190,7 +4252,11 @@ const file_protocol_protocol_proto_rawDesc = "" + "\x12counter_attachment\x18\f \x01(\tR\x11counterAttachment\x12&\n" + "\x0faddress_opt_out\x18\r \x01(\bR\raddressOptOut\x12\x1b\n" + "\thost_tags\x18\x0e \x03(\tR\bhostTags\x12H\n" + - "\raddress_check\x18\x0f \x01(\v2#.debuglet.protocol.AddressSelfCheckR\faddressCheck\"\xa0\x01\n" + + "\raddress_check\x18\x0f \x01(\v2#.debuglet.protocol.AddressSelfCheckR\faddressCheck\x12J\n" + + "\x0fnetwork_denials\x18\x10 \x01(\v2!.debuglet.protocol.NetworkDenialsR\x0enetworkDenials\"h\n" + + "\x0eNetworkDenials\x12-\n" + + "\x12refused_admissions\x18\x01 \x01(\x04R\x11refusedAdmissions\x12'\n" + + "\x0frevoked_sockets\x18\x02 \x01(\x04R\x0erevokedSockets\"\xa0\x01\n" + "\x10AddressSelfCheck\x121\n" + "\x12ipv4_local_private\x18\x01 \x01(\bH\x00R\x10ipv4LocalPrivate\x88\x01\x01\x12\x1d\n" + "\n" + @@ -4297,7 +4363,7 @@ func file_protocol_protocol_proto_rawDescGZIP() []byte { } var file_protocol_protocol_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_protocol_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 57) +var file_protocol_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 58) var file_protocol_protocol_proto_goTypes = []any{ (RunState)(0), // 0: debuglet.protocol.RunState (DebugletOutputStatus)(0), // 1: debuglet.protocol.DebugletOutputStatus @@ -4349,22 +4415,23 @@ var file_protocol_protocol_proto_goTypes = []any{ (*TaggingMode)(nil), // 47: debuglet.protocol.TaggingMode (*AttributionState)(nil), // 48: debuglet.protocol.AttributionState (*VantagePointReport)(nil), // 49: debuglet.protocol.VantagePointReport - (*AddressSelfCheck)(nil), // 50: debuglet.protocol.AddressSelfCheck - (*HostResourceValue)(nil), // 51: debuglet.protocol.HostResourceValue - (*HostResources)(nil), // 52: debuglet.protocol.HostResources - (*ReflectAddressRequest)(nil), // 53: debuglet.protocol.ReflectAddressRequest - (*ReflectAddressResponse)(nil), // 54: debuglet.protocol.ReflectAddressResponse - (*EgressTest)(nil), // 55: debuglet.protocol.EgressTest - (*ListenerChallenge)(nil), // 56: debuglet.protocol.ListenerChallenge - (*ConnectivityReport)(nil), // 57: debuglet.protocol.ConnectivityReport - (*ClockState)(nil), // 58: debuglet.protocol.ClockState - (*HostPlatform)(nil), // 59: debuglet.protocol.HostPlatform - (*timestamppb.Timestamp)(nil), // 60: google.protobuf.Timestamp + (*NetworkDenials)(nil), // 50: debuglet.protocol.NetworkDenials + (*AddressSelfCheck)(nil), // 51: debuglet.protocol.AddressSelfCheck + (*HostResourceValue)(nil), // 52: debuglet.protocol.HostResourceValue + (*HostResources)(nil), // 53: debuglet.protocol.HostResources + (*ReflectAddressRequest)(nil), // 54: debuglet.protocol.ReflectAddressRequest + (*ReflectAddressResponse)(nil), // 55: debuglet.protocol.ReflectAddressResponse + (*EgressTest)(nil), // 56: debuglet.protocol.EgressTest + (*ListenerChallenge)(nil), // 57: debuglet.protocol.ListenerChallenge + (*ConnectivityReport)(nil), // 58: debuglet.protocol.ConnectivityReport + (*ClockState)(nil), // 59: debuglet.protocol.ClockState + (*HostPlatform)(nil), // 60: debuglet.protocol.HostPlatform + (*timestamppb.Timestamp)(nil), // 61: google.protobuf.Timestamp } var file_protocol_protocol_proto_depIdxs = []int32{ 45, // 0: debuglet.protocol.HelloResponse.capabilities:type_name -> debuglet.protocol.ExecutorCapabilities 49, // 1: debuglet.protocol.HelloResponse.vantage_point:type_name -> debuglet.protocol.VantagePointReport - 60, // 2: debuglet.protocol.UploadRequest.start_time:type_name -> google.protobuf.Timestamp + 61, // 2: debuglet.protocol.UploadRequest.start_time:type_name -> google.protobuf.Timestamp 5, // 3: debuglet.protocol.UploadRequest.policy:type_name -> debuglet.protocol.DebugletPolicy 30, // 4: debuglet.protocol.UploadRequest.control_binding:type_name -> debuglet.protocol.ControlBinding 10, // 5: debuglet.protocol.BandwidthRequest.limits:type_name -> debuglet.protocol.DestinationLimit @@ -4376,15 +4443,15 @@ var file_protocol_protocol_proto_depIdxs = []int32{ 5, // 11: debuglet.protocol.DebugletAllocateRequest.policy:type_name -> debuglet.protocol.DebugletPolicy 10, // 12: debuglet.protocol.DebugletAllocateResponse.allocated_limits:type_name -> debuglet.protocol.DestinationLimit 30, // 13: debuglet.protocol.DebugletIdent.original_binding:type_name -> debuglet.protocol.ControlBinding - 60, // 14: debuglet.protocol.DebugletOutput.timestamp:type_name -> google.protobuf.Timestamp + 61, // 14: debuglet.protocol.DebugletOutput.timestamp:type_name -> google.protobuf.Timestamp 1, // 15: debuglet.protocol.DebugletOutputEnd.status:type_name -> debuglet.protocol.DebugletOutputStatus 25, // 16: debuglet.protocol.DebugletStreamRequest.ident:type_name -> debuglet.protocol.DebugletIdent 26, // 17: debuglet.protocol.DebugletStreamRequest.output:type_name -> debuglet.protocol.DebugletOutput 27, // 18: debuglet.protocol.DebugletStreamRequest.end:type_name -> debuglet.protocol.DebugletOutputEnd 27, // 19: debuglet.protocol.DebugletStreamResponse.end:type_name -> debuglet.protocol.DebugletOutputEnd 30, // 20: debuglet.protocol.RetainedRun.original_binding:type_name -> debuglet.protocol.ControlBinding - 60, // 21: debuglet.protocol.RetainedRun.started_at:type_name -> google.protobuf.Timestamp - 60, // 22: debuglet.protocol.RetainedRun.start_time:type_name -> google.protobuf.Timestamp + 61, // 21: debuglet.protocol.RetainedRun.started_at:type_name -> google.protobuf.Timestamp + 61, // 22: debuglet.protocol.RetainedRun.start_time:type_name -> google.protobuf.Timestamp 30, // 23: debuglet.protocol.InspectRetainedRunRequest.control_binding:type_name -> debuglet.protocol.ControlBinding 2, // 24: debuglet.protocol.InspectRetainedRunResponse.status:type_name -> debuglet.protocol.RetainedRunStatus 37, // 25: debuglet.protocol.InspectRetainedRunResponse.run:type_name -> debuglet.protocol.RetainedRun @@ -4393,58 +4460,59 @@ var file_protocol_protocol_proto_depIdxs = []int32{ 48, // 28: debuglet.protocol.ExecutorCapabilities.attribution:type_name -> debuglet.protocol.AttributionState 47, // 29: debuglet.protocol.ExecutorCapabilities.tagging:type_name -> debuglet.protocol.TaggingMode 46, // 30: debuglet.protocol.ExecutorCapabilities.icmp:type_name -> debuglet.protocol.ProbeState - 58, // 31: debuglet.protocol.VantagePointReport.clock:type_name -> debuglet.protocol.ClockState - 59, // 32: debuglet.protocol.VantagePointReport.platform:type_name -> debuglet.protocol.HostPlatform - 57, // 33: debuglet.protocol.VantagePointReport.connectivity:type_name -> debuglet.protocol.ConnectivityReport + 59, // 31: debuglet.protocol.VantagePointReport.clock:type_name -> debuglet.protocol.ClockState + 60, // 32: debuglet.protocol.VantagePointReport.platform:type_name -> debuglet.protocol.HostPlatform + 58, // 33: debuglet.protocol.VantagePointReport.connectivity:type_name -> debuglet.protocol.ConnectivityReport 46, // 34: debuglet.protocol.VantagePointReport.scion_paths:type_name -> debuglet.protocol.ProbeState - 52, // 35: debuglet.protocol.VantagePointReport.resources:type_name -> debuglet.protocol.HostResources - 50, // 36: debuglet.protocol.VantagePointReport.address_check:type_name -> debuglet.protocol.AddressSelfCheck - 51, // 37: debuglet.protocol.HostResources.process_rss_bytes:type_name -> debuglet.protocol.HostResourceValue - 51, // 38: debuglet.protocol.HostResources.open_fds:type_name -> debuglet.protocol.HostResourceValue - 51, // 39: debuglet.protocol.HostResources.state_available_bytes:type_name -> debuglet.protocol.HostResourceValue - 51, // 40: debuglet.protocol.HostResources.state_capacity_bytes:type_name -> debuglet.protocol.HostResourceValue - 55, // 41: debuglet.protocol.ConnectivityReport.ipv4:type_name -> debuglet.protocol.EgressTest - 55, // 42: debuglet.protocol.ConnectivityReport.ipv6:type_name -> debuglet.protocol.EgressTest - 56, // 43: debuglet.protocol.ConnectivityReport.listeners:type_name -> debuglet.protocol.ListenerChallenge - 53, // 44: debuglet.protocol.DispatcherService.ReflectAddress:input_type -> debuglet.protocol.ReflectAddressRequest - 13, // 45: debuglet.protocol.DispatcherService.Heartbeat:input_type -> debuglet.protocol.HeartbeatRequest - 16, // 46: debuglet.protocol.DispatcherService.Resources:input_type -> debuglet.protocol.ResourcesRequest - 18, // 47: debuglet.protocol.DispatcherService.DebugletState:input_type -> debuglet.protocol.DebugletStateRequest - 21, // 48: debuglet.protocol.DispatcherService.DebugletAllocate:input_type -> debuglet.protocol.DebugletAllocateRequest - 42, // 49: debuglet.protocol.DispatcherService.ExperimentReady:input_type -> debuglet.protocol.ExperimentReadyRequest - 23, // 50: debuglet.protocol.DispatcherService.DebugletExit:input_type -> debuglet.protocol.DebugletExitRequest - 28, // 51: debuglet.protocol.DispatcherService.DebugletStream:input_type -> debuglet.protocol.DebugletStreamRequest - 31, // 52: debuglet.protocol.DispatcherService.BindSession:input_type -> debuglet.protocol.BindSessionRequest - 33, // 53: debuglet.protocol.DispatcherService.RenewLease:input_type -> debuglet.protocol.RenewLeaseRequest - 3, // 54: debuglet.protocol.ExecutorService.Hello:input_type -> debuglet.protocol.HelloRequest - 6, // 55: debuglet.protocol.ExecutorService.Upload:input_type -> debuglet.protocol.UploadRequest - 8, // 56: debuglet.protocol.ExecutorService.Abort:input_type -> debuglet.protocol.AbortRequest - 11, // 57: debuglet.protocol.ExecutorService.Bandwidth:input_type -> debuglet.protocol.BandwidthRequest - 35, // 58: debuglet.protocol.ExecutorService.ProbeSession:input_type -> debuglet.protocol.ProbeSessionRequest - 38, // 59: debuglet.protocol.ExecutorService.InspectRetainedRun:input_type -> debuglet.protocol.InspectRetainedRunRequest - 40, // 60: debuglet.protocol.ExecutorService.VerifyTags:input_type -> debuglet.protocol.VerifyTagsRequest - 54, // 61: debuglet.protocol.DispatcherService.ReflectAddress:output_type -> debuglet.protocol.ReflectAddressResponse - 15, // 62: debuglet.protocol.DispatcherService.Heartbeat:output_type -> debuglet.protocol.HeartbeatResponse - 17, // 63: debuglet.protocol.DispatcherService.Resources:output_type -> debuglet.protocol.ResourcesResponse - 20, // 64: debuglet.protocol.DispatcherService.DebugletState:output_type -> debuglet.protocol.DebugletStateResponse - 22, // 65: debuglet.protocol.DispatcherService.DebugletAllocate:output_type -> debuglet.protocol.DebugletAllocateResponse - 44, // 66: debuglet.protocol.DispatcherService.ExperimentReady:output_type -> debuglet.protocol.ExperimentReadyResponse - 24, // 67: debuglet.protocol.DispatcherService.DebugletExit:output_type -> debuglet.protocol.DebugletExitResponse - 29, // 68: debuglet.protocol.DispatcherService.DebugletStream:output_type -> debuglet.protocol.DebugletStreamResponse - 32, // 69: debuglet.protocol.DispatcherService.BindSession:output_type -> debuglet.protocol.BindSessionResponse - 34, // 70: debuglet.protocol.DispatcherService.RenewLease:output_type -> debuglet.protocol.RenewLeaseResponse - 4, // 71: debuglet.protocol.ExecutorService.Hello:output_type -> debuglet.protocol.HelloResponse - 7, // 72: debuglet.protocol.ExecutorService.Upload:output_type -> debuglet.protocol.UploadResponse - 9, // 73: debuglet.protocol.ExecutorService.Abort:output_type -> debuglet.protocol.AbortResponse - 12, // 74: debuglet.protocol.ExecutorService.Bandwidth:output_type -> debuglet.protocol.BandwidthResponse - 36, // 75: debuglet.protocol.ExecutorService.ProbeSession:output_type -> debuglet.protocol.ProbeSessionResponse - 39, // 76: debuglet.protocol.ExecutorService.InspectRetainedRun:output_type -> debuglet.protocol.InspectRetainedRunResponse - 41, // 77: debuglet.protocol.ExecutorService.VerifyTags:output_type -> debuglet.protocol.VerifyTagsResponse - 61, // [61:78] is the sub-list for method output_type - 44, // [44:61] is the sub-list for method input_type - 44, // [44:44] is the sub-list for extension type_name - 44, // [44:44] is the sub-list for extension extendee - 0, // [0:44] is the sub-list for field type_name + 53, // 35: debuglet.protocol.VantagePointReport.resources:type_name -> debuglet.protocol.HostResources + 51, // 36: debuglet.protocol.VantagePointReport.address_check:type_name -> debuglet.protocol.AddressSelfCheck + 50, // 37: debuglet.protocol.VantagePointReport.network_denials:type_name -> debuglet.protocol.NetworkDenials + 52, // 38: debuglet.protocol.HostResources.process_rss_bytes:type_name -> debuglet.protocol.HostResourceValue + 52, // 39: debuglet.protocol.HostResources.open_fds:type_name -> debuglet.protocol.HostResourceValue + 52, // 40: debuglet.protocol.HostResources.state_available_bytes:type_name -> debuglet.protocol.HostResourceValue + 52, // 41: debuglet.protocol.HostResources.state_capacity_bytes:type_name -> debuglet.protocol.HostResourceValue + 56, // 42: debuglet.protocol.ConnectivityReport.ipv4:type_name -> debuglet.protocol.EgressTest + 56, // 43: debuglet.protocol.ConnectivityReport.ipv6:type_name -> debuglet.protocol.EgressTest + 57, // 44: debuglet.protocol.ConnectivityReport.listeners:type_name -> debuglet.protocol.ListenerChallenge + 54, // 45: debuglet.protocol.DispatcherService.ReflectAddress:input_type -> debuglet.protocol.ReflectAddressRequest + 13, // 46: debuglet.protocol.DispatcherService.Heartbeat:input_type -> debuglet.protocol.HeartbeatRequest + 16, // 47: debuglet.protocol.DispatcherService.Resources:input_type -> debuglet.protocol.ResourcesRequest + 18, // 48: debuglet.protocol.DispatcherService.DebugletState:input_type -> debuglet.protocol.DebugletStateRequest + 21, // 49: debuglet.protocol.DispatcherService.DebugletAllocate:input_type -> debuglet.protocol.DebugletAllocateRequest + 42, // 50: debuglet.protocol.DispatcherService.ExperimentReady:input_type -> debuglet.protocol.ExperimentReadyRequest + 23, // 51: debuglet.protocol.DispatcherService.DebugletExit:input_type -> debuglet.protocol.DebugletExitRequest + 28, // 52: debuglet.protocol.DispatcherService.DebugletStream:input_type -> debuglet.protocol.DebugletStreamRequest + 31, // 53: debuglet.protocol.DispatcherService.BindSession:input_type -> debuglet.protocol.BindSessionRequest + 33, // 54: debuglet.protocol.DispatcherService.RenewLease:input_type -> debuglet.protocol.RenewLeaseRequest + 3, // 55: debuglet.protocol.ExecutorService.Hello:input_type -> debuglet.protocol.HelloRequest + 6, // 56: debuglet.protocol.ExecutorService.Upload:input_type -> debuglet.protocol.UploadRequest + 8, // 57: debuglet.protocol.ExecutorService.Abort:input_type -> debuglet.protocol.AbortRequest + 11, // 58: debuglet.protocol.ExecutorService.Bandwidth:input_type -> debuglet.protocol.BandwidthRequest + 35, // 59: debuglet.protocol.ExecutorService.ProbeSession:input_type -> debuglet.protocol.ProbeSessionRequest + 38, // 60: debuglet.protocol.ExecutorService.InspectRetainedRun:input_type -> debuglet.protocol.InspectRetainedRunRequest + 40, // 61: debuglet.protocol.ExecutorService.VerifyTags:input_type -> debuglet.protocol.VerifyTagsRequest + 55, // 62: debuglet.protocol.DispatcherService.ReflectAddress:output_type -> debuglet.protocol.ReflectAddressResponse + 15, // 63: debuglet.protocol.DispatcherService.Heartbeat:output_type -> debuglet.protocol.HeartbeatResponse + 17, // 64: debuglet.protocol.DispatcherService.Resources:output_type -> debuglet.protocol.ResourcesResponse + 20, // 65: debuglet.protocol.DispatcherService.DebugletState:output_type -> debuglet.protocol.DebugletStateResponse + 22, // 66: debuglet.protocol.DispatcherService.DebugletAllocate:output_type -> debuglet.protocol.DebugletAllocateResponse + 44, // 67: debuglet.protocol.DispatcherService.ExperimentReady:output_type -> debuglet.protocol.ExperimentReadyResponse + 24, // 68: debuglet.protocol.DispatcherService.DebugletExit:output_type -> debuglet.protocol.DebugletExitResponse + 29, // 69: debuglet.protocol.DispatcherService.DebugletStream:output_type -> debuglet.protocol.DebugletStreamResponse + 32, // 70: debuglet.protocol.DispatcherService.BindSession:output_type -> debuglet.protocol.BindSessionResponse + 34, // 71: debuglet.protocol.DispatcherService.RenewLease:output_type -> debuglet.protocol.RenewLeaseResponse + 4, // 72: debuglet.protocol.ExecutorService.Hello:output_type -> debuglet.protocol.HelloResponse + 7, // 73: debuglet.protocol.ExecutorService.Upload:output_type -> debuglet.protocol.UploadResponse + 9, // 74: debuglet.protocol.ExecutorService.Abort:output_type -> debuglet.protocol.AbortResponse + 12, // 75: debuglet.protocol.ExecutorService.Bandwidth:output_type -> debuglet.protocol.BandwidthResponse + 36, // 76: debuglet.protocol.ExecutorService.ProbeSession:output_type -> debuglet.protocol.ProbeSessionResponse + 39, // 77: debuglet.protocol.ExecutorService.InspectRetainedRun:output_type -> debuglet.protocol.InspectRetainedRunResponse + 41, // 78: debuglet.protocol.ExecutorService.VerifyTags:output_type -> debuglet.protocol.VerifyTagsResponse + 62, // [62:79] is the sub-list for method output_type + 45, // [45:62] is the sub-list for method input_type + 45, // [45:45] is the sub-list for extension type_name + 45, // [45:45] is the sub-list for extension extendee + 0, // [0:45] is the sub-list for field type_name } func init() { file_protocol_protocol_proto_init() } @@ -4463,17 +4531,17 @@ func file_protocol_protocol_proto_init() { file_protocol_protocol_proto_msgTypes[34].OneofWrappers = []any{} file_protocol_protocol_proto_msgTypes[36].OneofWrappers = []any{} file_protocol_protocol_proto_msgTypes[45].OneofWrappers = []any{} - file_protocol_protocol_proto_msgTypes[47].OneofWrappers = []any{} file_protocol_protocol_proto_msgTypes[48].OneofWrappers = []any{} - file_protocol_protocol_proto_msgTypes[55].OneofWrappers = []any{} + file_protocol_protocol_proto_msgTypes[49].OneofWrappers = []any{} file_protocol_protocol_proto_msgTypes[56].OneofWrappers = []any{} + file_protocol_protocol_proto_msgTypes[57].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_protocol_protocol_proto_rawDesc), len(file_protocol_protocol_proto_rawDesc)), NumEnums: 3, - NumMessages: 57, + NumMessages: 58, NumExtensions: 0, NumServices: 2, }, diff --git a/protocol/protocol.proto b/protocol/protocol.proto index e0ec45ff..09638b56 100644 --- a/protocol/protocol.proto +++ b/protocol/protocol.proto @@ -414,6 +414,14 @@ message VantagePointReport { // (pkg/wire HostTags). Read at registration; a malformed list is dropped. repeated string host_tags = 14; AddressSelfCheck address_check = 15; // Optional; absent is unknown. + NetworkDenials network_denials = 16; // Operator-only policy observations; absent is unknown. +} + +// Counts for this executor session, observed at network policy boundaries. +// These do not count denied packets/bytes or prove end-to-end enforcement. +message NetworkDenials { + uint64 refused_admissions = 1; + uint64 revoked_sockets = 2; } // What the executor learned from its last address observation round, reduced From b7d37f1fbd428ff2ce7f484c8f1793e90e4ea885 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:16:14 +0200 Subject: [PATCH 2/6] Rebuild experimental guest consumers and retain language measurement inputs --- .github/workflows/guest-languages.yml | 33 +++ docs/development/guest-languages.md | 111 +++++++--- examples/debuglets/c/README.md | 14 +- examples/debuglets/javascript/hello.js | 3 + examples/debuglets/rust/README.md | 14 +- examples/debuglets/rust/debuglet/LICENSE | 202 ++++++++++++++++++ pkg/debuglet/abi_foreign_guests_test.go | 50 ++++- .../testdata/guest_c/guest_c.wasm.json | 6 +- .../testdata/guest_rust/guest_rust.wasm.json | 10 +- scripts/ci-guest-languages.sh | 67 ++++++ scripts/guest-measure-inputs.sh | 21 ++ scripts/measure-guest-languages.sh | 61 ++++++ tools/guest-measure/main.go | 109 ++++++++++ 13 files changed, 662 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/guest-languages.yml create mode 100644 examples/debuglets/javascript/hello.js create mode 100644 examples/debuglets/rust/debuglet/LICENSE create mode 100644 scripts/ci-guest-languages.sh create mode 100644 scripts/guest-measure-inputs.sh create mode 100644 scripts/measure-guest-languages.sh create mode 100644 tools/guest-measure/main.go diff --git a/.github/workflows/guest-languages.yml b/.github/workflows/guest-languages.yml new file mode 100644 index 00000000..0888af36 --- /dev/null +++ b/.github/workflows/guest-languages.yml @@ -0,0 +1,33 @@ +name: Experimental guest sources + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guest-languages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + guest-languages: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: bash scripts/ci-guest-languages.sh + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: guest-languages-${{ github.sha }}-${{ github.run_attempt }} + path: .cache/guest-languages/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 diff --git a/docs/development/guest-languages.md b/docs/development/guest-languages.md index a70e814b..29b2a720 100644 --- a/docs/development/guest-languages.md +++ b/docs/development/guest-languages.md @@ -12,26 +12,85 @@ under those conditions. It is a measurement, not a support commitment. ## Measurements -Each guest prints one line. Every module was built in a pinned container and -run five times (three for Python with a filesystem) by a small runner that uses -wazero v1.12.0 with the executor's runtime and module configuration (the same -interpreter, memory limit and module options as -`internal/executor/debuglet/debuglet.go`, without the `env` host module, which -none of these hello guests imports). Peak resident memory is the runner -process's maximum RSS as reported by `/usr/bin/time -v`; the runner alone with -an empty module uses 6 MiB. Times are medians on one x86-64 Linux build host, -limited to three CPUs; treat them as orders of magnitude. - -| Language | Toolchain | Build command | Module size | Build time | Engine compile | Start to first output | Peak RSS | Host operations available | -| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | -| Go | Go 1.26.8 | `GOOS=wasip1 GOARCH=wasm go build -trimpath` (`examples/debuglets/go/hello-local`) | 2,600,518 B | 3.8 s cold cache, 0.04 s warm | 116 ms | 14 ms | 69 MiB | All of ABI v1 and `debuglet_io_v1` through `pkg/debuglet`; arguments and output | -| Rust | rustc 1.90.0, `wasm32-wasip1` | `cargo build --release --target wasm32-wasip1` (`examples/debuglets/rust/helloworld`) | 65,127 B | 0.2 s | 3 ms | 0.6 ms | 9.3 MiB | TCP, TLS, listener and ICMPv4 through the experimental crate; arguments and output | -| C | wasi-sdk 25 (clang 19.1.5) | `clang -O2 main.c -lm` (`examples/debuglets/c/helloworld`) | 50,067 B | 0.07 s | 0.8 ms | 0.1 ms | 7.0 MiB | TCP, TLS, listener and ICMPv4 through the experimental header; arguments and output | -| JavaScript | Javy 9.1.0 (static module, QuickJS embedded) | `javy build -o hello.wasm hello.js` | 1,358,167 B | 4.9 s | 73 ms | 1.4 ms | 45 MiB | Output only: no `env` imports and no arguments | -| Python | CPython 3.14.7 WASI build (wasi-sdk 24), debug sections stripped | none (prebuilt interpreter, script passed with `-c`) | 7,630,023 B (30,522,756 B as released) | none | 255 ms | fails at startup | 158 MiB until the failure | None on the engine; see below | - -Python with its standard library mounted read-only, which the engine does not -offer, printed its line after 1.5 s at a peak RSS of 212 MiB. +Each guest prints one line. The retained Rust/C figures below are from the +initial toolchain assessment. The Go, JavaScript and Python rows were reproduced +with the checked-in runner and inputs on x86-64 Linux. Times are medians of five +fresh processes (three for the Python filesystem comparison), not performance +thresholds. Peak RSS includes the runner process and compilation; it is not the +guest's linear memory. The runner uses the executor's wazero interpreter, +256 MiB memory limit, clocks and randomness, without networking host modules. +It records compilation separately from time to first stdout and stderr, so +Python's startup error is never counted as successful first output. + +| Language | Toolchain | Module size | Build time | Engine compile | First stdout | Peak RSS | Host operations available | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| Go | Go 1.26.8 | 2,600,518 B | 3.39 s cold, 0.036 s warm | 86 ms | 10 ms | 71 MiB | ABI v1 and `debuglet_io_v1` through `pkg/debuglet`; arguments and output | +| Rust | rustc 1.90.0, `wasm32-wasip1` | 65,127 B | 0.2 s | 3 ms | 0.6 ms | 9.3 MiB | Experimental TCP, TLS, listener and ICMPv4 bindings; arguments and output | +| C | wasi-sdk 25 (clang 19.1.5) | 50,067 B | 0.07 s | 0.8 ms | 0.1 ms | 7.0 MiB | Experimental TCP, TLS, listener and ICMPv4 bindings; arguments and output | +| JavaScript | Javy 9.1.0 static module | 1,358,212 B | 2.19 s | 60 ms | 1.2 ms | 47 MiB | Output only; no `env` imports or argument channel | +| Python | CPython 3.14.7 WASI build, stripped | 7,630,023 B | Prebuilt interpreter | 227 ms | Startup fails | 170 MiB | None on the engine | + +The Python module is 30,522,756 bytes before stripping. With its standard +library mounted read-only, which the executor does not offer, it prints after +1.31 s at 203 MiB peak RSS. These are measurements, not a support commitment. + +## Reproduce the measurements + +On Linux amd64 with Docker, curl, gzip, `sha256sum` and Python 3: + +```sh +bash scripts/measure-guest-languages.sh +``` + +The script keeps JSON samples, build times, toolchain versions and module +SHA-256 hashes under `.cache/guest-measure/`. It builds +`tools/guest-measure`, Go's `examples/debuglets/go/hello-local`, and the retained +`examples/debuglets/javascript/hello.js`; Python runs +`python -c "print('Hello from Debuglet! (Python)')"`. It checks the expected +missing-`encodings` startup failure without a filesystem, then runs the separate +filesystem diagnostic. The runner has a 30-second deadline and bounded output. +Go runs in the digest-pinned image in `deploy/ci/images.env` with three CPUs and +5 GiB RAM; the script records the actual Go and wazero versions. Javy runs as a +native Linux executable. Rust/C hello figures above used `cargo build --release +--target wasm32-wasip1` in `examples/debuglets/rust/helloworld` and wasi-sdk 25 +`clang -O2 main.c -lm` in `examples/debuglets/c/helloworld`. + +The download script checks these exact archives before extraction: + +| Input | SHA-256 | +| --- | --- | +| [Javy 9.1.0 Linux amd64 gzip](https://github.com/bytecodealliance/javy/releases/download/v9.1.0/javy-x86_64-linux-v9.1.0.gz) | `a68b122d48eb3dfc1b801d4e14c39271fde3638243d3272d206e376ac9189e39` | +| [CPython 3.14.7 wasi-sdk 24 zip](https://github.com/brettcannon/cpython-wasi-build/releases/download/v3.14.7/python-3.14.7-wasi_sdk-24.zip) | `2e064d3fb8172471d39d741348efa722349c40b96301f69968dff714999c584b` | + +The Python archive is Brett Cannon's experimental WASI build, not an official +CPython release artifact. Stripping uses `llvm-strip --strip-all` from wasi-sdk +25 pinned in the script. The measured modules have hashes: + +- Go: `bcdf89c2102c404ab40091acef44ff00fbd5dd6e77f3bdd64b09caff3b5b3f8a`. +- JavaScript: `853baf0024ebbe9aa40784b69e45cd296e68b2c620d0a26386d9e0f90e901ba8`. +- Python: `7fe2dead89e0f64016c79142c0badd45e95b66808fc30ea904d149deda92b8a0`. + +## Rust and C source and consumer checks + +```sh +bash scripts/ci-guest-languages.sh +``` + +The `guest-languages` CI job runs this same script. It rebuilds the retained +TCP fixtures with digest-pinned Rust 1.90 and wasi-sdk 25 images, requires exact +binary equality, and checks source hashes in their `.wasm.json` records. It +also packages the Rust crate with its license, copies the C header and license, +and builds consumers in empty directories from those deliverables. Both fresh +consumers run against the current executor host for a TCP read larger than the +ABI buffer followed by EOF, listener echo, and connection-refusal behavior. +Artifacts include the source packages, consumer modules, SHA-256 manifest and +JSON test results under `.cache/guest-languages/`. + +This validates local source packaging and the tested TCP subset. It does not +publish a crate or release, cover every binding, or establish a maintainer and +support policy. Changing a binding requires deliberately rebuilding and +updating the retained module's source and binary record together; a stale +fixture fails the checks. ## JavaScript @@ -70,9 +129,10 @@ The maintainers decide; this is the proposal the measurements support. and the repository builds and tests it. - **Rust and C: experimental.** They are small and fast, and the test suite runs one retained guest per language on the engine, but their bindings cover - part of the ABI and no CI lane builds them. Promoting either would need the - missing bindings (UDP, address getters, `drain_connection`, - `debuglet_io_v1`), a CI build with the pinned toolchain and an owner. + part of the ABI. CI rebuilds their sources and tests fresh package consumers. + Promoting either still needs the missing bindings (UDP, address getters, + `drain_connection`, `debuglet_io_v1`), a release/distribution decision and an + accountable owner accepting the support policy. - **JavaScript: unsupported.** A guest can print but cannot take arguments or measure anything. Supporting it would mean maintaining a Javy plugin that exposes the ABI, an argument channel, a pinned Javy release and a CI lane. @@ -80,5 +140,6 @@ The maintainers decide; this is the proposal the measurements support. mean maintaining a custom CPython WASI build with an embedded standard library and an ABI extension module, and accepting its start-up cost. -No runtime, build path or CI lane for JavaScript or Python is part of the -repository. +The JavaScript/Python scripts are reproducible assessment tools, not supported +SDKs or executor runtime additions. No maintainer support decision is implied +by these measurements. diff --git a/examples/debuglets/c/README.md b/examples/debuglets/c/README.md index d245c5a9..b5fcae69 100644 --- a/examples/debuglets/c/README.md +++ b/examples/debuglets/c/README.md @@ -1,12 +1,12 @@ # C debuglets -These examples and the header are experimental. They are maintained with the repository as experimental; there is no separate release or version promise. The project's CI does not build them. Use the [Go examples](../go/README.md) for supported development; the Go SDK in [`pkg/debuglet`](../../../pkg/debuglet) is the complete reference for the host interface. +These examples and the header are experimental. They are maintained with the repository as experimental; there is no separate release or version promise. The `guest-languages` CI job rebuilds them and tests fresh source-package consumers. Use the [Go examples](../go/README.md) for supported development; the Go SDK in [`pkg/debuglet`](../../../pkg/debuglet) is the complete reference for the host interface. The examples use [`common/debuglet_api.h`](common/debuglet_api.h) to call the executor's WebAssembly imports. Its declarations are part of guest ABI `debuglet-go-wasi-imports-v1` and use exactly the signatures that ABI freezes. ## What is checked -A C guest built on the header is retained in [`pkg/debuglet/testdata/guest_c`](../../../pkg/debuglet/testdata/guest_c), and `TestForeignGuestsOnCurrentHost` runs it on the executor's engine: a TCP read until end of stream, a listener that echoes one message, and a refused connection. The retained module is rebuilt by hand when the header changes; its record names the toolchain. +A C guest built on the header is retained in [`pkg/debuglet/testdata/guest_c`](../../../pkg/debuglet/testdata/guest_c), and `TestForeignGuestsOnCurrentHost` runs it on the executor's engine: a TCP read until end of stream, a listener that echoes one message, and a refused connection. The retained module and its source-hash record are deliberately updated when the header changes; CI checks the rebuild. Tested toolchain: wasi-sdk 25 (`ghcr.io/webassembly/wasi-sdk:wasi-sdk-25` container), target `wasm32-wasip1`. @@ -42,3 +42,13 @@ cp /path/to/debuglet/examples/debuglets/c/common/debuglet_api.h . ``` The header is licensed under the Apache License 2.0, like the rest of the repository. + +### Rebuild and consumer validation + +From the repository root on Linux amd64, run +`bash scripts/ci-guest-languages.sh`. CI uses the same digest-pinned compilers +to rebuild the retained fixtures, check source/binary hashes and run fresh +package consumers against the current host. Source packages and test results +are written under `.cache/guest-languages/`. These are experimental source +packages; no registry publication or supported release is implied. See +[guest language coverage and limits](../../../docs/development/guest-languages.md). diff --git a/examples/debuglets/javascript/hello.js b/examples/debuglets/javascript/hello.js new file mode 100644 index 00000000..1c5969f1 --- /dev/null +++ b/examples/debuglets/javascript/hello.js @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich +console.log("Hello from Debuglet! (JavaScript)"); diff --git a/examples/debuglets/rust/README.md b/examples/debuglets/rust/README.md index 940ea6a9..42ae56f6 100644 --- a/examples/debuglets/rust/README.md +++ b/examples/debuglets/rust/README.md @@ -1,10 +1,10 @@ # Rust debuglets -These examples and the `debuglet` crate are experimental. They are maintained with the repository as experimental; there is no separate release or version promise. The project's CI does not build them. Use the [Go examples](../go/README.md) for supported development; the Go SDK in [`pkg/debuglet`](../../../pkg/debuglet) is the complete reference for the host interface. +These examples and the `debuglet` crate are experimental. They are maintained with the repository as experimental; there is no separate release or version promise. The `guest-languages` CI job rebuilds them and tests fresh source-package consumers. Use the [Go examples](../go/README.md) for supported development; the Go SDK in [`pkg/debuglet`](../../../pkg/debuglet) is the complete reference for the host interface. ## What is checked -The crate's imports are part of guest ABI `debuglet-go-wasi-imports-v1` and use exactly the signatures that ABI freezes. A Rust guest built on the crate is retained in [`pkg/debuglet/testdata/guest_rust`](../../../pkg/debuglet/testdata/guest_rust), and `TestForeignGuestsOnCurrentHost` runs it on the executor's engine: a TCP read until end of stream, a listener that echoes one message, and a refused connection. The retained module is rebuilt by hand when the crate changes; its record names the toolchain. +The crate's imports are part of guest ABI `debuglet-go-wasi-imports-v1` and use exactly the signatures that ABI freezes. A Rust guest built on the crate is retained in [`pkg/debuglet/testdata/guest_rust`](../../../pkg/debuglet/testdata/guest_rust), and `TestForeignGuestsOnCurrentHost` runs it on the executor's engine: a TCP read until end of stream, a listener that echoes one message, and a refused connection. The retained module and its source-hash record are deliberately updated when the crate changes; CI checks the rebuild. Tested toolchain: Rust 1.90.0 (`rust:1.90-bookworm` container) with the `wasm32-wasip1` target. The crate declares `rust-version = "1.78"`, the first release with that target name. @@ -46,3 +46,13 @@ cargo build --release --target wasm32-wasip1 ``` The crate is licensed under the Apache License 2.0, like the rest of the repository. + +### Rebuild and consumer validation + +From the repository root on Linux amd64, run +`bash scripts/ci-guest-languages.sh`. CI uses the same digest-pinned compilers +to rebuild the retained fixtures, check source/binary hashes and run fresh +package consumers against the current host. Source packages and test results +are written under `.cache/guest-languages/`. These are experimental source +packages; no registry publication or supported release is implied. See +[guest language coverage and limits](../../../docs/development/guest-languages.md). diff --git a/examples/debuglets/rust/debuglet/LICENSE b/examples/debuglets/rust/debuglet/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/examples/debuglets/rust/debuglet/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pkg/debuglet/abi_foreign_guests_test.go b/pkg/debuglet/abi_foreign_guests_test.go index a1b57ad5..f9236850 100644 --- a/pkg/debuglet/abi_foreign_guests_test.go +++ b/pkg/debuglet/abi_foreign_guests_test.go @@ -21,6 +21,7 @@ import ( "io" "net" "os" + "path/filepath" "strconv" "testing" "time" @@ -28,14 +29,15 @@ import ( // foreignGuestRecord is the record tracked beside a retained foreign guest. type foreignGuestRecord struct { - GuestABI string `json:"guest_abi"` - Language string `json:"language"` - Toolchain string `json:"toolchain"` - Image string `json:"image"` - Target string `json:"target"` - Build string `json:"build"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` + GuestABI string `json:"guest_abi"` + Language string `json:"language"` + Toolchain string `json:"toolchain"` + Image string `json:"image"` + Target string `json:"target"` + Build string `json:"build"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + Sources map[string]string `json:"sources"` } // foreignGuests are the retained guests, by language. @@ -68,6 +70,25 @@ func TestForeignGuestsOnCurrentHost(t *testing.T) { } } +// The source-build lane provides consumers made in empty directories from the +// packaged crate and copied header. Ordinary tests still run the retained guests. +func TestBuiltForeignConsumersOnCurrentHost(t *testing.T) { + dir := os.Getenv("DEBUGLET_FOREIGN_CONSUMER_DIR") + if dir == "" { + t.Skip("run scripts/ci-guest-languages.sh to build fresh consumers") + } + for _, language := range []string{"c", "rust"} { + t.Run(language, func(t *testing.T) { + wasm, err := os.ReadFile(filepath.Join(dir, "consumer_"+language+".wasm")) + if err != nil { + t.Fatal(err) + } + requireABIV1Imports(t, wasm) + runForeignGuestCases(t, wasm) + }) + } +} + // retainedForeignGuest reads a retained module and fails unless its record // still describes it. func retainedForeignGuest(t *testing.T, language, module, recordPath string) []byte { @@ -92,6 +113,19 @@ func retainedForeignGuest(t *testing.T, language, module, recordPath string) []b if record.Language != language { t.Fatalf("%s records language %q, want %q", recordPath, record.Language, language) } + if len(record.Sources) == 0 { + t.Fatal("retained foreign guest has no source digests") + } + for path, want := range record.Sources { + source, err := os.ReadFile(filepath.Join("../..", path)) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(source) + if hex.EncodeToString(sum[:]) != want { + t.Fatalf("%s changed since the retained guest was built; rebuild the guest and its record", path) + } + } if record.Bytes != int64(len(wasm)) { t.Fatalf("retained guest is %d bytes, its record says %d", len(wasm), record.Bytes) } diff --git a/pkg/debuglet/testdata/guest_c/guest_c.wasm.json b/pkg/debuglet/testdata/guest_c/guest_c.wasm.json index ac66599c..7cef9db3 100644 --- a/pkg/debuglet/testdata/guest_c/guest_c.wasm.json +++ b/pkg/debuglet/testdata/guest_c/guest_c.wasm.json @@ -6,5 +6,9 @@ "target": "wasm32-wasip1", "build": "clang --target=wasm32-wasip1 -Oz -s -Wall -Werror -o guest_c.wasm main.c", "sha256": "ad4523fbce3146f41feca0ff1ddbace82e5b5863faa8f100882eb21c1cd18d2b", - "bytes": 27813 + "bytes": 27813, + "sources": { + "examples/debuglets/c/common/debuglet_api.h": "03e0e2715cdac7389dcdf2b08f469810e1154983077fb859ed23ef5912ad011b", + "pkg/debuglet/testdata/guest_c/main.c": "c81bd5b873c6bf825ebefb8c1c1c3600d47f08d11c987b7d00dcae0cb16d9b1a" + } } diff --git a/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json b/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json index b9a6a198..d64246a3 100644 --- a/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json +++ b/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json @@ -6,5 +6,13 @@ "target": "wasm32-wasip1", "build": "RUSTFLAGS=--remap-path-prefix=/= cargo build --release --locked --target wasm32-wasip1 (opt-level = \"s\", strip = true)", "sha256": "fa81d338109e9cb398902105a3b0ecad0bb2f8dc39163a0974cdc98fc31f4d96", - "bytes": 66914 + "bytes": 66914, + "sources": { + "examples/debuglets/rust/debuglet/Cargo.toml": "ac3e6be1bda3a5300987503c95240922fd8fb312c5557fdaaf06acf98b07daf6", + "examples/debuglets/rust/debuglet/src/lib.rs": "85eb88c5fee3593a0330dae065d5118730dc5c5743bbcd2153458d9e50947cb2", + "pkg/debuglet/testdata/guest_rust/Cargo.toml": "5630b5362026c40331169ed77dd01381d85e65ec8590aeaee3c1202c7c4d8111", + "pkg/debuglet/testdata/guest_rust/Cargo.lock": "87df50f3798c3dece1e84d61704c4ee0055644c69393d0142e45f515a7be5930", + "pkg/debuglet/testdata/guest_rust/src/main.rs": "a91921ba080d11909ccda25df29d8305cf080a87852301352d11a22c57b8ac8b", + "examples/debuglets/rust/debuglet/LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + } } diff --git a/scripts/ci-guest-languages.sh b/scripts/ci-guest-languages.sh new file mode 100644 index 00000000..82192fe0 --- /dev/null +++ b/scripts/ci-guest-languages.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ETH Zurich +# Rebuild experimental bindings and test fresh consumers on the real host. +set -euo pipefail +cd "$(dirname "$0")/.." +root=$PWD +out=$root/.cache/guest-languages +mkdir -p "$out" +. deploy/ci/images.env +rust=rust:1.90-bookworm@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f +wasi=ghcr.io/webassembly/wasi-sdk:wasi-sdk-25@sha256:fdebde86990902087ecc470bf993ffec4d646e8fbd2e68c290a44120437622d0 +scratch=$(mktemp -d) +cleanup() { + docker run --rm -v "$scratch:/out" "$wasi" chown -R "$(id -u):$(id -g)" /out + rm -rf -- "$scratch" +} +trap cleanup EXIT + +docker run --rm --init --cpus 3 --memory 5g \ + -v "$root:/src:ro" -v "$scratch:/out" -w /src "$wasi" bash -ceu ' + export PATH=/opt/wasi-sdk/bin:$PATH + clang --version + clang --target=wasm32-wasip1 -Oz -s -Wall -Werror \ + -o /out/guest_c.wasm pkg/debuglet/testdata/guest_c/main.c + mkdir /out/c-consumer + cp examples/debuglets/c/common/debuglet_api.h LICENSE /out/c-consumer/ + sed '\''s|../../../../examples/debuglets/c/common/debuglet_api.h|debuglet_api.h|'\'' \ + pkg/debuglet/testdata/guest_c/main.c > /out/c-consumer/main.c + clang --target=wasm32-wasip1 -Oz -s -Wall -Werror \ + -o /out/consumer_c.wasm /out/c-consumer/main.c + ' +docker run --rm --init --cpus 3 --memory 5g \ + -v "$root:/src:ro" -v "$scratch:/out" -w /src "$rust" bash -ceu ' + rustc --version + rustup target add wasm32-wasip1 + export CARGO_TARGET_DIR=/out/target + export RUSTFLAGS=--remap-path-prefix=/src/= + cargo build --release --locked --target wasm32-wasip1 \ + --manifest-path pkg/debuglet/testdata/guest_rust/Cargo.toml + cp /out/target/wasm32-wasip1/release/guest_rust.wasm /out/ + cargo package --allow-dirty --no-verify \ + --manifest-path examples/debuglets/rust/debuglet/Cargo.toml + mkdir /out/package + tar -xf /out/target/package/debuglet-0.1.0.crate -C /out/package + cmp LICENSE /out/package/debuglet-0.1.0/LICENSE + cargo new --bin --vcs none /out/rust-consumer + cp pkg/debuglet/testdata/guest_rust/src/main.rs /out/rust-consumer/src/main.rs + printf '\''debuglet = { path = "/out/package/debuglet-0.1.0" }\n'\'' >> /out/rust-consumer/Cargo.toml + cargo build --release --target wasm32-wasip1 --manifest-path /out/rust-consumer/Cargo.toml + cp /out/target/wasm32-wasip1/release/rust-consumer.wasm /out/consumer_rust.wasm + ' +for language in c rust; do + cmp "$scratch/guest_$language.wasm" "pkg/debuglet/testdata/guest_$language/guest_$language.wasm" + cp "$scratch/consumer_$language.wasm" "$out/" +done +cp "$scratch/target/package/debuglet-0.1.0.crate" "$out/" +tar -czf "$out/debuglet-c-source.tar.gz" -C "$scratch/c-consumer" debuglet_api.h LICENSE +docker run --rm --init --cpus 3 --memory 5g \ + -v "$root:/src" -w /src -e GOTOOLCHAIN=local -e GOMAXPROCS=3 \ + -e DEBUGLET_FOREIGN_CONSUMER_DIR=/src/.cache/guest-languages \ + -v debuglet-ci-go-mod:/go/pkg/mod -v debuglet-ci-go-build:/go/build-cache \ + -e GOCACHE=/go/build-cache "${DEBUGLET_CI_BASE_IMAGE}@${DEBUGLET_CI_BASE_DIGEST}" \ + go test -json ./pkg/debuglet -run 'TestForeignGuestsOnCurrentHost|TestBuiltForeignConsumersOnCurrentHost' \ + -count=1 -timeout=120s > "$out/tests.json" +sha256sum "$out"/*.wasm "$out"/*.crate "$out"/*.tar.gz > "$out/artifacts.sha256" +cat "$out/artifacts.sha256" diff --git a/scripts/guest-measure-inputs.sh b/scripts/guest-measure-inputs.sh new file mode 100644 index 00000000..0c9b1642 --- /dev/null +++ b/scripts/guest-measure-inputs.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ETH Zurich +# Fetch the exact experimental runtimes measured in docs/development/guest-languages.md. +set -euo pipefail +out=${1:?usage: guest-measure-inputs.sh OUTPUT_DIRECTORY} +mkdir -p "$out" +curl --fail --location --retry 2 --max-time 120 \ + https://github.com/bytecodealliance/javy/releases/download/v9.1.0/javy-x86_64-linux-v9.1.0.gz \ + -o "$out/javy.gz" +curl --fail --location --retry 2 --max-time 120 \ + https://github.com/brettcannon/cpython-wasi-build/releases/download/v3.14.7/python-3.14.7-wasi_sdk-24.zip \ + -o "$out/python.zip" +(cd "$out" && sha256sum --check <<'SUMS' +a68b122d48eb3dfc1b801d4e14c39271fde3638243d3272d206e376ac9189e39 javy.gz +2e064d3fb8172471d39d741348efa722349c40b96301f69968dff714999c584b python.zip +SUMS +) +gzip -dc "$out/javy.gz" > "$out/javy" +chmod +x "$out/javy" +python3 -m zipfile -e "$out/python.zip" "$out/python" diff --git a/scripts/measure-guest-languages.sh b/scripts/measure-guest-languages.sh new file mode 100644 index 00000000..0bd15fe0 --- /dev/null +++ b/scripts/measure-guest-languages.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ETH Zurich +# Linux amd64: Docker, curl, gzip, sha256sum and Python 3 are required. +set -euo pipefail +cd "$(dirname "$0")/.." +root=$PWD +out=$root/.cache/guest-measure +mkdir -p "$out" +. deploy/ci/images.env +bash scripts/guest-measure-inputs.sh "$out/inputs" +start=$(date +%s%N) +"$out/inputs/javy" build -o "$out/javascript.wasm" examples/debuglets/javascript/hello.js +printf 'javascript\t%d\n' "$((($(date +%s%N)-start)/1000000))" > "$out/build-ms.tsv" +docker run --rm --init --cpus 3 --memory 5g \ + -v "$out:/out" ghcr.io/webassembly/wasi-sdk:wasi-sdk-25@sha256:fdebde86990902087ecc470bf993ffec4d646e8fbd2e68c290a44120437622d0 \ + /opt/wasi-sdk/bin/llvm-strip --strip-all -o /out/python.wasm /out/inputs/python/python.wasm +# This mount is a diagnostic comparison only; executor guests have no filesystem. +mkdir -p "$out/stdlib-root/usr/local" +cp -R "$out/inputs/python/lib" "$out/stdlib-root/usr/local/" +docker run --rm --init --cpus 3 --memory 5g \ + -v "$root:/src" -w /src -e GOTOOLCHAIN=local -e GOMAXPROCS=3 \ + -v debuglet-ci-go-mod:/go/pkg/mod -v debuglet-ci-go-build:/go/build-cache \ + -e GOCACHE=/go/build-cache "${DEBUGLET_CI_BASE_IMAGE}@${DEBUGLET_CI_BASE_DIGEST}" bash -ceu ' + out=.cache/guest-measure + go version > "$out/toolchain.txt" + go list -m github.com/tetratelabs/wazero >> "$out/toolchain.txt" + go build -buildvcs=false -o "$out/measure" ./tools/guest-measure + for kind in cold warm; do + start=$(date +%s%N) + GOCACHE=/tmp/guest-go-cache GOOS=wasip1 GOARCH=wasm \ + go build -buildvcs=false -trimpath -o "$out/go.wasm" ./examples/debuglets/go/hello-local + printf "go-%s\t%d\n" "$kind" "$((($(date +%s%N)-start)/1000000))" >> "$out/build-ms.tsv" + done + for i in 1 2 3 4 5; do + "$out/measure" "$out/go.wasm" > "$out/go-$i.json" + "$out/measure" "$out/javascript.wasm" > "$out/javascript-$i.json" + if "$out/measure" "$out/python.wasm" python -c "print('\''Hello from Debuglet! (Python)'\'')" > "$out/python-$i.json"; then + echo "Python unexpectedly started without its standard library" >&2; exit 1 + else + test "$?" -eq 1 + fi + done + for i in 1 2 3; do + "$out/measure" -stdlib-root "$out/stdlib-root" "$out/python.wasm" \ + python -c "print('\''Hello from Debuglet! (Python)'\'')" > "$out/python-filesystem-$i.json" + done + sha256sum "$out"/*.wasm > "$out/modules.sha256" + ' +python3 - "$out" <<'PY' +import json, pathlib, sys +out = pathlib.Path(sys.argv[1]) +for name in ('go', 'javascript', 'python'): + result = json.loads((out / f'{name}-1.json').read_text()) + if name == 'python': + assert result['error'] and 'encodings' in result['stderr'] and not result['stdout'], result + else: + assert not result['error'] and 'Hello from Debuglet!' in result['stdout'], result + print(name, result['bytes'], 'bytes;', result['maxrss_kib'], 'KiB peak RSS') +PY +printf 'Measurements: %s\n' "$out" diff --git a/tools/guest-measure/main.go b/tools/guest-measure/main.go new file mode 100644 index 00000000..219c5dcb --- /dev/null +++ b/tools/guest-measure/main.go @@ -0,0 +1,109 @@ +//go:build linux + +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +// guest-measure measures one WASI hello module with the executor's interpreter +// and memory settings. It deliberately provides no networking host imports. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "syscall" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +type capture struct { + bytes.Buffer + start time.Time + first *float64 +} + +func (w *capture) Write(p []byte) (int, error) { + if w.Len()+len(p) > 64<<10 { + return 0, errors.New("measurement output exceeds 64 KiB") + } + if len(p) > 0 && w.first == nil { + elapsed := float64(time.Since(w.start)) / float64(time.Millisecond) + w.first = &elapsed + } + return w.Buffer.Write(p) +} + +func main() { + stdlib := flag.String("stdlib-root", "", "optional read-only filesystem root; unavailable in the real executor") + flag.Parse() + if flag.NArg() == 0 { + fmt.Fprintln(os.Stderr, "usage: guest-measure [-stdlib-root DIR] module.wasm [guest arguments...]") + os.Exit(2) + } + wasm, err := os.ReadFile(flag.Arg(0)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(4096).WithCloseOnContextDone(true)) + defer rt.Close(context.Background()) + start := time.Now() + compiled, err := rt.CompileModule(ctx, wasm) + compileMS := float64(time.Since(start)) / float64(time.Millisecond) + var imports []string + out, stderr := &capture{}, &capture{} + var runMS float64 + if err == nil { + for _, fn := range compiled.ImportedFunctions() { + module, name, _ := fn.Import() + if module != "wasi_snapshot_preview1" { + imports = append(imports, module+"."+name) + } + } + _, err = wasi_snapshot_preview1.Instantiate(ctx, rt) + } + if err == nil { + config := wazero.NewModuleConfig().WithStdout(out).WithStderr(stderr). + WithSysWalltime().WithSysNanotime().WithSysNanosleep().WithRandSource(rand.Reader). + WithArgs(flag.Args()[1:]...) + if *stdlib != "" { + config = config.WithFSConfig(wazero.NewFSConfig().WithReadOnlyDirMount(*stdlib, "/")) + } + out.start = time.Now() + stderr.start = out.start + _, err = rt.InstantiateModule(ctx, compiled, config) + runMS = float64(time.Since(out.start)) / float64(time.Millisecond) + } + var rss syscall.Rusage + if usageErr := syscall.Getrusage(syscall.RUSAGE_SELF, &rss); usageErr != nil { + err = errors.Join(err, usageErr) + } + sum := sha256.Sum256(wasm) + message := "" + if err != nil { + message = err.Error() + } + if err := json.NewEncoder(os.Stdout).Encode(map[string]any{ + "module_sha256": hex.EncodeToString(sum[:]), "bytes": len(wasm), + "compile_ms": compileMS, "first_stdout_ms": out.first, "first_stderr_ms": stderr.first, + "run_ms": runMS, "maxrss_kib": rss.Maxrss, "filesystem_mounted": *stdlib != "", + "non_wasi_imports": imports, "stdout": out.String(), "stderr": stderr.String(), "error": message, + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if message != "" { + os.Exit(1) + } +} From bc315a2f4ce461bfb5232e6d46074bad41b99ca7 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:21:05 +0200 Subject: [PATCH 3/6] Validate every retained language measurement sample --- scripts/measure-guest-languages.sh | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/measure-guest-languages.sh b/scripts/measure-guest-languages.sh index 0bd15fe0..477a6125 100644 --- a/scripts/measure-guest-languages.sh +++ b/scripts/measure-guest-languages.sh @@ -50,12 +50,18 @@ docker run --rm --init --cpus 3 --memory 5g \ python3 - "$out" <<'PY' import json, pathlib, sys out = pathlib.Path(sys.argv[1]) -for name in ('go', 'javascript', 'python'): - result = json.loads((out / f'{name}-1.json').read_text()) - if name == 'python': - assert result['error'] and 'encodings' in result['stderr'] and not result['stdout'], result - else: - assert not result['error'] and 'Hello from Debuglet!' in result['stdout'], result +for name in ('go', 'javascript', 'python', 'python-filesystem'): + for i in range(1, 4 if name == 'python-filesystem' else 6): + result = json.loads((out / f'{name}-{i}.json').read_text()) + assert result['filesystem_mounted'] == (name == 'python-filesystem'), result + assert not result['non_wasi_imports'], result + if name == 'python': + assert result['error'] == 'module closed with exit_code(1)', result + assert 'Fatal Python error: Failed to import encodings module' in result['stderr'], result + assert not result['stdout'] and result['first_stdout_ms'] is None, result + else: + assert not result['error'] and 'Hello from Debuglet!' in result['stdout'], result + assert result['first_stdout_ms'] is not None, result print(name, result['bytes'], 'bytes;', result['maxrss_kib'], 'KiB peak RSS') PY printf 'Measurements: %s\n' "$out" From 18348dcf6e41b13d26c4cf862d5ce1a72094de93 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:33:08 +0200 Subject: [PATCH 4/6] Replay simulator scenarios through scheduler and runtime allocation owners --- .../resource/sim_trace_replay_test.go | 423 +++--- .../testdata/sim-trace-churn-seed7.json | 1283 +++++++++++++++++ .../sim-trace-hierarchical-seed7.json | 525 +++++++ .../testdata/sim-trace-shared-seed0.json | 68 + .../resource/testdata/sim-traces.md | 37 + 5 files changed, 2134 insertions(+), 202 deletions(-) create mode 100644 internal/dispatcher/resource/testdata/sim-trace-churn-seed7.json create mode 100644 internal/dispatcher/resource/testdata/sim-trace-hierarchical-seed7.json create mode 100644 internal/dispatcher/resource/testdata/sim-trace-shared-seed0.json create mode 100644 internal/dispatcher/resource/testdata/sim-traces.md diff --git a/internal/dispatcher/resource/sim_trace_replay_test.go b/internal/dispatcher/resource/sim_trace_replay_test.go index edbbdf7c..80008b76 100644 --- a/internal/dispatcher/resource/sim_trace_replay_test.go +++ b/internal/dispatcher/resource/sim_trace_replay_test.go @@ -7,298 +7,317 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "maps" + "net/netip" "os" "slices" - "strings" "testing" + "time" "github.com/google/uuid" - "github.com/netsec-ethz/debuglet/internal/bitrate" "github.com/netsec-ethz/debuglet/internal/dispatcher/resource" -) - -// testdata/sim-trace-congestion-seed7.json is the trace the Debuglet simulator -// (commit e918b0c1) writes for `go run . -scenario congestion -seed 7 -trace -// FILE`. Its scenario events are replayed here into the dispatcher's -// destination accounting, and core's admission decisions are compared with -// the simulator's. Only the JSON fields the replay needs are decoded. -const ( - simTraceDigest = "b2f51d7e28deb45f6dfc22f34e15b0fbcc655125164e9b673c7e36ba93a86b2b" - // simTraceSHA256 pins the whole fixture file. - simTraceSHA256 = "67c26fa00727c98a5d7996a0c7e68fde24daeee875ac071c4fb6eae915696294" + "github.com/netsec-ethz/debuglet/internal/dispatcher/resource/schedule" + "github.com/netsec-ethz/debuglet/internal/executor/ratelimit/app" + "go.uber.org/zap" ) type simJob struct { ID string `json:"id"` Executor string `json:"executor"` + Source string `json:"source"` Floor bitrate.Bitrate `json:"floor"` Ceil bitrate.Bitrate `json:"ceil"` Destinations []string `json:"destinations"` } - type simCapacity struct { Target string `json:"target"` Capacity bitrate.Bitrate `json:"capacity"` } - type simEvent struct { Submit *simJob `json:"submit"` Remove string `json:"remove"` UpdateExecutor *simCapacity `json:"update_executor"` UpdateDestination *simCapacity `json:"update_destination"` } - +type simScenario struct { + Name string `json:"name"` + Seed int64 `json:"seed"` + Filter int `json:"filter"` + Options struct { + ExecutorCapacity bitrate.Bitrate `json:"executor_capacity"` + DestinationCapacity bitrate.Bitrate `json:"destination_capacity"` + SourceCapacity bitrate.Bitrate `json:"source_capacity"` + } `json:"options"` + Events []json.RawMessage `json:"events"` +} type simTrace struct { - Scenario struct { - Name string `json:"name"` - Seed int64 `json:"seed"` - Options struct { - ExecutorCapacity bitrate.Bitrate `json:"executor_capacity"` - DestinationCapacity bitrate.Bitrate `json:"destination_capacity"` - } `json:"options"` - Events []json.RawMessage `json:"events"` - } `json:"scenario"` - Steps []struct { + Scenario simScenario `json:"scenario"` + Steps []struct { Index int `json:"index"` Rejected bool `json:"rejected"` Skipped bool `json:"skipped"` } `json:"steps"` Allocations map[string]bitrate.Bitrate `json:"allocations"` + Violation json.RawMessage `json:"violation"` Digest string `json:"digest"` } -// The classes of admission decisions in which core and the simulator may -// differ. The dispatcher's destination accounting holds no executor capacity, -// so where the simulator refuses a job because its executor is full core -// admits it; and the simulator's multi v1 filter does not bound jobs by -// destination capacity, so where a destination is full core refuses a job the -// simulator admits. Any other difference fails the replay. -const ( - divergenceExecutorFull = "simulator refuses by executor capacity, core admits" - divergenceDestinationFull = "core refuses by destination capacity, simulator admits" -) - -func TestReplaySimulatorTraceAgainstDestinationAccounting(t *testing.T) { - raw, err := os.ReadFile("testdata/sim-trace-congestion-seed7.json") - if err != nil { - t.Fatal(err) - } - if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != simTraceSHA256 { - t.Fatalf("fixture SHA-256 is %x, want %s", sum, simTraceSHA256) - } - var trace simTrace - if err := json.Unmarshal(raw, &trace); err != nil { - t.Fatal(err) - } - if trace.Digest != simTraceDigest || len(trace.Steps) != len(trace.Scenario.Events) { - t.Fatalf("unexpected fixture: digest %s, %d steps for %d events", trace.Digest, len(trace.Steps), len(trace.Scenario.Events)) - } - for i, step := range trace.Steps { - if step.Index != i { - t.Fatalf("step %d carries index %d", i, step.Index) - } - } - events := make([]simEvent, len(trace.Scenario.Events)) - for i, rawEvent := range trace.Scenario.Events { - if err := json.Unmarshal(rawEvent, &events[i]); err != nil { - t.Fatalf("event %d: %v", i, err) - } +// Generated by the companion simulator's deterministic scenario commands. +// Pin complete files as well as logical digests; provenance and commands are +// in testdata/sim-traces.md. These tests exercise owners, not packet traffic. +func TestReplaySimulatorTracesAgainstRuntime(t *testing.T) { + for _, fixture := range []struct { + name, sha, digest string + seed int + }{ + {"congestion", "67c26fa00727c98a5d7996a0c7e68fde24daeee875ac071c4fb6eae915696294", "b2f51d7e28deb45f6dfc22f34e15b0fbcc655125164e9b673c7e36ba93a86b2b", 7}, + {"churn", "e996c87309af1eb7bdd4f7ac02e98de807fc58e88b6c5ec0cbac6e6b8500be4e", "f0219bac3675d1f083296d637226cfea42bf546ff6661db18d7271f5a817d5f8", 7}, + {"hierarchical", "cab81bb5e8fcf30584d7d67a2bd6f8635cb79ec6ccea4ca1ea206a48ac9f548b", "5ffae027a441ecb30d79cc85129592b4a0a6e983be7d4e83052977208719c860", 7}, + {"shared", "5eccf799e9ff72619411c2ac692e6b26e4e788d4dff4017ba0030f954fe4376a", "78aa6e4d8d47dd3bfd89c253723382ad96a8adc5b53de5f7acfc7bc98183adec", 0}, + } { + t.Run(fixture.name, func(t *testing.T) { + raw, err := os.ReadFile(fmt.Sprintf("testdata/sim-trace-%s-seed%d.json", fixture.name, fixture.seed)) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + if hex.EncodeToString(sum[:]) != fixture.sha { + t.Fatalf("fixture hash %x, want %s", sum, fixture.sha) + } + var trace simTrace + if err := json.Unmarshal(raw, &trace); err != nil { + t.Fatal(err) + } + if trace.Digest != fixture.digest || len(trace.Violation) != 0 || len(trace.Steps) != len(trace.Scenario.Events) { + t.Fatal("fixture is not a complete successful trace") + } + replaySimulator(t, trace) + }) } +} - // Units: the simulator writes bit/s, the unit of internal/bitrate. - gigabit, err := bitrate.Parse("1gbit") - if err != nil || gigabit != bitrate.Gigabit { - t.Fatalf("1gbit parses to %d (%v)", int64(gigabit), err) - } +func replaySimulator(t *testing.T, trace simTrace) { + t.Helper() options := trace.Scenario.Options - if options.DestinationCapacity != gigabit || options.ExecutorCapacity != gigabit { - t.Fatalf("scenario capacities %d and %d bit/s, want 1 Gbit/s", int64(options.ExecutorCapacity), int64(options.DestinationCapacity)) + // Each fixture source belongs to one executor with the same default + // capacity. Core has no independent source allocator to compare here. + if options.ExecutorCapacity != bitrate.Gigabit || options.DestinationCapacity != bitrate.Gigabit || options.SourceCapacity != bitrate.Gigabit { + t.Fatal("fixture units/defaults changed") } - for i, event := range events { - if job := event.Submit; job != nil && (job.Floor%bitrate.Megabit != 0 || job.Ceil%bitrate.Megabit != 0 || - !bitrate.InPolicyRange(int64(job.Floor)) || !bitrate.InPolicyRange(int64(job.Ceil))) { - t.Fatalf("event %d: floor %d and ceiling %d bit/s are not whole Mbit/s policy values", i, int64(job.Floor), int64(job.Ceil)) - } - } - usage := resource.NewDestinations(options.DestinationCapacity) - executorCapacity := map[string]bitrate.Bitrate{} - for _, event := range events { - if event.Submit != nil { - executorCapacity[event.Submit.Executor] = options.ExecutorCapacity - } + scheduler := schedule.New(time.Nanosecond) + from, to := time.Unix(1, 0), time.Unix(2, 0) + capacity := map[string]bitrate.Bitrate{} + limiters := map[string]*app.Limiter{} + sources := map[string]string{} + active := map[string]*simJob{} + touched := map[string]bool{} + runID := func(id string) uuid.UUID { return uuid.NewSHA1(uuid.NameSpaceOID, []byte(id)) } + request := func(j *simJob) schedule.Request { + return schedule.Request{Executor: j.Executor, Destination: j.Destinations, From: from, To: to, Use: j.Floor} } - active := map[string]*simJob{} // the jobs admitted by both sides - touched := map[string]bool{} // every destination core has charged so far - coreRefused := map[string]bool{} // the jobs only the simulator admitted - runID := func(job string) uuid.UUID { return uuid.NewSHA1(uuid.NameSpaceOID, []byte(job)) } - - // violation fails the replay with the shortest prefix of the scenario - // that reproduces it. violation := func(i int, format string, args ...any) { t.Helper() - prefix, _ := json.MarshalIndent(trace.Scenario.Events[:i+1], "", " ") - t.Fatalf("event %d: %s\nreproducing events:\n%s", i, fmt.Sprintf(format, args...), prefix) + prefix := trace.Scenario + prefix.Events = prefix.Events[:i+1] + data, _ := json.MarshalIndent(prefix, "", " ") + t.Fatalf("event %d: %s\nSave this scenario as failure.json, then run the simulator with -replay failure.json -trace replay.json:\n%s", i, fmt.Sprintf(format, args...), data) + } + executor := func(id string) *app.Limiter { + if limiters[id] == nil { + capacity[id] = options.ExecutorCapacity + limiters[id] = app.NewLimiter(zap.NewNop()) + limiters[id].SetExecutorCapacity(capacity[id]) + } + return limiters[id] } - // check states core's invariants on every destination charged so far. A - // destination without active jobs charges nothing and shares nothing. check := func(i int) { t.Helper() floors := map[string]bitrate.Bitrate{} + execFloors := map[string]bitrate.Bitrate{} totals := map[[2]string][2]bitrate.Bitrate{} - for _, job := range active { - for _, destination := range distinct(job.Destinations) { - floors[destination] += job.Floor - key := [2]string{job.Executor, destination} - totals[key] = [2]bitrate.Bitrate{totals[key][0] + job.Floor, totals[key][1] + job.Ceil} + for _, j := range active { + execFloors[j.Executor] += j.Floor + for _, dest := range j.Destinations { + floors[dest] += j.Floor + key := [2]string{j.Executor, dest} + totals[key] = [2]bitrate.Bitrate{totals[key][0] + j.Floor, totals[key][1] + j.Ceil} } } - for destination := range touched { - floor := floors[destination] - expected := map[string]bool{} - for key := range totals { - if key[1] == destination { - expected[key[0]] = true - } - } - if used := usage.Used(destination); used != floor { - violation(i, "%s charges %d, the active floors sum to %d", destination, int64(used), int64(floor)) + budgets := map[[2]string]bitrate.Bitrate{} + for dest := range touched { + if usage.Used(dest) != floors[dest] || scheduler.QueryMaxDest(dest, from, to) != floors[dest] { + violation(i, "destination %s reservation differs from active floors", dest) } var sum bitrate.Bitrate - seen := map[string]bool{} - for executor, share := range usage.Fairshare(destination) { - if seen[executor] || !expected[executor] { - violation(i, "%s on %s is given a share again or without an active job", executor, destination) - } - seen[executor] = true - want := totals[[2]string{executor, destination}] - if share < want[0] || share > want[1] { - violation(i, "%s on %s is given %d, outside [%d, %d]", executor, destination, int64(share), int64(want[0]), int64(want[1])) + for exec, share := range usage.Fairshare(dest) { + key := [2]string{exec, dest} + want, ok := totals[key] + if !ok || share < want[0] || share > want[1] { + violation(i, "unexpected destination share %s/%s: %d", exec, dest, share) } + budgets[key] = share + executor(exec).SetAddrCapacity(dest, share) sum += share } - for executor := range expected { - if !seen[executor] { - violation(i, "%s holds active jobs on %s but is given no share", executor, destination) + if sum > usage.Cap(dest) { + violation(i, "destination %s shares exceed capacity", dest) + } + } + for key, want := range totals { + floor, ceil, ok := usage.Totals(key[0], key[1]) + if !ok || floor != want[0] || ceil != want[1] || budgets[key] < want[0] { + violation(i, "lost destination ownership %v", key) + } + } + execSums := map[string]bitrate.Bitrate{} + addrSums := map[[2]string]bitrate.Bitrate{} + for _, j := range active { + limit, _, err := executor(j.Executor).GetExecLimit(runID(j.ID)) + if err != nil || limit < j.Floor || limit > j.Ceil { + violation(i, "runtime executor share %s=%d: %v", j.ID, limit, err) + } + execSums[j.Executor] += limit + for _, dest := range j.Destinations { + limit, _, err := executor(j.Executor).GetAddrLimit(runID(j.ID), dest) + if err != nil || limit < j.Floor || limit > j.Ceil { + violation(i, "runtime destination share %s/%s=%d: %v", j.ID, dest, limit, err) } + addrSums[[2]string{j.Executor, dest}] += limit } - if capacity := usage.Cap(destination); sum > capacity { - violation(i, "shares on %s sum to %d, above the capacity %d", destination, int64(sum), int64(capacity)) + } + for exec, cap := range capacity { + if scheduler.QueryMaxExec(exec, from, to) != execFloors[exec] || execSums[exec] > cap { + violation(i, "executor %s reservation or runtime capacity violated", exec) } } - for key, want := range totals { - if floor, ceil, ok := usage.Totals(key[0], key[1]); !ok || floor != want[0] || ceil != want[1] { - violation(i, "%s on %s holds [%d, %d], the active jobs [%d, %d]", key[0], key[1], int64(floor), int64(ceil), int64(want[0]), int64(want[1])) + for key, sum := range addrSums { + if sum > budgets[key] { + violation(i, "runtime spends %d above dispatcher budget %d on %v", sum, budgets[key], key) } } } - - var table []string - agree := map[string]int{} - differ := map[string]int{} - for i, event := range events { + remove := func(i int, j *simJob) { + scheduler.Remove(request(j)) + for _, dest := range j.Destinations { + usage.Remove(runID(j.ID), dest) + } + executor(j.Executor).RemoveDebuglet(runID(j.ID)) + executor(j.Executor).RemoveDebuglet(runID(j.ID)) + if _, _, err := executor(j.Executor).GetExecLimit(runID(j.ID)); !errors.Is(err, app.ErrNotRegistered) { + violation(i, "removed runtime job %s retained", j.ID) + } + delete(active, j.ID) + } + for i, raw := range trace.Scenario.Events { + var event simEvent + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } step := trace.Steps[i] + if step.Index != i { + t.Fatal("trace steps out of order") + } switch { case event.Submit != nil: - job := event.Submit + j := event.Submit + j.Destinations = expandSimDestinations(t, j.Destinations) + limiter := executor(j.Executor) + if old, ok := sources[j.Source]; ok && old != j.Executor { + violation(i, "fixture source %s spans executors", j.Source) + } + sources[j.Source] = j.Executor + if j.Floor < 0 || j.Ceil < j.Floor || j.Ceil > bitrate.Gigabit { + violation(i, "invalid fixture policy") + } before := usage.Snapshot() - admitted := usage.Allocate(runID(job.ID), job.Executor, job.Destinations, job.Floor, job.Ceil) == nil + admitted := scheduler.QueryMaxExec(j.Executor, from, to)+j.Floor <= capacity[j.Executor] if admitted { - for _, destination := range job.Destinations { - touched[destination] = true - } + admitted = usage.Allocate(runID(j.ID), j.Executor, j.Destinations, j.Floor, j.Ceil) == nil } - if admitted == !step.Rejected { - agree[map[bool]string{true: "admitted", false: "refused"}[admitted]]++ - if admitted { - active[job.ID] = job + if admitted == step.Rejected { + violation(i, "admission differs for %s: core=%t simulator=%t", j.ID, admitted, !step.Rejected) + } + if !admitted { + if usage.Snapshot() != before { + violation(i, "refusal changed destination accounting") } break } - var class string - if admitted { - // Keep core on the simulator's admitted set: release the - // allocation again, which must restore the accounting exactly. - for _, destination := range distinct(job.Destinations) { - usage.Remove(runID(job.ID), destination) - } - if after := usage.Snapshot(); after != before { - violation(i, "releasing %s does not restore the accounting:\nbefore\n%s\nafter\n%s", job.ID, before, after) - } - var executorFloors bitrate.Bitrate - for _, other := range active { - if other.Executor == job.Executor { - executorFloors += other.Floor - } - } - if executorFloors+job.Floor > executorCapacity[job.Executor] { - class = divergenceExecutorFull - } - } else { - coreRefused[job.ID] = true - for _, destination := range distinct(job.Destinations) { - if usage.CheckCapacity(destination, job.Floor) != nil { - class = divergenceDestinationFull - } - } + scheduler.Submit(request(j)) + if err := limiter.InsertDebuglet(runID(j.ID), j.Floor, j.Ceil, j.Destinations); err != nil { + violation(i, "runtime admission: %v", err) } - differ[class]++ - table = append(table, fmt.Sprintf("%3d %s %-7s core admitted=%-5t simulator rejected=%-5t class=%q", i, job.ID, job.Executor, admitted, step.Rejected, class)) - case event.Remove != "": - job, held := active[event.Remove] - if coreRefused[event.Remove] { - delete(coreRefused, event.Remove) - held = true // the simulator holds it, core never did - job = nil + active[j.ID] = j + for _, dest := range j.Destinations { + touched[dest] = true } + case event.Remove != "": + j, held := active[event.Remove] if held == step.Skipped { - violation(i, "core holds %s: %t, the simulator skipped its removal: %t", event.Remove, held, step.Skipped) + violation(i, "removal differs for %s", event.Remove) } - if job == nil { - break - } - allocations := usage.ActiveAllocations() - for _, destination := range distinct(job.Destinations) { - usage.Remove(runID(job.ID), destination) - } - delete(active, job.ID) - if released := allocations - usage.ActiveAllocations(); released != len(distinct(job.Destinations)) { - violation(i, "removing %s released %d allocations for %d destinations", job.ID, released, len(distinct(job.Destinations))) + if held { + remove(i, j) } case event.UpdateExecutor != nil: - executorCapacity[event.UpdateExecutor.Target] = event.UpdateExecutor.Capacity - case event.UpdateDestination != nil: - if strings.Contains(event.UpdateDestination.Target, "/") { - t.Fatalf("event %d: destination prefix %s is not replayed", i, event.UpdateDestination.Target) + c := event.UpdateExecutor + limiter := executor(c.Target) + if scheduler.QueryMaxExec(c.Target, from, to) > c.Capacity { + violation(i, "fixture reduces executor below reserved floors") } - if err := usage.SetLimit(event.UpdateDestination.Target, event.UpdateDestination.Capacity); err != nil { - violation(i, "core refuses the destination limit: %v", err) + capacity[c.Target] = c.Capacity + limiter.SetExecutorCapacity(c.Capacity) + case event.UpdateDestination != nil: + c := event.UpdateDestination + for _, dest := range expandSimDestinations(t, []string{c.Target}) { + if err := usage.SetLimit(dest, c.Capacity); err != nil { + violation(i, "destination capacity update: %v", err) + } } default: - t.Fatalf("event %d: unknown event %s", i, trace.Scenario.Events[i]) + violation(i, "unknown event") } check(i) } - - held := slices.Sorted(maps.Keys(active)) - for id := range coreRefused { - held = append(held, id) + if !slices.Equal(slices.Sorted(maps.Keys(active)), slices.Sorted(maps.Keys(trace.Allocations))) { + t.Fatal("final active membership differs") + } + for id, rate := range trace.Allocations { + if rate < active[id].Floor || rate > active[id].Ceil { + t.Fatal("simulator allocation violates policy") + } } - slices.Sort(held) - if allocated := slices.Sorted(maps.Keys(trace.Allocations)); !slices.Equal(held, allocated) { - t.Fatalf("the replay ends holding %v, the simulator %v", held, allocated) + // Explicitly release the remaining jobs and verify both owners are empty. + last := len(trace.Steps) - 1 + for _, id := range slices.Sorted(maps.Keys(active)) { + remove(last, active[id]) + check(last) } - t.Logf("agreed: %v; differed: %v\n%s", agree, differ, strings.Join(table, "\n")) - if differ[""] > 0 { - t.Fatalf("admission decisions differ outside the known classes:\n%s", strings.Join(table, "\n")) + if usage.ActiveAllocations() != 0 { + t.Fatal("destination ownership leaked") } } -func distinct(destinations []string) []string { - out := slices.Clone(destinations) +// Scenario prefixes mean per-address capacity, not an aggregate CIDR budget. +func expandSimDestinations(t *testing.T, values []string) []string { + t.Helper() + var out []string + for _, value := range values { + if prefix, err := netip.ParsePrefix(value); err == nil { + if prefix.Addr().BitLen()-prefix.Bits() > 8 { + t.Fatal("unbounded fixture prefix") + } + for addr := prefix.Masked().Addr(); prefix.Contains(addr); addr = addr.Next() { + out = append(out, addr.String()) + } + } else { + out = append(out, netip.MustParseAddr(value).String()) + } + } slices.Sort(out) return slices.Compact(out) } diff --git a/internal/dispatcher/resource/testdata/sim-trace-churn-seed7.json b/internal/dispatcher/resource/testdata/sim-trace-churn-seed7.json new file mode 100644 index 00000000..fe3f0a47 --- /dev/null +++ b/internal/dispatcher/resource/testdata/sim-trace-churn-seed7.json @@ -0,0 +1,1283 @@ +{ + "scenario": { + "name": "churn", + "seed": 7, + "filter": 3, + "options": { + "executor_capacity": 1000000000, + "destination_capacity": 1000000000, + "source_capacity": 1000000000 + }, + "events": [ + { + "submit": { + "id": "job-001", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 841000000, + "destinations": [ + "10.1.0.2" + ] + } + }, + { + "submit": { + "id": "job-002", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 681000000, + "destinations": [ + "10.1.0.2", + "10.1.0.3", + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-003", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 475000000, + "destinations": [ + "10.2.0.3" + ] + } + }, + { + "submit": { + "id": "job-004", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 302000000, + "destinations": [ + "10.3.0.1", + "10.3.0.3", + "10.3.0.2" + ] + } + }, + { + "submit": { + "id": "job-005", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 396000000, + "destinations": [ + "10.1.0.1", + "10.1.0.2", + "10.1.0.3" + ] + } + }, + { + "submit": { + "id": "job-006", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 282000000, + "destinations": [ + "10.1.0.2", + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-007", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 747000000, + "destinations": [ + "10.3.0.3", + "10.3.0.2", + "10.3.0.1" + ] + } + }, + { + "remove": "job-001" + }, + { + "remove": "job-002" + }, + { + "remove": "job-005" + }, + { + "remove": "job-006" + }, + { + "update_executor": { + "target": "exec-1", + "capacity": 0 + } + }, + { + "submit": { + "id": "job-013", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 398000000, + "destinations": [ + "10.3.0.1", + "10.3.0.2", + "10.3.0.3" + ] + } + }, + { + "remove": "job-004" + }, + { + "remove": "job-013" + }, + { + "remove": "job-007" + }, + { + "submit": { + "id": "job-017", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 638000000, + "destinations": [ + "10.1.0.1", + "10.1.0.3", + "10.1.0.2" + ] + } + }, + { + "update_executor": { + "target": "exec-1", + "capacity": 1000000000 + } + }, + { + "update_executor": { + "target": "exec-3", + "capacity": 0 + } + }, + { + "remove": "job-003" + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 0 + } + }, + { + "remove": "job-017" + }, + { + "submit": { + "id": "job-023", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 562000000, + "destinations": [ + "10.1.0.2", + "10.1.0.3" + ] + } + }, + { + "remove": "job-023" + }, + { + "submit": { + "id": "job-025", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 280000000, + "destinations": [ + "10.2.0.3", + "10.2.0.2", + "10.2.0.1" + ] + } + }, + { + "remove": "job-025" + }, + { + "submit": { + "id": "job-027", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 875000000, + "destinations": [ + "10.3.0.2", + "10.3.0.1", + "10.3.0.3" + ] + } + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 1000000000 + } + }, + { + "submit": { + "id": "job-029", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 940000000, + "destinations": [ + "10.2.0.2" + ] + } + }, + { + "update_executor": { + "target": "exec-1", + "capacity": 0 + } + }, + { + "submit": { + "id": "job-031", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 825000000, + "destinations": [ + "10.1.0.1" + ] + } + }, + { + "remove": "job-027" + }, + { + "remove": "job-031" + }, + { + "submit": { + "id": "job-034", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 464000000, + "destinations": [ + "10.3.0.1", + "10.3.0.2" + ] + } + }, + { + "remove": "job-029" + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 0 + } + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 1000000000 + } + }, + { + "submit": { + "id": "job-038", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 695000000, + "destinations": [ + "10.1.0.2", + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-039", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 502000000, + "destinations": [ + "10.1.0.1", + "10.1.0.2" + ] + } + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 0 + } + }, + { + "remove": "job-038" + }, + { + "update_executor": { + "target": "exec-1", + "capacity": 1000000000 + } + }, + { + "submit": { + "id": "job-043", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 490000000, + "destinations": [ + "10.1.0.3" + ] + } + }, + { + "remove": "job-043" + }, + { + "remove": "job-034" + }, + { + "submit": { + "id": "job-046", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 278000000, + "destinations": [ + "10.2.0.1", + "10.2.0.3", + "10.2.0.2" + ] + } + }, + { + "update_executor": { + "target": "exec-2", + "capacity": 1000000000 + } + }, + { + "submit": { + "id": "job-048", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 981000000, + "destinations": [ + "10.2.0.3", + "10.2.0.1" + ] + } + }, + { + "remove": "job-048" + }, + { + "remove": "job-046" + }, + { + "submit": { + "id": "job-051", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 392000000, + "destinations": [ + "10.2.0.3", + "10.2.0.2", + "10.2.0.1" + ] + } + }, + { + "submit": { + "id": "job-052", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 321000000, + "destinations": [ + "10.1.0.2", + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-053", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 965000000, + "destinations": [ + "10.3.0.2", + "10.3.0.3" + ] + } + }, + { + "submit": { + "id": "job-054", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 675000000, + "destinations": [ + "10.1.0.2", + "10.1.0.3" + ] + } + }, + { + "submit": { + "id": "job-055", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 524000000, + "destinations": [ + "10.1.0.3" + ] + } + }, + { + "submit": { + "id": "job-056", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 962000000, + "destinations": [ + "10.2.0.1", + "10.2.0.3" + ] + } + }, + { + "remove": "job-055" + }, + { + "submit": { + "id": "job-058", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 252000000, + "destinations": [ + "10.2.0.2" + ] + } + }, + { + "submit": { + "id": "job-059", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 385000000, + "destinations": [ + "10.3.0.2", + "10.3.0.3" + ] + } + }, + { + "submit": { + "id": "job-060", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 604000000, + "destinations": [ + "10.2.0.3", + "10.2.0.1", + "10.2.0.2" + ] + } + }, + { + "submit": { + "id": "job-061", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 200000000, + "destinations": [ + "10.3.0.2", + "10.3.0.3" + ] + } + }, + { + "remove": "job-056" + }, + { + "submit": { + "id": "job-063", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 229000000, + "destinations": [ + "10.1.0.3" + ] + } + }, + { + "remove": "job-053" + }, + { + "submit": { + "id": "job-065", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 890000000, + "destinations": [ + "10.1.0.3", + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-066", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 408000000, + "destinations": [ + "10.2.0.3", + "10.2.0.2", + "10.2.0.1" + ] + } + }, + { + "remove": "job-059" + }, + { + "submit": { + "id": "job-068", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 619000000, + "destinations": [ + "10.2.0.2" + ] + } + }, + { + "submit": { + "id": "job-069", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 200000000, + "ceil": 445000000, + "destinations": [ + "10.1.0.1" + ] + } + }, + { + "submit": { + "id": "job-070", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 697000000, + "destinations": [ + "10.2.0.2", + "10.2.0.1" + ] + } + }, + { + "update_executor": { + "target": "exec-3", + "capacity": 1000000000 + } + }, + { + "remove": "job-061" + }, + { + "update_executor": { + "target": "exec-3", + "capacity": 0 + } + }, + { + "submit": { + "id": "job-074", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 989000000, + "destinations": [ + "10.3.0.3", + "10.3.0.2", + "10.3.0.1" + ] + } + }, + { + "remove": "job-039" + }, + { + "remove": "job-052" + }, + { + "remove": "job-054" + }, + { + "remove": "job-063" + }, + { + "remove": "job-065" + }, + { + "remove": "job-069" + }, + { + "update_executor": { + "target": "exec-1", + "capacity": 0 + } + }, + { + "submit": { + "id": "job-082", + "executor": "exec-3", + "source": "10.0.0.3", + "floor": 200000000, + "ceil": 208000000, + "destinations": [ + "10.3.0.2", + "10.3.0.3" + ] + } + }, + { + "update_executor": { + "target": "exec-3", + "capacity": 1000000000 + } + }, + { + "remove": "job-068" + }, + { + "submit": { + "id": "job-085", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 200000000, + "ceil": 962000000, + "destinations": [ + "10.2.0.1" + ] + } + } + ] + }, + "steps": [ + { + "index": 0, + "assigned": 841000000 + }, + { + "index": 1, + "assigned": 500000000, + "updates": [ + { + "id": "job-001", + "cbw": 500000000 + } + ] + }, + { + "index": 2, + "assigned": 475000000 + }, + { + "index": 3, + "assigned": 302000000 + }, + { + "index": 4, + "assigned": 333333333, + "updates": [ + { + "id": "job-001", + "cbw": 333333333 + }, + { + "id": "job-002", + "cbw": 333333333 + } + ] + }, + { + "index": 5, + "assigned": 250000000, + "updates": [ + { + "id": "job-001", + "cbw": 250000000 + }, + { + "id": "job-002", + "cbw": 250000000 + }, + { + "id": "job-005", + "cbw": 250000000 + } + ] + }, + { + "index": 6, + "assigned": 698000000 + }, + { + "index": 7, + "updates": [ + { + "id": "job-002", + "cbw": 359000000 + }, + { + "id": "job-005", + "cbw": 359000000 + }, + { + "id": "job-006", + "cbw": 282000000 + } + ] + }, + { + "index": 8, + "updates": [ + { + "id": "job-005", + "cbw": 396000000 + } + ] + }, + { + "index": 9 + }, + { + "index": 10 + }, + { + "index": 11 + }, + { + "index": 12, + "assigned": 349000000, + "updates": [ + { + "id": "job-007", + "cbw": 349000000 + } + ] + }, + { + "index": 13, + "updates": [ + { + "id": "job-007", + "cbw": 602000000 + }, + { + "id": "job-013", + "cbw": 398000000 + } + ] + }, + { + "index": 14, + "updates": [ + { + "id": "job-007", + "cbw": 747000000 + } + ] + }, + { + "index": 15 + }, + { + "index": 16, + "rejected": true + }, + { + "index": 17 + }, + { + "index": 18 + }, + { + "index": 19 + }, + { + "index": 20 + }, + { + "index": 21, + "skipped": true + }, + { + "index": 22, + "assigned": 562000000 + }, + { + "index": 23 + }, + { + "index": 24, + "rejected": true + }, + { + "index": 25, + "skipped": true + }, + { + "index": 26, + "rejected": true + }, + { + "index": 27 + }, + { + "index": 28, + "assigned": 940000000 + }, + { + "index": 29 + }, + { + "index": 30, + "rejected": true + }, + { + "index": 31, + "skipped": true + }, + { + "index": 32, + "skipped": true + }, + { + "index": 33, + "rejected": true + }, + { + "index": 34 + }, + { + "index": 35 + }, + { + "index": 36 + }, + { + "index": 37, + "rejected": true + }, + { + "index": 38, + "rejected": true + }, + { + "index": 39 + }, + { + "index": 40, + "skipped": true + }, + { + "index": 41 + }, + { + "index": 42, + "assigned": 490000000 + }, + { + "index": 43 + }, + { + "index": 44, + "skipped": true + }, + { + "index": 45, + "rejected": true + }, + { + "index": 46 + }, + { + "index": 47, + "assigned": 981000000 + }, + { + "index": 48 + }, + { + "index": 49, + "skipped": true + }, + { + "index": 50, + "assigned": 392000000 + }, + { + "index": 51, + "assigned": 321000000 + }, + { + "index": 52, + "rejected": true + }, + { + "index": 53, + "assigned": 675000000 + }, + { + "index": 54, + "assigned": 339500000, + "updates": [ + { + "id": "job-054", + "cbw": 339500000 + } + ] + }, + { + "index": 55, + "assigned": 608000000 + }, + { + "index": 56, + "updates": [ + { + "id": "job-054", + "cbw": 675000000 + } + ] + }, + { + "index": 57, + "assigned": 252000000, + "updates": [ + { + "id": "job-051", + "cbw": 374000000 + }, + { + "id": "job-056", + "cbw": 374000000 + } + ] + }, + { + "index": 58, + "rejected": true + }, + { + "index": 59, + "assigned": 250000000, + "updates": [ + { + "id": "job-051", + "cbw": 250000000 + }, + { + "id": "job-056", + "cbw": 250000000 + }, + { + "id": "job-058", + "cbw": 250000000 + } + ] + }, + { + "index": 60, + "rejected": true + }, + { + "index": 61, + "updates": [ + { + "id": "job-051", + "cbw": 374000000 + }, + { + "id": "job-058", + "cbw": 252000000 + }, + { + "id": "job-060", + "cbw": 374000000 + } + ] + }, + { + "index": 62, + "assigned": 229000000, + "updates": [ + { + "id": "job-054", + "cbw": 450000000 + } + ] + }, + { + "index": 63, + "skipped": true + }, + { + "index": 64, + "assigned": 257000000, + "updates": [ + { + "id": "job-052", + "cbw": 257000000 + }, + { + "id": "job-054", + "cbw": 257000000 + } + ] + }, + { + "index": 65, + "assigned": 250000000, + "updates": [ + { + "id": "job-051", + "cbw": 250000000 + }, + { + "id": "job-058", + "cbw": 250000000 + }, + { + "id": "job-060", + "cbw": 250000000 + } + ] + }, + { + "index": 66, + "skipped": true + }, + { + "index": 67, + "assigned": 200000000, + "updates": [ + { + "id": "job-051", + "cbw": 200000000 + }, + { + "id": "job-058", + "cbw": 200000000 + }, + { + "id": "job-060", + "cbw": 200000000 + }, + { + "id": "job-066", + "cbw": 200000000 + } + ] + }, + { + "index": 68, + "assigned": 200000000, + "updates": [ + { + "id": "job-052", + "cbw": 200000000 + }, + { + "id": "job-054", + "cbw": 200000000 + }, + { + "id": "job-063", + "cbw": 200000000 + }, + { + "id": "job-065", + "cbw": 200000000 + } + ] + }, + { + "index": 69, + "rejected": true + }, + { + "index": 70 + }, + { + "index": 71, + "skipped": true + }, + { + "index": 72 + }, + { + "index": 73, + "rejected": true + }, + { + "index": 74, + "skipped": true + }, + { + "index": 75, + "updates": [ + { + "id": "job-054", + "cbw": 257000000 + }, + { + "id": "job-063", + "cbw": 229000000 + }, + { + "id": "job-065", + "cbw": 257000000 + }, + { + "id": "job-069", + "cbw": 257000000 + } + ] + }, + { + "index": 76, + "updates": [ + { + "id": "job-065", + "cbw": 385500000 + }, + { + "id": "job-069", + "cbw": 385500000 + } + ] + }, + { + "index": 77, + "updates": [ + { + "id": "job-065", + "cbw": 555000000 + }, + { + "id": "job-069", + "cbw": 445000000 + } + ] + }, + { + "index": 78 + }, + { + "index": 79 + }, + { + "index": 80 + }, + { + "index": 81, + "rejected": true + }, + { + "index": 82 + }, + { + "index": 83, + "updates": [ + { + "id": "job-051", + "cbw": 250000000 + }, + { + "id": "job-058", + "cbw": 250000000 + }, + { + "id": "job-060", + "cbw": 250000000 + }, + { + "id": "job-066", + "cbw": 250000000 + } + ] + }, + { + "index": 84, + "assigned": 200000000, + "updates": [ + { + "id": "job-051", + "cbw": 200000000 + }, + { + "id": "job-058", + "cbw": 200000000 + }, + { + "id": "job-060", + "cbw": 200000000 + }, + { + "id": "job-066", + "cbw": 200000000 + } + ] + } + ], + "allocations": { + "job-051": 200000000, + "job-058": 200000000, + "job-060": 200000000, + "job-066": 200000000, + "job-085": 200000000 + }, + "digest": "f0219bac3675d1f083296d637226cfea42bf546ff6661db18d7271f5a817d5f8" +} diff --git a/internal/dispatcher/resource/testdata/sim-trace-hierarchical-seed7.json b/internal/dispatcher/resource/testdata/sim-trace-hierarchical-seed7.json new file mode 100644 index 00000000..aa4be3cd --- /dev/null +++ b/internal/dispatcher/resource/testdata/sim-trace-hierarchical-seed7.json @@ -0,0 +1,525 @@ +{ + "scenario": { + "name": "hierarchical", + "seed": 7, + "filter": 3, + "options": { + "executor_capacity": 1000000000, + "destination_capacity": 1000000000, + "source_capacity": 1000000000 + }, + "events": [ + { + "update_destination": { + "target": "10.100.0.0/30", + "capacity": 200000000 + } + }, + { + "update_destination": { + "target": "10.101.0.0/30", + "capacity": 200000000 + } + }, + { + "submit": { + "id": "job-003", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 739000000, + "destinations": [ + "10.1.0.2" + ] + } + }, + { + "submit": { + "id": "job-004", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 655000000, + "destinations": [ + "10.2.0.2" + ] + } + }, + { + "submit": { + "id": "job-005", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 815000000, + "destinations": [ + "10.2.0.1", + "10.100.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-006", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 515000000, + "destinations": [ + "10.1.0.1", + "10.101.0.1" + ] + } + }, + { + "submit": { + "id": "job-007", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 259000000, + "destinations": [ + "10.1.0.2", + "10.101.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-008", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 376000000, + "destinations": [ + "10.2.0.1" + ] + } + }, + { + "submit": { + "id": "job-009", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 711000000, + "destinations": [ + "10.2.0.1" + ] + } + }, + { + "submit": { + "id": "job-010", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 950000000, + "destinations": [ + "10.2.0.3" + ] + } + }, + { + "submit": { + "id": "job-011", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 821000000, + "destinations": [ + "10.2.0.1", + "10.101.0.2" + ] + } + }, + { + "submit": { + "id": "job-012", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 234000000, + "destinations": [ + "10.2.0.3", + "10.101.0.1" + ] + } + }, + { + "submit": { + "id": "job-013", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 899000000, + "destinations": [ + "10.1.0.3", + "10.100.0.3" + ] + } + }, + { + "submit": { + "id": "job-014", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 126000000, + "destinations": [ + "10.2.0.2", + "10.101.0.3" + ] + } + }, + { + "submit": { + "id": "job-015", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 784000000, + "destinations": [ + "10.2.0.3" + ] + } + }, + { + "submit": { + "id": "job-016", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 104000000, + "destinations": [ + "10.1.0.1", + "10.101.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-017", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 771000000, + "destinations": [ + "10.2.0.2", + "10.100.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-018", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 492000000, + "destinations": [ + "10.1.0.1", + "10.101.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-019", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 969000000, + "destinations": [ + "10.2.0.3", + "10.101.0.1" + ] + } + }, + { + "submit": { + "id": "job-020", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 306000000, + "destinations": [ + "10.2.0.2" + ] + } + }, + { + "submit": { + "id": "job-021", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 710000000, + "destinations": [ + "10.2.0.3", + "10.101.0.0/30" + ] + } + }, + { + "submit": { + "id": "job-022", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 560000000, + "destinations": [ + "10.2.0.2", + "10.101.0.2" + ] + } + } + ] + }, + "steps": [ + { + "index": 0 + }, + { + "index": 1 + }, + { + "index": 2, + "assigned": 739000000 + }, + { + "index": 3, + "assigned": 655000000 + }, + { + "index": 4, + "assigned": 200000000, + "updates": [ + { + "id": "job-004", + "cbw": 500000000 + } + ] + }, + { + "index": 5, + "assigned": 200000000, + "updates": [ + { + "id": "job-003", + "cbw": 500000000 + } + ] + }, + { + "index": 6, + "assigned": 100000000, + "updates": [ + { + "id": "job-003", + "cbw": 370500000 + }, + { + "id": "job-006", + "cbw": 100000000 + } + ] + }, + { + "index": 7, + "assigned": 333333333, + "updates": [ + { + "id": "job-004", + "cbw": 333333333 + } + ] + }, + { + "index": 8, + "assigned": 250000000, + "updates": [ + { + "id": "job-004", + "cbw": 250000000 + }, + { + "id": "job-008", + "cbw": 250000000 + } + ] + }, + { + "index": 9, + "assigned": 200000000, + "updates": [ + { + "id": "job-004", + "cbw": 200000000 + }, + { + "id": "job-008", + "cbw": 200000000 + }, + { + "id": "job-009", + "cbw": 200000000 + } + ] + }, + { + "index": 10, + "assigned": 100000000, + "updates": [ + { + "id": "job-004", + "cbw": 166666666 + }, + { + "id": "job-005", + "cbw": 166666666 + }, + { + "id": "job-008", + "cbw": 166666666 + }, + { + "id": "job-009", + "cbw": 166666666 + }, + { + "id": "job-010", + "cbw": 166666666 + } + ] + }, + { + "index": 11, + "rejected": true + }, + { + "index": 12, + "assigned": 100000000, + "updates": [ + { + "id": "job-003", + "cbw": 250000000 + }, + { + "id": "job-005", + "cbw": 100000000 + } + ] + }, + { + "index": 13, + "assigned": 100000000, + "updates": [ + { + "id": "job-004", + "cbw": 145666666 + }, + { + "id": "job-008", + "cbw": 145666666 + }, + { + "id": "job-009", + "cbw": 145666666 + }, + { + "id": "job-010", + "cbw": 145666666 + } + ] + }, + { + "index": 14, + "assigned": 125000000, + "updates": [ + { + "id": "job-004", + "cbw": 125000000 + }, + { + "id": "job-008", + "cbw": 125000000 + }, + { + "id": "job-009", + "cbw": 125000000 + }, + { + "id": "job-010", + "cbw": 125000000 + } + ] + }, + { + "index": 15, + "rejected": true + }, + { + "index": 16, + "rejected": true + }, + { + "index": 17, + "rejected": true + }, + { + "index": 18, + "rejected": true + }, + { + "index": 19, + "assigned": 111111111, + "updates": [ + { + "id": "job-004", + "cbw": 111111111 + }, + { + "id": "job-008", + "cbw": 111111111 + }, + { + "id": "job-009", + "cbw": 111111111 + }, + { + "id": "job-010", + "cbw": 111111111 + }, + { + "id": "job-015", + "cbw": 111111111 + } + ] + }, + { + "index": 20, + "rejected": true + }, + { + "index": 21, + "rejected": true + } + ], + "allocations": { + "job-003": 250000000, + "job-004": 111111111, + "job-005": 100000000, + "job-006": 100000000, + "job-007": 100000000, + "job-008": 111111111, + "job-009": 111111111, + "job-010": 111111111, + "job-011": 100000000, + "job-013": 100000000, + "job-014": 100000000, + "job-015": 111111111, + "job-020": 111111111 + }, + "digest": "5ffae027a441ecb30d79cc85129592b4a0a6e983be7d4e83052977208719c860" +} diff --git a/internal/dispatcher/resource/testdata/sim-trace-shared-seed0.json b/internal/dispatcher/resource/testdata/sim-trace-shared-seed0.json new file mode 100644 index 00000000..fd092dc5 --- /dev/null +++ b/internal/dispatcher/resource/testdata/sim-trace-shared-seed0.json @@ -0,0 +1,68 @@ +{ + "scenario": { + "name": "shared-destination", + "seed": 0, + "filter": 3, + "options": { + "executor_capacity": 1000000000, + "destination_capacity": 1000000000, + "source_capacity": 1000000000 + }, + "events": [ + { + "update_destination": { + "target": "203.0.113.0/30", + "capacity": 400000000 + } + }, + { + "submit": { + "id": "shared-1", + "executor": "exec-1", + "source": "10.0.0.1", + "floor": 100000000, + "ceil": 250000000, + "destinations": [ + "203.0.113.1" + ] + } + }, + { + "submit": { + "id": "shared-2", + "executor": "exec-2", + "source": "10.0.0.2", + "floor": 100000000, + "ceil": 250000000, + "destinations": [ + "203.0.113.1" + ] + } + } + ] + }, + "steps": [ + { + "index": 0 + }, + { + "index": 1, + "assigned": 250000000 + }, + { + "index": 2, + "assigned": 200000000, + "updates": [ + { + "id": "shared-1", + "cbw": 200000000 + } + ] + } + ], + "allocations": { + "shared-1": 200000000, + "shared-2": 200000000 + }, + "digest": "78aa6e4d8d47dd3bfd89c253723382ad96a8adc5b53de5f7acfc7bc98183adec" +} diff --git a/internal/dispatcher/resource/testdata/sim-traces.md b/internal/dispatcher/resource/testdata/sim-traces.md new file mode 100644 index 00000000..6370990b --- /dev/null +++ b/internal/dispatcher/resource/testdata/sim-traces.md @@ -0,0 +1,37 @@ +# Simulator trace fixtures + +Generated with companion simulator commit +`9d747d40796afe4bf029330cb49ba508b930ebf3`, using Go 1.26.8 on Linux amd64: + +```sh +go run . -scenario congestion -seed 7 -trace congestion.json +go run . -scenario churn -seed 7 -trace churn.json +go run . -scenario hierarchical -seed 7 -trace hierarchical.json +go run . -replay testdata/shared-destination.json -trace shared.json +``` + +The shared scenario uses seed 0 and explicit input, not random generation. +`sim_trace_replay_test.go` pins each whole-file SHA-256 and logical trace digest. +The congestion trace is unchanged from simulator `e918b0c1`. + +Each trace replays against the real core destination owner, overlapping +scheduler reservations and per-executor application limiter. Tests compare +admission and removal, floor/ceiling bounds, destination shares handed to each +executor, runtime limit conservation and final ownership release. They include +capacity changes during churn and per-address limits expanded from small CIDR +prefixes. Prefix limits are not aggregate CIDR budgets. Sources in these +fixtures map one-to-one to executors with equal capacity; core does not have +an independent source allocator in this comparison. Per-job fair shares can +differ between models while all shared invariants hold. + +The simulator's previous shared-destination allocation spent 500 Mbit/s across +a 400 Mbit/s address; hierarchical seed 7 spent 500 Mbit/s across a 200 Mbit/s +address. These inputs now complete successfully after the simulator began +reserving and sharing destination and source resources above admitted floors. +The core comparison admits the same jobs and conserves each destination budget. + +A failed event prints the shortest applied prefix, including the scenario name, +seed, filter, capacities and original events. Save it as `failure.json`, then +run the companion with `-replay failure.json -trace replay.json`. A simulator +invariant violation also writes a `.minimal.json` trace. Neither model check +measures packets, kernel enforcement, burst behavior or distributed timing. From 6fcd8454b3630399dfe3fe049c186ff47e029ef5 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:37:14 +0200 Subject: [PATCH 5/6] Check recorded simulator allocations at every replay step --- .../resource/sim_trace_replay_test.go | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/dispatcher/resource/sim_trace_replay_test.go b/internal/dispatcher/resource/sim_trace_replay_test.go index 80008b76..312463ad 100644 --- a/internal/dispatcher/resource/sim_trace_replay_test.go +++ b/internal/dispatcher/resource/sim_trace_replay_test.go @@ -56,9 +56,14 @@ type simScenario struct { type simTrace struct { Scenario simScenario `json:"scenario"` Steps []struct { - Index int `json:"index"` - Rejected bool `json:"rejected"` - Skipped bool `json:"skipped"` + Index int `json:"index"` + Rejected bool `json:"rejected"` + Skipped bool `json:"skipped"` + Assigned bitrate.Bitrate `json:"assigned"` + Updates []struct { + ID string `json:"id"` + CBW bitrate.Bitrate `json:"cbw"` + } `json:"updates"` } `json:"steps"` Allocations map[string]bitrate.Bitrate `json:"allocations"` Violation json.RawMessage `json:"violation"` @@ -114,6 +119,7 @@ func replaySimulator(t *testing.T, trace simTrace) { limiters := map[string]*app.Limiter{} sources := map[string]string{} active := map[string]*simJob{} + simRates := map[string]bitrate.Bitrate{} touched := map[string]bool{} runID := func(id string) uuid.UUID { return uuid.NewSHA1(uuid.NameSpaceOID, []byte(id)) } request := func(j *simJob) schedule.Request { @@ -174,8 +180,18 @@ func replaySimulator(t *testing.T, trace simTrace) { } } execSums := map[string]bitrate.Bitrate{} + simExecSums := map[string]bitrate.Bitrate{} + simDestSums := map[string]bitrate.Bitrate{} addrSums := map[[2]string]bitrate.Bitrate{} for _, j := range active { + rate, ok := simRates[j.ID] + if !ok || rate < j.Floor || rate > j.Ceil { + violation(i, "simulator share %s=%d violates policy", j.ID, rate) + } + simExecSums[j.Executor] += rate + for _, dest := range j.Destinations { + simDestSums[dest] += rate + } limit, _, err := executor(j.Executor).GetExecLimit(runID(j.ID)) if err != nil || limit < j.Floor || limit > j.Ceil { violation(i, "runtime executor share %s=%d: %v", j.ID, limit, err) @@ -190,10 +206,15 @@ func replaySimulator(t *testing.T, trace simTrace) { } } for exec, cap := range capacity { - if scheduler.QueryMaxExec(exec, from, to) != execFloors[exec] || execSums[exec] > cap { + if scheduler.QueryMaxExec(exec, from, to) != execFloors[exec] || execSums[exec] > cap || simExecSums[exec] > cap { violation(i, "executor %s reservation or runtime capacity violated", exec) } } + for dest, sum := range simDestSums { + if sum > usage.Cap(dest) { + violation(i, "simulator shares on %s exceed capacity", dest) + } + } for key, sum := range addrSums { if sum > budgets[key] { violation(i, "runtime spends %d above dispatcher budget %d on %v", sum, budgets[key], key) @@ -211,6 +232,7 @@ func replaySimulator(t *testing.T, trace simTrace) { violation(i, "removed runtime job %s retained", j.ID) } delete(active, j.ID) + delete(simRates, j.ID) } for i, raw := range trace.Scenario.Events { var event simEvent @@ -252,6 +274,7 @@ func replaySimulator(t *testing.T, trace simTrace) { violation(i, "runtime admission: %v", err) } active[j.ID] = j + simRates[j.ID] = step.Assigned for _, dest := range j.Destinations { touched[dest] = true } @@ -281,8 +304,17 @@ func replaySimulator(t *testing.T, trace simTrace) { default: violation(i, "unknown event") } + for _, update := range step.Updates { + if active[update.ID] == nil { + violation(i, "simulator updated inactive job %s", update.ID) + } + simRates[update.ID] = update.CBW + } check(i) } + if !maps.Equal(simRates, trace.Allocations) { + t.Fatal("final simulator rates differ from replayed updates") + } if !slices.Equal(slices.Sorted(maps.Keys(active)), slices.Sorted(maps.Keys(trace.Allocations))) { t.Fatal("final active membership differs") } From 18351c4fe3ac684a541170fed3a24d5f8e081a01 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Wed, 7 Oct 2026 14:50:18 +0200 Subject: [PATCH 6/6] Record guest source paths and hashes as explicit metadata fields --- .gitleaksignore | 2 ++ pkg/debuglet/abi_foreign_guests_test.go | 29 +++++++++------- .../testdata/guest_c/guest_c.wasm.json | 14 +++++--- .../testdata/guest_rust/guest_rust.wasm.json | 34 ++++++++++++++----- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 87b57159..f1168668 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -19,3 +19,5 @@ internal/dispatcher/transport/api/auth_test.go:generic-api-key:28 # and lack of runtime authority are recorded in 209765cb7c74d04e70ef68ac289d7c1c4b764421. 11cfc7430a18964e308e5cd2426badc5630de19f:local-config/dispatcher/key:private-key:1 11cfc7430a18964e308e5cd2426badc5630de19f:local-config/executor/key:private-key:1 +# Historical C fixture source-content checksum; explicit fields replace this layout. +b7d37f1fbd428ff2ce7f484c8f1793e90e4ea885:pkg/debuglet/testdata/guest_c/guest_c.wasm.json:generic-api-key:11 diff --git a/pkg/debuglet/abi_foreign_guests_test.go b/pkg/debuglet/abi_foreign_guests_test.go index f9236850..c4822fd5 100644 --- a/pkg/debuglet/abi_foreign_guests_test.go +++ b/pkg/debuglet/abi_foreign_guests_test.go @@ -29,15 +29,18 @@ import ( // foreignGuestRecord is the record tracked beside a retained foreign guest. type foreignGuestRecord struct { - GuestABI string `json:"guest_abi"` - Language string `json:"language"` - Toolchain string `json:"toolchain"` - Image string `json:"image"` - Target string `json:"target"` - Build string `json:"build"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` - Sources map[string]string `json:"sources"` + GuestABI string `json:"guest_abi"` + Language string `json:"language"` + Toolchain string `json:"toolchain"` + Image string `json:"image"` + Target string `json:"target"` + Build string `json:"build"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + Sources []struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + } `json:"sources"` } // foreignGuests are the retained guests, by language. @@ -116,14 +119,14 @@ func retainedForeignGuest(t *testing.T, language, module, recordPath string) []b if len(record.Sources) == 0 { t.Fatal("retained foreign guest has no source digests") } - for path, want := range record.Sources { - source, err := os.ReadFile(filepath.Join("../..", path)) + for _, input := range record.Sources { + source, err := os.ReadFile(filepath.Join("../..", input.Path)) if err != nil { t.Fatal(err) } sum := sha256.Sum256(source) - if hex.EncodeToString(sum[:]) != want { - t.Fatalf("%s changed since the retained guest was built; rebuild the guest and its record", path) + if hex.EncodeToString(sum[:]) != input.SHA256 { + t.Fatalf("%s changed since the retained guest was built; rebuild the guest and its record", input.Path) } } if record.Bytes != int64(len(wasm)) { diff --git a/pkg/debuglet/testdata/guest_c/guest_c.wasm.json b/pkg/debuglet/testdata/guest_c/guest_c.wasm.json index 7cef9db3..bbb1dbe9 100644 --- a/pkg/debuglet/testdata/guest_c/guest_c.wasm.json +++ b/pkg/debuglet/testdata/guest_c/guest_c.wasm.json @@ -7,8 +7,14 @@ "build": "clang --target=wasm32-wasip1 -Oz -s -Wall -Werror -o guest_c.wasm main.c", "sha256": "ad4523fbce3146f41feca0ff1ddbace82e5b5863faa8f100882eb21c1cd18d2b", "bytes": 27813, - "sources": { - "examples/debuglets/c/common/debuglet_api.h": "03e0e2715cdac7389dcdf2b08f469810e1154983077fb859ed23ef5912ad011b", - "pkg/debuglet/testdata/guest_c/main.c": "c81bd5b873c6bf825ebefb8c1c1c3600d47f08d11c987b7d00dcae0cb16d9b1a" - } + "sources": [ + { + "path": "examples/debuglets/c/common/debuglet_api.h", + "sha256": "03e0e2715cdac7389dcdf2b08f469810e1154983077fb859ed23ef5912ad011b" + }, + { + "path": "pkg/debuglet/testdata/guest_c/main.c", + "sha256": "c81bd5b873c6bf825ebefb8c1c1c3600d47f08d11c987b7d00dcae0cb16d9b1a" + } + ] } diff --git a/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json b/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json index d64246a3..89726064 100644 --- a/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json +++ b/pkg/debuglet/testdata/guest_rust/guest_rust.wasm.json @@ -7,12 +7,30 @@ "build": "RUSTFLAGS=--remap-path-prefix=/= cargo build --release --locked --target wasm32-wasip1 (opt-level = \"s\", strip = true)", "sha256": "fa81d338109e9cb398902105a3b0ecad0bb2f8dc39163a0974cdc98fc31f4d96", "bytes": 66914, - "sources": { - "examples/debuglets/rust/debuglet/Cargo.toml": "ac3e6be1bda3a5300987503c95240922fd8fb312c5557fdaaf06acf98b07daf6", - "examples/debuglets/rust/debuglet/src/lib.rs": "85eb88c5fee3593a0330dae065d5118730dc5c5743bbcd2153458d9e50947cb2", - "pkg/debuglet/testdata/guest_rust/Cargo.toml": "5630b5362026c40331169ed77dd01381d85e65ec8590aeaee3c1202c7c4d8111", - "pkg/debuglet/testdata/guest_rust/Cargo.lock": "87df50f3798c3dece1e84d61704c4ee0055644c69393d0142e45f515a7be5930", - "pkg/debuglet/testdata/guest_rust/src/main.rs": "a91921ba080d11909ccda25df29d8305cf080a87852301352d11a22c57b8ac8b", - "examples/debuglets/rust/debuglet/LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" - } + "sources": [ + { + "path": "examples/debuglets/rust/debuglet/Cargo.toml", + "sha256": "ac3e6be1bda3a5300987503c95240922fd8fb312c5557fdaaf06acf98b07daf6" + }, + { + "path": "examples/debuglets/rust/debuglet/src/lib.rs", + "sha256": "85eb88c5fee3593a0330dae065d5118730dc5c5743bbcd2153458d9e50947cb2" + }, + { + "path": "pkg/debuglet/testdata/guest_rust/Cargo.toml", + "sha256": "5630b5362026c40331169ed77dd01381d85e65ec8590aeaee3c1202c7c4d8111" + }, + { + "path": "pkg/debuglet/testdata/guest_rust/Cargo.lock", + "sha256": "87df50f3798c3dece1e84d61704c4ee0055644c69393d0142e45f515a7be5930" + }, + { + "path": "pkg/debuglet/testdata/guest_rust/src/main.rs", + "sha256": "a91921ba080d11909ccda25df29d8305cf080a87852301352d11a22c57b8ac8b" + }, + { + "path": "examples/debuglets/rust/debuglet/LICENSE", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + } + ] }