fleetmesh-node 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/config/change.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Who changed what, and under which session.
|
|
2
|
+
|
|
3
|
+
A node managed by one operator needs none of this: the file changed, and
|
|
4
|
+
there was only ever one person who could have changed it. A node managed by
|
|
5
|
+
several does, and today it has nothing. `NodeConfig.operator` is a single
|
|
6
|
+
pubkey describing whose node it is, not who last touched it. The control
|
|
7
|
+
socket is chmod 0o600 with no principal at all, so every change over it is
|
|
8
|
+
anonymous and indistinguishable. There is no history of config changes
|
|
9
|
+
anywhere in the tree.
|
|
10
|
+
|
|
11
|
+
Three things make a change attributable, and the node currently has none of
|
|
12
|
+
them:
|
|
13
|
+
|
|
14
|
+
operator WHO - a pubkey or did:nostr. This is the UCAN issuer, and it
|
|
15
|
+
is the principal `Parameter.requires` is waiting for: that
|
|
16
|
+
field ships declared-only precisely because nothing
|
|
17
|
+
authenticated one. `Actor` is where it arrives.
|
|
18
|
+
session WHICH RUN - a uuid meshctl mints per invocation, so a dozen
|
|
19
|
+
changes from one `meshctl apply` are one act, and two
|
|
20
|
+
operators racing are two.
|
|
21
|
+
tool WHAT MADE IT - 'meshctl/0.2.0', or 'meshnode' for a local
|
|
22
|
+
command. A change nobody remembers making is usually a script.
|
|
23
|
+
|
|
24
|
+
A `Change` is one parameter moving, with the actor and the layer it came
|
|
25
|
+
from. Its `resource` is `Parameter.resource()` - the same string UCAN will
|
|
26
|
+
name in `with`, derived rather than written twice - so the record of what
|
|
27
|
+
happened and the authority to make it happen refer to the same thing.
|
|
28
|
+
|
|
29
|
+
Changes are announced on a declared channel, not written to a private file,
|
|
30
|
+
so they reach whatever backends an operator configured and are not a fourth
|
|
31
|
+
output path beside the journal, the tap and the log.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import contextvars
|
|
37
|
+
import dataclasses
|
|
38
|
+
import socket
|
|
39
|
+
import struct
|
|
40
|
+
import uuid
|
|
41
|
+
from typing import Any, Iterator, Mapping, Optional, Sequence, TypedDict
|
|
42
|
+
|
|
43
|
+
from fleetmesh_core.identity import did as did_mod
|
|
44
|
+
|
|
45
|
+
from .. import channels
|
|
46
|
+
|
|
47
|
+
# An actor the node cannot name. Used where a change is real but its author is
|
|
48
|
+
# not established - a file edited by hand, a pre-session command - rather than
|
|
49
|
+
# inventing an operator, because a wrong name in an audit record is worse than
|
|
50
|
+
# an honest blank.
|
|
51
|
+
UNKNOWN_OPERATOR: str = ''
|
|
52
|
+
LOCAL_TOOL: str = 'meshnode'
|
|
53
|
+
|
|
54
|
+
# The ladder, weakest first. Every rung is recorded, not only the one that
|
|
55
|
+
# answered, because the interesting facts live between the rungs:
|
|
56
|
+
#
|
|
57
|
+
# a rung ABSENT is not a rung that FAILED. "no signature offered" and
|
|
58
|
+
# "signature did not verify" are different events and only one of them is
|
|
59
|
+
# an attack.
|
|
60
|
+
#
|
|
61
|
+
# two rungs naming different subjects is impersonation, and it is visible
|
|
62
|
+
# only if both are kept. An actor asserting operator X whose signature
|
|
63
|
+
# verifies as Y is the case this exists to catch.
|
|
64
|
+
#
|
|
65
|
+
# an audit record has to be re-readable years later, so it must say what was
|
|
66
|
+
# checked at the time rather than what the current code would check.
|
|
67
|
+
ASSERTED: str = 'asserted' # the caller said so. No verification at all.
|
|
68
|
+
PEERCRED: str = 'peercred' # the kernel named a local uid on the socket.
|
|
69
|
+
SIGNED: str = 'signed' # a signature verified against the claimed DID.
|
|
70
|
+
DELEGATED: str = 'delegated' # a UCAN chain proved the ability, maybe via hops.
|
|
71
|
+
RUNGS: tuple[str, ...] = (ASSERTED, PEERCRED, SIGNED, DELEGATED)
|
|
72
|
+
|
|
73
|
+
# Rungs this node can actually climb today. DELEGATED needs the UCAN
|
|
74
|
+
# verification that `Parameter.requires` is also waiting on, so it is declared
|
|
75
|
+
# and never produced - the same footing, and for the same reason, as
|
|
76
|
+
# ACCESS_DECLARED_ONLY in parameter.py. A rung the node cannot climb must read
|
|
77
|
+
# as absent, never as satisfied.
|
|
78
|
+
RUNGS_AVAILABLE: tuple[str, ...] = (ASSERTED, PEERCRED, SIGNED)
|
|
79
|
+
|
|
80
|
+
OK, FAILED, ABSENT = 'ok', 'failed', 'absent'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _method_of(did: str) -> str:
|
|
84
|
+
"""The DID method name, or '' for anything that is not a DID.
|
|
85
|
+
|
|
86
|
+
Local because `did.parse` raises for a method this profile does not
|
|
87
|
+
speak, and a proof has to be able to record an unrecognised one rather
|
|
88
|
+
than fail while recording that it failed.
|
|
89
|
+
"""
|
|
90
|
+
parts = did.split(':', 2)
|
|
91
|
+
return parts[1] if len(parts) >= 3 and parts[0] == 'did' else ''
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ProofRecord(TypedDict):
|
|
95
|
+
"""One rung as a backend stores it."""
|
|
96
|
+
rung: str
|
|
97
|
+
ok: bool
|
|
98
|
+
subject: str
|
|
99
|
+
detail: str
|
|
100
|
+
method: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass(frozen=True)
|
|
104
|
+
class Proof:
|
|
105
|
+
"""One rung, and what it said.
|
|
106
|
+
|
|
107
|
+
`subject` is who this rung names in its own terms: a DID for SIGNED, a
|
|
108
|
+
`uid=1000` for PEERCRED. Kept per rung rather than collapsed into one
|
|
109
|
+
operator field, because collapsing is exactly what hides a disagreement.
|
|
110
|
+
"""
|
|
111
|
+
rung: str
|
|
112
|
+
ok: bool
|
|
113
|
+
subject: str = ''
|
|
114
|
+
detail: str = ''
|
|
115
|
+
method: str = ''
|
|
116
|
+
|
|
117
|
+
def describe(self) -> 'ProofRecord':
|
|
118
|
+
return {'rung': self.rung, 'ok': self.ok, 'subject': self.subject,
|
|
119
|
+
'detail': self.detail, 'method': self.method}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def asserted(operator: str) -> Proof:
|
|
123
|
+
"""The caller's claim. `ok` is True because the claim was made, not
|
|
124
|
+
because it was checked - the rung itself is what says it is unverified."""
|
|
125
|
+
return Proof(rung=ASSERTED, ok=bool(operator), subject=operator,
|
|
126
|
+
detail='claimed by the caller, unverified',
|
|
127
|
+
method=_method_of(operator))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def peercred_of(conn: Any) -> Optional[Proof]:
|
|
131
|
+
"""SO_PEERCRED off a unix socket, or None where the platform has none.
|
|
132
|
+
|
|
133
|
+
Linux answers with SO_PEERCRED and a struct of pid, uid, gid. macOS and
|
|
134
|
+
the BSDs answer a different question with LOCAL_PEERCRED and a different
|
|
135
|
+
struct, and `socket` does not expose it portably, so this returns None
|
|
136
|
+
there rather than guessing - a rung that cannot be climbed must read
|
|
137
|
+
absent, not satisfied, and a fabricated uid in an audit record is worse
|
|
138
|
+
than an honest blank.
|
|
139
|
+
|
|
140
|
+
None is also the answer for a socket that is not a unix socket at all,
|
|
141
|
+
because a uid means nothing across a network.
|
|
142
|
+
"""
|
|
143
|
+
so_peercred = getattr(socket, 'SO_PEERCRED', None)
|
|
144
|
+
if so_peercred is None:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
if conn.family != socket.AF_UNIX:
|
|
148
|
+
return None
|
|
149
|
+
raw = conn.getsockopt(socket.SOL_SOCKET, so_peercred, struct.calcsize('3i'))
|
|
150
|
+
pid, uid, gid = struct.unpack('3i', raw)
|
|
151
|
+
except (AttributeError, OSError, struct.error):
|
|
152
|
+
return None
|
|
153
|
+
return peercred(uid, gid, pid)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def peercred(uid: int, gid: int = -1, pid: int = -1) -> Proof:
|
|
157
|
+
"""SO_PEERCRED on the control socket.
|
|
158
|
+
|
|
159
|
+
Real and free, and it identifies a unix user rather than an operator, so
|
|
160
|
+
it corroborates and never establishes: a uid is not a DID and this rung
|
|
161
|
+
never claims one.
|
|
162
|
+
"""
|
|
163
|
+
bits = ['uid=%d' % uid]
|
|
164
|
+
if gid >= 0:
|
|
165
|
+
bits.append('gid=%d' % gid)
|
|
166
|
+
if pid >= 0:
|
|
167
|
+
bits.append('pid=%d' % pid)
|
|
168
|
+
return Proof(rung=PEERCRED, ok=uid >= 0, subject='uid:%d' % uid,
|
|
169
|
+
detail=' '.join(bits) + '; a local user, not a DID')
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def signature(operator: str, msg32: bytes, sig_hex: str) -> Proof:
|
|
173
|
+
"""A signature over `msg32`, checked against the claimed DID's own key.
|
|
174
|
+
|
|
175
|
+
Verified offline or not at all: `fleetmesh_core.identity.did` does no I/O, so a
|
|
176
|
+
resolvable method comes back refused by name rather than dialling out on
|
|
177
|
+
an audit path.
|
|
178
|
+
"""
|
|
179
|
+
k = did_mod.signing_key(operator)
|
|
180
|
+
if k is None:
|
|
181
|
+
return Proof(rung=SIGNED, ok=False, subject=operator,
|
|
182
|
+
detail=did_mod.refusal(operator), method=_method_of(operator))
|
|
183
|
+
curve, _raw = k
|
|
184
|
+
ok = did_mod.verify(operator, msg32, sig_hex)
|
|
185
|
+
return Proof(rung=SIGNED, ok=ok, subject=operator, method=_method_of(operator),
|
|
186
|
+
detail='%s over %s' % ('verified' if ok else 'did not verify', curve))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def new_session() -> str:
|
|
190
|
+
"""A session id. meshctl mints one per invocation and passes it down."""
|
|
191
|
+
return str(uuid.uuid4())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclasses.dataclass(frozen=True)
|
|
195
|
+
class Actor:
|
|
196
|
+
"""Who is acting, in which run, through what.
|
|
197
|
+
|
|
198
|
+
Frozen, because an actor is a statement about a moment and editing one
|
|
199
|
+
after the fact is exactly what an audit record must not permit.
|
|
200
|
+
"""
|
|
201
|
+
operator: str = UNKNOWN_OPERATOR
|
|
202
|
+
session: str = ''
|
|
203
|
+
tool: str = LOCAL_TOOL
|
|
204
|
+
at: int = 0
|
|
205
|
+
proofs: tuple[Proof, ...] = ()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def named(self) -> bool:
|
|
209
|
+
"""Whether this actor identifies anyone. False is a legitimate answer
|
|
210
|
+
and is reported as one rather than filled in."""
|
|
211
|
+
return bool(self.operator)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def assurance(self) -> str:
|
|
215
|
+
"""The highest rung that verified, or '' for none.
|
|
216
|
+
|
|
217
|
+
One number is not the record - `ladder()` is - but it is what a policy
|
|
218
|
+
compares against, so it is computed from the proofs rather than
|
|
219
|
+
stored, and cannot be asserted independently of them.
|
|
220
|
+
"""
|
|
221
|
+
best = ''
|
|
222
|
+
for p in self.proofs:
|
|
223
|
+
if p.ok and (not best or RUNGS.index(p.rung) > RUNGS.index(best)):
|
|
224
|
+
best = p.rung
|
|
225
|
+
return best
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def verified(self) -> bool:
|
|
229
|
+
"""Whether an identity was actually proved, not merely claimed.
|
|
230
|
+
|
|
231
|
+
ASSERTED and PEERCRED do not count: the first is unchecked by
|
|
232
|
+
definition and the second names a unix user. This is the distinction
|
|
233
|
+
an audit record must keep, because a trail that cannot tell a claim
|
|
234
|
+
from a proof reads as stronger than it is.
|
|
235
|
+
"""
|
|
236
|
+
return self.assurance in (SIGNED, DELEGATED)
|
|
237
|
+
|
|
238
|
+
def ladder(self) -> 'dict[str, str]':
|
|
239
|
+
"""Every rung and its state: ok, failed, or absent.
|
|
240
|
+
|
|
241
|
+
The whole ladder, not just the rung that answered. A rung that was
|
|
242
|
+
never offered and a rung that failed are different events, and only
|
|
243
|
+
the second is an attack.
|
|
244
|
+
"""
|
|
245
|
+
state = {r: ABSENT for r in RUNGS}
|
|
246
|
+
for p in self.proofs:
|
|
247
|
+
if p.rung in state:
|
|
248
|
+
state[p.rung] = OK if p.ok else FAILED
|
|
249
|
+
return state
|
|
250
|
+
|
|
251
|
+
def conflicts(self) -> list[str]:
|
|
252
|
+
"""Rungs naming a DID that is not the one claimed.
|
|
253
|
+
|
|
254
|
+
This is the impersonation case and it is detectable only because
|
|
255
|
+
every rung keeps its own subject: an actor asserting operator X whose
|
|
256
|
+
signature verifies as Y would, with one collapsed field, have been
|
|
257
|
+
recorded as X.
|
|
258
|
+
"""
|
|
259
|
+
out: list[str] = []
|
|
260
|
+
for p in self.proofs:
|
|
261
|
+
if not p.subject or p.subject.startswith('uid:'):
|
|
262
|
+
continue
|
|
263
|
+
if self.operator and p.subject != self.operator:
|
|
264
|
+
out.append('%s names %s, not %s' % (p.rung, p.subject[:24], self.operator[:24]))
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
def describe(self) -> ActorRecord:
|
|
268
|
+
return {'operator': self.operator or '(unknown)', 'session': self.session,
|
|
269
|
+
'tool': self.tool, 'at': self.at,
|
|
270
|
+
'assurance': self.assurance or '(none)',
|
|
271
|
+
'verified': self.verified,
|
|
272
|
+
'ladder': self.ladder(),
|
|
273
|
+
'conflicts': self.conflicts(),
|
|
274
|
+
'proofs': [p.describe() for p in self.proofs]}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# The actor of the request in flight. A ContextVar and not an attribute on
|
|
278
|
+
# the node: the control socket serves every connection on its own thread, so
|
|
279
|
+
# `node.acting = actor` let two concurrent requests overwrite each other and
|
|
280
|
+
# a change could be attributed to whoever asked second. Each thread (and each
|
|
281
|
+
# asyncio task, should one ever call in) sees only the actor it was given.
|
|
282
|
+
ACTING: contextvars.ContextVar[Optional['Actor']] = contextvars.ContextVar('fleetmesh_acting', default=None)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def acting() -> Optional['Actor']:
|
|
286
|
+
"""Who caused the work running now, or None outside a request."""
|
|
287
|
+
return ACTING.get()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@contextlib.contextmanager
|
|
291
|
+
def acting_as(actor: Optional['Actor']) -> Iterator[Optional['Actor']]:
|
|
292
|
+
"""Run a block on behalf of `actor`, restoring whoever was acting before."""
|
|
293
|
+
token = ACTING.set(actor)
|
|
294
|
+
try:
|
|
295
|
+
yield actor
|
|
296
|
+
finally:
|
|
297
|
+
ACTING.reset(token)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ActorRecord(TypedDict):
|
|
301
|
+
"""An actor as a backend stores it. A named record for the reason
|
|
302
|
+
check-dicts.py enforces: an audit row is read by field name.
|
|
303
|
+
|
|
304
|
+
`ladder` and `proofs` are both here on purpose. The ladder is the summary
|
|
305
|
+
a person reads; the proofs are what was actually checked, which is what a
|
|
306
|
+
reader needs years later when the code has moved on.
|
|
307
|
+
"""
|
|
308
|
+
operator: str
|
|
309
|
+
session: str
|
|
310
|
+
tool: str
|
|
311
|
+
at: int
|
|
312
|
+
assurance: str
|
|
313
|
+
verified: bool
|
|
314
|
+
ladder: dict[str, str]
|
|
315
|
+
conflicts: list[str]
|
|
316
|
+
proofs: list[ProofRecord]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class ChangeRecord(TypedDict):
|
|
320
|
+
"""One change, as a backend stores it."""
|
|
321
|
+
resource: str
|
|
322
|
+
was: Any
|
|
323
|
+
now: Any
|
|
324
|
+
source: str
|
|
325
|
+
operator: str
|
|
326
|
+
session: str
|
|
327
|
+
tool: str
|
|
328
|
+
at: int
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclasses.dataclass(frozen=True)
|
|
332
|
+
class Change:
|
|
333
|
+
"""One parameter moving from `was` to `now`.
|
|
334
|
+
|
|
335
|
+
`resource` comes from `Parameter.resource()`, so the audit record and the
|
|
336
|
+
UCAN capability that will authorise it name the same thing. `secret`
|
|
337
|
+
suppresses the values: an audit trail that prints the wallet URI it just
|
|
338
|
+
changed has leaked it to everyone who can read the log.
|
|
339
|
+
"""
|
|
340
|
+
resource: str
|
|
341
|
+
was: Any
|
|
342
|
+
now: Any
|
|
343
|
+
source: str
|
|
344
|
+
actor: Actor
|
|
345
|
+
secret: bool = False
|
|
346
|
+
|
|
347
|
+
REDACTED: str = '(secret)'
|
|
348
|
+
|
|
349
|
+
def describe(self) -> ChangeRecord:
|
|
350
|
+
was: Any = self.REDACTED if self.secret else self.was
|
|
351
|
+
now: Any = self.REDACTED if self.secret else self.now
|
|
352
|
+
return {'resource': self.resource, 'was': was, 'now': now,
|
|
353
|
+
'source': self.source, 'operator': self.actor.operator or '(unknown)',
|
|
354
|
+
'session': self.actor.session, 'tool': self.actor.tool, 'at': self.actor.at}
|
|
355
|
+
|
|
356
|
+
def said(self) -> str:
|
|
357
|
+
who = self.actor.operator[:16] if self.actor.operator else '(unknown operator)'
|
|
358
|
+
shown_was = self.REDACTED if self.secret else self.was
|
|
359
|
+
shown_now = self.REDACTED if self.secret else self.now
|
|
360
|
+
sess = (' session ' + self.actor.session[:8]) if self.actor.session else ''
|
|
361
|
+
return 'config %s %r -> %r by %s via %s%s' % (
|
|
362
|
+
self.resource, shown_was, shown_now, who, self.source, sess)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def diff(resource_of: Any, before: Mapping[str, Any], after: Mapping[str, Any],
|
|
366
|
+
params: Sequence[Any], source: str, actor: Actor) -> list[Change]:
|
|
367
|
+
"""Every parameter whose value differs between `before` and `after`.
|
|
368
|
+
|
|
369
|
+
Taken over the declared parameters rather than over the raw keys, so a key
|
|
370
|
+
nobody declared cannot be recorded as a change to a setting that does not
|
|
371
|
+
exist, and a `secret` is carried through to the record that must redact it.
|
|
372
|
+
"""
|
|
373
|
+
out: list[Change] = []
|
|
374
|
+
for p in params:
|
|
375
|
+
was, now = before.get(p.key), after.get(p.key)
|
|
376
|
+
if was == now:
|
|
377
|
+
continue
|
|
378
|
+
out.append(Change(resource=resource_of(p), was=was, now=now,
|
|
379
|
+
source=source, actor=actor, secret=bool(getattr(p, 'secret', False))))
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# The channel a change is announced on. Declared here beside the record rather
|
|
384
|
+
# than in a central topic list, which is the arrangement node/channels.py
|
|
385
|
+
# exists to establish.
|
|
386
|
+
CHANGED = channels.Channel(
|
|
387
|
+
name='config.changed',
|
|
388
|
+
description='A configuration parameter moved, with who moved it, the session they '
|
|
389
|
+
'moved it in, and the escalation layer it came from. Secret parameters '
|
|
390
|
+
'report that they changed and not to what.',
|
|
391
|
+
level=channels.NOTE,
|
|
392
|
+
wire=lambda change: change.describe(),
|
|
393
|
+
say=lambda change: change.said(),
|
|
394
|
+
tags=('config', 'audit'))
|
|
395
|
+
|
|
396
|
+
# Raised when a change arrives with nobody named. Separate from the change
|
|
397
|
+
# itself so an operator can require attribution by watching one channel,
|
|
398
|
+
# rather than parsing every change for a blank field.
|
|
399
|
+
UNATTRIBUTED = channels.Channel(
|
|
400
|
+
name='config.unattributed',
|
|
401
|
+
description='A configuration change arrived with no operator identity. Expected for a '
|
|
402
|
+
'file edited by hand; on a node managed by several operators it means a '
|
|
403
|
+
'tool is not passing one.',
|
|
404
|
+
level=channels.TROUBLE,
|
|
405
|
+
wire=lambda change: change.describe(),
|
|
406
|
+
say=lambda change: 'config %s changed with no operator named (via %s)' % (
|
|
407
|
+
change.resource, change.source),
|
|
408
|
+
tags=('config', 'audit', 'trouble'))
|
|
409
|
+
|
|
410
|
+
IMPERSONATION = channels.Channel(
|
|
411
|
+
name='config.identity_conflict',
|
|
412
|
+
description='Two rungs of the identity ladder named different subjects - an actor '
|
|
413
|
+
'claiming one operator whose signature verified as another. Visible only '
|
|
414
|
+
'because every rung keeps its own subject.',
|
|
415
|
+
level=channels.TROUBLE,
|
|
416
|
+
wire=lambda change: change.describe(),
|
|
417
|
+
say='identity conflict on {resource} (operator {operator})',
|
|
418
|
+
tags=('config', 'audit', 'identity', 'trouble'))
|
|
419
|
+
|
|
420
|
+
CHANNELS: tuple[channels.Channel, ...] = (CHANGED, UNATTRIBUTED, IMPERSONATION)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def announce(log: Any, change: Change) -> None:
|
|
424
|
+
"""Say a change happened, and say separately what is wrong with its author.
|
|
425
|
+
|
|
426
|
+
Three channels rather than three fields on one, so an operator can watch
|
|
427
|
+
the thing they care about: that every change is attributed, or that no
|
|
428
|
+
identity ever disagreed with itself.
|
|
429
|
+
"""
|
|
430
|
+
log.say(CHANGED.name, change)
|
|
431
|
+
if not change.actor.named:
|
|
432
|
+
log.say(UNATTRIBUTED.name, change)
|
|
433
|
+
if change.actor.conflicts():
|
|
434
|
+
log.say(IMPERSONATION.name, change)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
__all__ = ['Actor', 'ActorRecord', 'Proof', 'ProofRecord', 'asserted', 'peercred', 'signature',
|
|
438
|
+
'RUNGS', 'RUNGS_AVAILABLE', 'peercred_of', 'ASSERTED', 'PEERCRED', 'SIGNED', 'DELEGATED',
|
|
439
|
+
'OK', 'FAILED', 'ABSENT', 'IMPERSONATION',
|
|
440
|
+
'Change', 'ChangeRecord', 'diff', 'announce', 'new_session',
|
|
441
|
+
'CHANGED', 'UNATTRIBUTED', 'CHANNELS', 'UNKNOWN_OPERATOR', 'LOCAL_TOOL']
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The audit trail: every change, durable, in order.
|
|
2
|
+
|
|
3
|
+
`announce` says a change happened on a channel, and a channel reaches whatever
|
|
4
|
+
backends an operator configured. That is the right way to tell somebody, and
|
|
5
|
+
the wrong way to keep a record: if no file backend is configured there is no
|
|
6
|
+
trail at all, and an audit trail that depends on how the log was set up is not
|
|
7
|
+
an audit trail.
|
|
8
|
+
|
|
9
|
+
So a change is both announced and stored. The store is the record; the channel
|
|
10
|
+
is the notification.
|
|
11
|
+
|
|
12
|
+
`seq` is explicit and taken from `MAX(seq) + 1` under the store lock, for the
|
|
13
|
+
reason `grants.history` does the same: auto-increment is spelled differently
|
|
14
|
+
by the two backends and SQLite cannot add one with ALTER TABLE, while `rowid`
|
|
15
|
+
does not exist on PostgreSQL at all. Order matters here more than it does for
|
|
16
|
+
grants - "who changed it last" is the question an audit trail is for - so it
|
|
17
|
+
is a column and not an accident of storage.
|
|
18
|
+
|
|
19
|
+
`DURABILITY = 'commit'`: a change must be on disk before the call returns. The
|
|
20
|
+
window class would let a batch sit in memory, and the one record you cannot
|
|
21
|
+
afford to lose to a crash is the one that says what somebody just did.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any, Optional, Sequence, TypedDict
|
|
27
|
+
|
|
28
|
+
from ..compartment.schema import Column, Index, Table
|
|
29
|
+
from ..storage import SqliteStore
|
|
30
|
+
from .change import Change
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChangeRow(TypedDict):
|
|
34
|
+
"""One stored change, as an operator reads it back.
|
|
35
|
+
|
|
36
|
+
A named record for the reason check-dicts.py enforces it: an audit row is
|
|
37
|
+
read by field name, and this is the row.
|
|
38
|
+
"""
|
|
39
|
+
seq: int
|
|
40
|
+
at: int
|
|
41
|
+
resource: str
|
|
42
|
+
was: Any
|
|
43
|
+
now: Any
|
|
44
|
+
source: str
|
|
45
|
+
operator: str
|
|
46
|
+
session: str
|
|
47
|
+
tool: str
|
|
48
|
+
assurance: str
|
|
49
|
+
verified: bool
|
|
50
|
+
conflicts: list[str]
|
|
51
|
+
proofs: list[Any]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TrailState(TypedDict):
|
|
55
|
+
"""How much trail there is, and how much of it nobody owns."""
|
|
56
|
+
changes: int
|
|
57
|
+
last_seq: int
|
|
58
|
+
unattributed: int
|
|
59
|
+
unverified: int
|
|
60
|
+
conflicted: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ChangeStore(SqliteStore):
|
|
64
|
+
"""Config changes, newest last by `seq`."""
|
|
65
|
+
|
|
66
|
+
DURABILITY = 'commit'
|
|
67
|
+
|
|
68
|
+
SCHEMA = (
|
|
69
|
+
Table('changes', (
|
|
70
|
+
Column('seq', 'INTEGER', primary_key=True),
|
|
71
|
+
Column('at', 'INTEGER', not_null=True),
|
|
72
|
+
Column('resource', 'TEXT', not_null=True),
|
|
73
|
+
Column('was', 'TEXT'),
|
|
74
|
+
Column('now', 'TEXT'),
|
|
75
|
+
Column('source', 'TEXT', not_null=True),
|
|
76
|
+
Column('operator', 'TEXT', not_null=True),
|
|
77
|
+
Column('session', 'TEXT', not_null=True),
|
|
78
|
+
Column('tool', 'TEXT', not_null=True),
|
|
79
|
+
Column('assurance', 'TEXT', not_null=True),
|
|
80
|
+
Column('verified', 'INTEGER', not_null=True),
|
|
81
|
+
Column('conflicts', 'TEXT', not_null=True),
|
|
82
|
+
Column('proofs', 'TEXT', not_null=True),
|
|
83
|
+
), indices=(
|
|
84
|
+
# The three questions an operator asks of an audit trail: what changed
|
|
85
|
+
# on this setting, what did this person change, and what did that one
|
|
86
|
+
# run do.
|
|
87
|
+
Index('changes_resource', ('resource', 'seq')),
|
|
88
|
+
Index('changes_operator', ('operator', 'seq')),
|
|
89
|
+
Index('changes_session', ('session', 'seq')),
|
|
90
|
+
)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _next_seq(self) -> int:
|
|
94
|
+
"""The next record number. Every caller holds the store lock, so the
|
|
95
|
+
read-then-write cannot race."""
|
|
96
|
+
row = self._one('SELECT COALESCE(MAX(seq), 0) + 1 FROM changes')
|
|
97
|
+
return int(row[0]) if row and row[0] is not None else 1
|
|
98
|
+
|
|
99
|
+
def put(self, change: Change) -> int:
|
|
100
|
+
"""Store one change and return its seq.
|
|
101
|
+
|
|
102
|
+
The values go through `Change.describe()`, so a secret is already
|
|
103
|
+
redacted by the time it reaches a row: an audit trail that records the
|
|
104
|
+
wallet URI it just rotated has published it to everyone who can read
|
|
105
|
+
the database.
|
|
106
|
+
"""
|
|
107
|
+
rec = change.describe()
|
|
108
|
+
act = change.actor
|
|
109
|
+
with self.transaction() as cur:
|
|
110
|
+
seq = self._next_seq()
|
|
111
|
+
cur.execute(
|
|
112
|
+
'INSERT INTO changes (seq, at, resource, was, now, source, operator, session, '
|
|
113
|
+
'tool, assurance, verified, conflicts, proofs) '
|
|
114
|
+
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
115
|
+
(seq, int(act.at), rec['resource'],
|
|
116
|
+
json.dumps(rec['was']), json.dumps(rec['now']), rec['source'],
|
|
117
|
+
act.operator, act.session, act.tool,
|
|
118
|
+
act.assurance or '', 1 if act.verified else 0,
|
|
119
|
+
json.dumps(act.conflicts()),
|
|
120
|
+
json.dumps([p.describe() for p in act.proofs])))
|
|
121
|
+
return seq
|
|
122
|
+
|
|
123
|
+
def _rows(self, where: str, args: Sequence[Any], limit: int) -> list[ChangeRow]:
|
|
124
|
+
rows = self._all(
|
|
125
|
+
'SELECT seq, at, resource, was, now, source, operator, session, tool, '
|
|
126
|
+
'assurance, verified, conflicts, proofs FROM changes '
|
|
127
|
+
+ where + ' ORDER BY seq DESC LIMIT ?', tuple(args) + (int(limit),))
|
|
128
|
+
out: list[ChangeRow] = []
|
|
129
|
+
for r in rows:
|
|
130
|
+
row: ChangeRow = {
|
|
131
|
+
'seq': int(r[0]), 'at': int(r[1]), 'resource': r[2],
|
|
132
|
+
'was': json.loads(r[3]) if r[3] is not None else None,
|
|
133
|
+
'now': json.loads(r[4]) if r[4] is not None else None,
|
|
134
|
+
'source': r[5], 'operator': r[6] or '(unknown)', 'session': r[7],
|
|
135
|
+
'tool': r[8], 'assurance': r[9] or '(none)', 'verified': bool(r[10]),
|
|
136
|
+
'conflicts': json.loads(r[11] or '[]'),
|
|
137
|
+
'proofs': json.loads(r[12] or '[]'),
|
|
138
|
+
}
|
|
139
|
+
out.append(row)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
def recent(self, limit: int = 50) -> list[ChangeRow]:
|
|
143
|
+
"""Newest first, which is the order the question is usually asked in."""
|
|
144
|
+
return self._rows('', (), limit)
|
|
145
|
+
|
|
146
|
+
def of_resource(self, resource: str, limit: int = 50) -> list[ChangeRow]:
|
|
147
|
+
"""What changed on one setting. 'who last touched this' lives here."""
|
|
148
|
+
return self._rows('WHERE resource = ?', (resource,), limit)
|
|
149
|
+
|
|
150
|
+
def of_operator(self, operator: str, limit: int = 50) -> list[ChangeRow]:
|
|
151
|
+
return self._rows('WHERE operator = ?', (operator,), limit)
|
|
152
|
+
|
|
153
|
+
def of_session(self, session: str, limit: int = 200) -> list[ChangeRow]:
|
|
154
|
+
"""One run of one tool. A dozen changes from one `meshctl apply` are
|
|
155
|
+
one act, and this is what makes them readable as one."""
|
|
156
|
+
return self._rows('WHERE session = ?', (session,), limit)
|
|
157
|
+
|
|
158
|
+
def unattributed(self, limit: int = 50) -> list[ChangeRow]:
|
|
159
|
+
"""Changes with nobody named. The thing to watch on a node several
|
|
160
|
+
people manage."""
|
|
161
|
+
return self._rows("WHERE operator = ''", (), limit)
|
|
162
|
+
|
|
163
|
+
def unverified(self, limit: int = 50) -> list[ChangeRow]:
|
|
164
|
+
"""Changes whose author was claimed but never proved. Separate from
|
|
165
|
+
unattributed, because 'said they were X' and 'said nothing' are
|
|
166
|
+
different and only one of them is a policy violation worth chasing."""
|
|
167
|
+
return self._rows('WHERE verified = 0', (), limit)
|
|
168
|
+
|
|
169
|
+
def conflicted(self, limit: int = 50) -> list[ChangeRow]:
|
|
170
|
+
"""Changes where two rungs named different subjects."""
|
|
171
|
+
return self._rows("WHERE conflicts <> '[]'", (), limit)
|
|
172
|
+
|
|
173
|
+
def state(self) -> TrailState:
|
|
174
|
+
"""For the health block: how much trail there is and how much of it
|
|
175
|
+
nobody owns."""
|
|
176
|
+
row = self._one('SELECT COUNT(*), COALESCE(MAX(seq), 0) FROM changes')
|
|
177
|
+
total, last = (int(row[0]), int(row[1])) if row else (0, 0)
|
|
178
|
+
un = self._one("SELECT COUNT(*) FROM changes WHERE operator = ''")
|
|
179
|
+
nv = self._one('SELECT COUNT(*) FROM changes WHERE verified = 0')
|
|
180
|
+
cf = self._one("SELECT COUNT(*) FROM changes WHERE conflicts <> '[]'")
|
|
181
|
+
out: TrailState = {'changes': total, 'last_seq': last,
|
|
182
|
+
'unattributed': int(un[0]) if un else 0,
|
|
183
|
+
'unverified': int(nv[0]) if nv else 0,
|
|
184
|
+
'conflicted': int(cf[0]) if cf else 0}
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def record(store: Optional[ChangeStore], log: Any, change: Change) -> Optional[int]:
|
|
189
|
+
"""Store the change, then announce it.
|
|
190
|
+
|
|
191
|
+
Stored first on purpose. The announcement is a notification and may go
|
|
192
|
+
nowhere an operator kept; the row is the record, and a crash between the
|
|
193
|
+
two should lose the telling rather than the evidence.
|
|
194
|
+
"""
|
|
195
|
+
from . import change as change_mod # noqa: PLC0415 (avoids a cycle)
|
|
196
|
+
seq = store.put(change) if store is not None else None
|
|
197
|
+
change_mod.announce(log, change)
|
|
198
|
+
return seq
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ['ChangeStore', 'ChangeRow', 'TrailState', 'record']
|