From 5ef1c063583fb8d059fe8184dd7ad468e2824427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Z=20Sink=C3=B3?= Date: Sun, 8 Nov 2026 14:57:15 +0100 Subject: [PATCH] docs: what destroy and invoke do today, and a binding contract proposal The plan binding of execute (M4a) holds only for execute. destroy and invoke reach the same host with the same key and are not covered by it. This records, in three kept-apart parts, a measurement of both entry points, the properties those observations imply, and a contract proposal; and it ships the scratch probes and their output with the exact environment so the tables can be checked. Measured on a clean git archive of devel eda4aeb with the module's mock host seam (no provider): every target dimension (resource_id, host, base_path, key_id, revision) changes what is signed and sent, for both entry points; invoke runs any registry operation whatever its role (delete, update, create, read, action) with one sign and one actuate; invoke sends the caller's body text as it is (duplicate keys, extra fields, any JSON value kind), where execute's renderBody filters by role; and a repeated request signs and sends again every time (destroy 3x gives 3 sign and 3 actuate, invoke 5x gives 5 and 5) whatever the provider answers. Proposed (concept): a per-entry-point execution_digest with its own domain tag, invoke limited to action operations with an object body of the declared fields, and the body that is sent defined as a function of the digested value. What the module can prove, and what stays with the Relay, is stated, as are the open decisions and the questions handed to D5. Nothing is implemented and no approval is verified by anything described. Documentation and measurement files only. --- [signing-metadata] key = cic-my-sign-key signature = vault:v1:MEQCIDzLD5giLi2U8hobA5Ul2+Tw7yZSwbqzqqGyoc5vfBC+AiAeADiSN3prfgXXjQh+/0JL53qsTP18H1loo//LwKqxsQ== hash-algorithm = sha256 digest = fXmh0f8oo29uFEPZlLXpq6iEd2n42q8dU1zzkBJ8exg= [certificate] -----BEGIN CERTIFICATE----- MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0 lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW -----END CERTIFICATE----- --- MANIFEST.sha256 | 7 +- docs/design/README.md | 4 + .../measurements/destroy-invoke/README.md | 55 +++++ .../measurements/destroy-invoke/output.txt | 41 ++++ .../destroy-invoke/probe_test.go.txt | 154 +++++++++++++ .../destroy-invoke/raw_probe_test.go.txt | 50 ++++ docs/design/specs/destroy-invoke-binding.md | 218 ++++++++++++++++++ 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 docs/design/measurements/destroy-invoke/README.md create mode 100644 docs/design/measurements/destroy-invoke/output.txt create mode 100644 docs/design/measurements/destroy-invoke/probe_test.go.txt create mode 100644 docs/design/measurements/destroy-invoke/raw_probe_test.go.txt create mode 100644 docs/design/specs/destroy-invoke-binding.md diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index a1388da..c6a728e 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -78,11 +78,13 @@ 5432506f15d65fa59572362a70db4bc403c13b163ec961ecb2951d7f98d093d0 module/go.mod 546e7c533da4b86b8817ee426865df0d11e24d2aa1580647634345f452a5017e module/canonical_json_test.go 57980b8a84a13ffad62b7295e3095bd635f8894716665d0654dfb54eebbf10e5 docs/contracts/en/host-expectations.md +5b7f1f228e76f1240526022991728bbe1834c4c06b19ed5f52a15441e54dd6ee docs/design/README.md 5c650f84f688d4d965721df74779228bb6fdfa47d5c08c46e8274e6d97b212c8 module/testdata/vcn-v1/lifecycle-map.json 5ca6da66b9de8a04ca71f1c18ba2026ad02b8c1c70c700a432058da267e6b27d module/correspondence/vcn.json 5d51889fc8cd00c4c0bf1462b01be8c4d6754e2a4e59dd8e7f0599c610c64472 tools/vault-sign-agent.sh 5d8afd2b9d29065c768de8a21562049904b7c4ad3449448c3159af34e3a0b9df docs/en/concept/git-management.meta.yaml 5daa3bf50db9db492aac5721bddc0837a6a30dec9558d886abf45ad89632f68e module/manual_real_oci_test.go +60f8027dfd091bdaddd7537c65780676cabd460e85b6c7657e8eed294c8b1de7 docs/design/measurements/destroy-invoke/output.txt 635384b1db18a7654d1e30b660ba825645d88a89e7bc0e808500a213b584f278 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.registry.json 66181e4c6a0b2735b5a8b3088b37e6a844e2c692399a5b72b036059da0188e0a tools/releaselib/__init__.py 688dffd2264877f3a64fca1d3526c57bb34cb1c18f5a01aa906667311831a052 docs/en/workflow.md @@ -94,11 +96,11 @@ 6db64092fb2c2dc8bcbd3e321faf04f6cc40b16e890c8dec3e56e4178df01402 LICENSE.yaml 6e47d263c8203e2fdd44672be70aeb71456e767e0d2ea6840831f6070eb8214f tools/oci-extract/testdata/polymorphic.go 6f3655bf787efee284225bcb6096913d0ec556771dddb42845311e649c21e7ad docs/hu/concept/declarative_ecosystem_integration.md +7038a486c0c4b17922d3c35885486cf27321baec8ba9262c04b7db36333aba45 docs/design/measurements/destroy-invoke/probe_test.go.txt 7068014ecee4111cb4456d3d3328c33b53a79c7803d68f040036f34449423f65 tools/oci-extract/testdata/instance.go 7080b3423beded8b64891afdcf2e6f6d8bec6ebeaa88aecbb1b06e149b54ef86 module/module.wasm 7136d8366bbd7c5ccc19a3d810a7f55c31541acec9b0f05bd84e45a39107ded7 tests/test_capability_manifest.py 7426752feb1140b2023192728df35248e4f802617c150bb0523ab97dc1638c0b tools/init-hooks.sh -7499755898c9fe815f8cb5b62314630258bc68fe5d461097ff7f10644508bed3 docs/design/README.md 74d93981f1935d21001ed0475ebb84132ab67c71840ca82ca0cbfac5aca29ea2 docs/contracts/en/wasm-abi.md 763f2814427dc8faaa7fb1ccf3e8afe2f6460273a2dfe852287b3ef66bc43b96 tools/oci-extract/policy_test.go 768d54fe8ec5fde38c41ec075ce02b51d93cf3bf5f47e8d81e33932f3e3ece27 module/ocid_boundary_test.go @@ -107,9 +109,11 @@ 79273b206c6143ede2e8a28f7c3d667d93c5978c764b3db4f543c7f4a477253b module/testdata/vcn-v1/cases/transport-error/expected/evidence.claims.json 794a6e83b11358f4f2105408d7798a3495fa992d09d469823d0148233e6df57e module/relay_integration_test.go 79f7c4ad204e39c79b7b55a0ee925a603250bb49610366a98e3210e1b925abd3 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.oci.json +7b2748a353c7aad1822022fbbb4a6271c54ef84a0fe5f3535f43850306857756 docs/design/measurements/destroy-invoke/README.md 7b84214b85a3fd95e9153528df1fa1522ff026df1f561752d95c715250301ca6 tools/vault-sign-agent.md 7df714ce12b9abfb18357c4fc25e67b8f23e6557b72ee1ea4c2a4a5d4a596c43 docs/contracts/hu/host-expectations.md 7e18dd94212c07eb66f6db357067b71e870dc64d43336f9c1db3c08b30adf0f4 module/testdata/vcn-v1/cases/not-found/expected/observed.registry.json +7eee1cc0f4fa15a4519d9f233388a647da0caddbb0521de3493843e0cbc31d25 docs/design/specs/destroy-invoke-binding.md 7efd45c44d808639d5fe607cf4182e930be6b8d82709e716114e63512f69b55f module/testdata/vcn-v1/cases/conformant/expected/verdict.fields.json 805c05942247d6327f0f006a8799119ace530c4ae288ad637d0f8faddca21d86 docs/hu/architecture.md 806851c10862d6900eb858a229e3ba7138943abe3fd75e5b1e8f8ca4b57d1d6f mk/golang.mk @@ -153,6 +157,7 @@ a85fe8169218b1abe90093cc5d7f8bccc0cb1eb98a4670881b31d37834e54893 tools/oci-extr a8ecf9c2551505fd5a4c477c735c88fff9c4627136dade955cdcbfc16d8ca131 docs/en/makefile-cheatsheet.md aedb718131b5633d08f58ac67b23d3940b4fbffd98e93c9f75db3cf80903f1f8 module/schemas/core/instance.json b1609aa1ad37f8f682e3025d866eaebe9719802b34aac580387d3bce65935f30 module/observed.go +b28519ff1d7882a9ba98b06b57ba5b7ecfb4ff888d180db5f5f4bae0f4b56fb2 docs/design/measurements/destroy-invoke/raw_probe_test.go.txt b2c3bf78eb4368f14eb5d380cd7aee88bf33c39f88613fa1fae00f551f7e1d83 module/testdata/vcn-v1/plan.json b304ac8143d669cbb505d50ffbeb11deac010d54cfab4fa2f0d6ac2355382e7f module/cicflow_host.go b3585cfa2c622b28548424d092f4ee05a679d7ced46a762f7c1f5bc4931e5c27 tools/oci-extract/testdata/bucket.go diff --git a/docs/design/README.md b/docs/design/README.md index f1eb056..6b15859 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -31,6 +31,10 @@ namespace it opens. Written 2026-07-18 from the design session recorded in computed over. - [specs/plan-digest.md](specs/plan-digest.md) — `plan_digest`, `target_digest`, `execution_digest` and the gate `execute` applies before it signs anything. +- [specs/destroy-invoke-binding.md](specs/destroy-invoke-binding.md) — a measurement of what + `destroy` and `invoke` do today, the properties it implies, and a binding contract proposal + (concept); the probes and their output are in + [measurements/destroy-invoke/](measurements/destroy-invoke/README.md). - [specs/approval-and-execution-state.md](specs/approval-and-execution-state.md) — M4b (concept): what an approval names, the execution state of one approval (atomic reservation, UNKNOWN), the ProofTrace evidence, and the acceptance tests the Relay side will be held to. diff --git a/docs/design/measurements/destroy-invoke/README.md b/docs/design/measurements/destroy-invoke/README.md new file mode 100644 index 0000000..6aedefc --- /dev/null +++ b/docs/design/measurements/destroy-invoke/README.md @@ -0,0 +1,55 @@ +# Destroy and Invoke: measurement of the current behaviour + +What this is: the scratch probes and their output behind the tables in +[`../../specs/destroy-invoke-binding.md`](../../specs/destroy-invoke-binding.md), section 1. They +observe what `Destroy` and `Invoke` are given and what they sign and send. They are kept here +unchanged so that the tables can be checked, not taken on trust. + +## Environment of the recorded run + +| | | +|---|---| +| Module commit measured | `eda4aebbe65bdfcf8e1d5a1a37db9df3d943013a` (`devel`), taken with `git archive` into an empty directory, so no working-tree residue | +| Production code vs the earlier run | the same run was first made on `f48246e`; `module/provider.go`, `canonical_json.go`, `plan_binding.go` and `abi.go` are identical in `eda4aeb` (empty `git diff`), and the output of the two runs is identical apart from timings | +| Go | go1.25.0 linux/amd64 (the repo's builder image) | +| TinyGo | 0.39.0 (not used by the probes) | +| Builder image | `cic-module-oracle-cloud-builder:latest`, id `sha256:b5a3b64b391e6d4a1254b41dfa59f78025a603cfccdaacf902c408967047debf` | +| Host | Linux 7.0.0-43-generic | +| Provider | none. The host seam is the module's own test mock (`cicFlowMock` in `module/cicflow_mock_test.go`): `sign` returns a fixed signature, `actuate` answers with a configured status and body. No OCI endpoint, credential or network is involved | + +"sign" and "actuate" in the tables are the numbers of calls that reached that mock seam. + +## Files + +- `probe_test.go.txt` - `TestZZDestroy`, `TestZZInvoke`, `TestZZRenderBodyVsInvoke`: the + dimension changes, the operation roles, the replay counts and the `renderBody` comparison. +- `raw_probe_test.go.txt` - `TestZZInvokeRawBodies`: the body fidelity of `Invoke`. The request + TEXT is written out byte by byte (a backslash is `"\x5c"`) and handed to `Invoke` as it is, because + a request built with `json.Marshal` rewrites the body text (it compacts whitespace and escapes + `<`, `>`, `&`) and an editor or tool can rewrite an escape sequence typed into a source file; + either would make the body measurement meaningless. +- `output.txt` - the output of the recorded run, verbatim (`DI` lines from the first file, + `RAW` lines from the second). + +The files end in `.txt` so that they are not compiled into the module. + +## Rerun + +From a clean checkout of the commit above, in a scratch copy (not in the repository): + +``` +cp probe_test.go.txt /module/zz_di_probe_test.go +cp raw_probe_test.go.txt /module/zz_di_raw_test.go +cd /module +go test -count=1 -run 'TestZZ' -v . +``` + +The two files belong together (the second uses `ok200` from the first). They use helpers of the +module's test package (`cicFlowMock`, `decodeResult`, `execBinding`, `destroyRequest`, +`invokeRequest`, `renderBody`), which exist at the commit above. + +## Limits of this measurement + +It measures the module against a mock host seam. It does not measure what the Relay does with the +calls that reach it, what OCI answers, or whether OCI accepts any request body; the real +provider was not involved. diff --git a/docs/design/measurements/destroy-invoke/output.txt b/docs/design/measurements/destroy-invoke/output.txt new file mode 100644 index 0000000..938466c --- /dev/null +++ b/docs/design/measurements/destroy-invoke/output.txt @@ -0,0 +1,41 @@ +DI destroy: baseline status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="" key=t/u/f if-match=etag-1 +DI destroy: only binding.resource_id differs status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..OTHER body="" key=t/u/f if-match=etag-1 +DI destroy: only binding.host differs status=ok sign=1 actuate=1 | DELETE iaas.us-ashburn-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="" key=t/u/f if-match=etag-1 +DI destroy: only binding.base_path differs status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20990101/vcns/ocid1.vcn.oc1..approved body="" key=t/u/f if-match=etag-1 +DI destroy: only binding.key_id differs status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="" key=other/tenancy/key if-match=etag-1 +DI destroy: only binding.revision differs status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="" key=t/u/f if-match=etag-STALE +DI destroy: instance kind (TerminateInstance) status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/instances/ocid1.instance.oc1..i body="" key=t/u/f if-match= +DI destroy: the same request 3x, provider 204 then 404 -> results=[ok error/not-found error/not-found] sign=3 actuate=3 +--- PASS: TestZZDestroy (0.00s) +DI invoke: baseline ChangeVcnCompartment status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: only binding.resource_id differs status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..OTHER/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: only binding.host differs status=ok sign=1 actuate=1 | POST iaas.us-ashburn-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: only binding.base_path differs status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20990101/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: only binding.key_id differs status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=other/tenancy/key if-match=etag-1 +DI invoke: only binding.revision differs status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-STALE +DI invoke: operation=DeleteVcn (registry role delete) status=ok sign=1 actuate=1 | DELETE iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: operation=UpdateVcn (registry role update) status=ok sign=1 actuate=1 | PUT iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: operation=CreateVcn (registry role create) status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: operation=GetVcn (registry role read) status=ok sign=1 actuate=1 | GET iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: operation=AddVcnCidr (registry role action) status=ok sign=1 actuate=1 | POST iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved/actions/addCidr body="{\"compartmentId\":\"ocid1.compartment..c\"}" key=t/u/f if-match=etag-1 +DI invoke: unknown field names like an Execute plan would filter status=ok sign=1 actuate=1 | PUT iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved body="{\"displayName\":\"x\",\"lifecycleState\":\"TERMINATED\",\"id\":\"ot..." key=t/u/f if-match=etag-1 +DI invoke: the same request 5x -> sign=5 actuate=5 +--- PASS: TestZZInvoke (0.00s) +DI renderBody(ChangeVcnCompartment, role action) = "{\"compartmentId\":\"ocid1.compartment..c\"}" +DI renderBody(UpdateVcn, role update) = "{\"displayName\":\"X\"}" +DI renderBody(CreateVcn, role create) = "{\"compartmentId\":\"ocid1.compartment..c\",\"displayName\":\"X\"}" +DI renderBody(DeleteVcn, role delete) = "" +DI renderBody(GetVcn, role read) = "{}" +--- PASS: TestZZRenderBodyVsInvoke (0.00s) +RAW whitespace and key order are kept status=ok sign=1 actuate=1 sent="{ \"z\" : 1 , \"compartmentId\" : \"ocid1.compartment..c\" }" identical_to_input=false +RAW a duplicate key is kept status=ok sign=1 actuate=1 sent="{\"compartmentId\":\"ocid1.compartment..a\",\"compartmentId\":\"ocid1.compartment..b\"}" identical_to_input=true +RAW a u-escape is kept as written status=ok sign=1 actuate=1 sent="{\"s\":\"\\u00e9\\u003c\"}" identical_to_input=true +RAW 2^53+1 and 1.0 are kept as written status=ok sign=1 actuate=1 sent="{\"n\":9007199254740993,\"m\":1.0}" identical_to_input=true +RAW data is JSON null status=ok sign=1 actuate=1 sent="null" identical_to_input=true +RAW data is an array status=ok sign=1 actuate=1 sent="[1,2,3]" identical_to_input=true +RAW data is a string status=ok sign=1 actuate=1 sent="\"just a string\"" identical_to_input=true +RAW extra fields besides the action's status=ok sign=1 actuate=1 sent="{\"compartmentId\":\"ocid1.compartment..c\",\"displayName\":\"EVIL\",\"lifecycleState\":\"TERMINATED\"}" identical_to_input=true +RAW config absent entirely status=ok sign=1 actuate=1 sent="{}" identical_to_input=false +RAW data is not JSON (the whole request is not JSON) status=error sign=0 actuate=0 +--- PASS: TestZZInvokeRawBodies (0.00s) +ok github.com/CentralInfraCore/wasm-module-template/module 0.005s diff --git a/docs/design/measurements/destroy-invoke/probe_test.go.txt b/docs/design/measurements/destroy-invoke/probe_test.go.txt new file mode 100644 index 0000000..d872cb5 --- /dev/null +++ b/docs/design/measurements/destroy-invoke/probe_test.go.txt @@ -0,0 +1,154 @@ +//go:build !wasip1 + +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "testing" +) + +var ( + reKey = regexp.MustCompile(`keyId=\\?"([^"\\]*)`) + reMatch = regexp.MustCompile(`if-match\\?":\\?"([^"\\]*)`) +) + +// run executes fn against a fresh mock and prints one line: what the module was given +// and what it signed and sent. +func probe(t *testing.T, label string, reply func(int, cicFlowCall) cicFlowReply, fn func() ([]byte, error)) { + t.Helper() + mock := &cicFlowMock{Reply: reply} + restore := mock.wireHostSeam() + defer restore() + out, _ := fn() + res := decodeResult(t, out) + line := fmt.Sprintf("DI %-62s status=%-5s sign=%d actuate=%d", label, res.Status, mock.signCount(), mock.actuateCount()) + if res.Error != nil { + line += " err=" + res.Error.FieldPath + } + for i, c := range mock.Actuated { + if i >= 2 { + line += " ..." + break + } + k := "" + if m := reKey.FindStringSubmatch(c.Raw); len(m) > 1 { + k = m[1] + } + im := "" + if m := reMatch.FindStringSubmatch(c.Raw); len(m) > 1 { + im = m[1] + } + body := string(c.Body) + if len(body) > 60 { + body = body[:57] + "..." + } + line += fmt.Sprintf(" | %s %s body=%q key=%s if-match=%s", c.Method, strings.TrimPrefix(c.URL, "https://"), body, k, im) + } + fmt.Println(line) +} + +func ok200(int, cicFlowCall) cicFlowReply { return cicFlowReply{Status: 200, Body: `{}`} } + +func TestZZDestroy(t *testing.T) { + b := execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", BasePath: "/20160918", KeyID: "t/u/f", ResourceID: "ocid1.vcn.oc1..approved", Revision: "etag-1"} + call := func(bb execBinding, kind string) func() ([]byte, error) { + return func() ([]byte, error) { + raw, _ := json.Marshal(destroyRequest{Kind: kind, Binding: bb}) + return Destroy(nil, raw) + } + } + probe(t, "destroy: baseline", ok200, call(b, "cic:network:vcn")) + for name, f := range map[string]func(*execBinding){ + "resource_id": func(x *execBinding) { x.ResourceID = "ocid1.vcn.oc1..OTHER" }, + "host": func(x *execBinding) { x.Host = "iaas.us-ashburn-1.oraclecloud.com" }, + "base_path": func(x *execBinding) { x.BasePath = "/20990101" }, + "key_id": func(x *execBinding) { x.KeyID = "other/tenancy/key" }, + "revision": func(x *execBinding) { x.Revision = "etag-STALE" }, + } { + bb := b + f(&bb) + probe(t, "destroy: only binding."+name+" differs", ok200, call(bb, "cic:network:vcn")) + } + probe(t, "destroy: instance kind (TerminateInstance)", ok200, call(execBinding{Host: b.Host, BasePath: b.BasePath, KeyID: b.KeyID, ResourceID: "ocid1.instance.oc1..i"}, "cic:compute:instance")) + // replay: three calls on ONE mock, the provider answers 404 after the first + mock := &cicFlowMock{Reply: func(n int, c cicFlowCall) cicFlowReply { + if n <= 1 { + return cicFlowReply{Status: 204} + } + return cicFlowReply{Status: 404, Body: `{"code":"NotAuthorizedOrNotFound","message":"gone"}`} + }} + restore := mock.wireHostSeam() + var sts []string + for i := 0; i < 3; i++ { + raw, _ := json.Marshal(destroyRequest{Kind: "cic:network:vcn", Binding: b}) + out, _ := Destroy(nil, raw) + r := decodeResult(t, out) + s := r.Status + if r.Error != nil { + s += "/" + r.Error.Class + } + sts = append(sts, s) + } + restore() + fmt.Printf("DI destroy: the same request 3x, provider 204 then 404 -> results=%v sign=%d actuate=%d\n", sts, mock.signCount(), mock.actuateCount()) +} + +func TestZZInvoke(t *testing.T) { + b := execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", BasePath: "/20160918", KeyID: "t/u/f", ResourceID: "ocid1.vcn.oc1..approved", Revision: "etag-1"} + inv := func(bb execBinding, op, cfg string) func() ([]byte, error) { + return func() ([]byte, error) { + req := invokeRequest{Kind: "cic:network:vcn", Operation: op, Binding: bb} + if cfg != "" { + req.Config = schemaPayload{Data: json.RawMessage(cfg)} + } + raw, _ := json.Marshal(req) + return Invoke(nil, raw) + } + } + act := `{"compartmentId":"ocid1.compartment..c"}` + probe(t, "invoke: baseline ChangeVcnCompartment", ok200, inv(b, "ChangeVcnCompartment", act)) + for name, f := range map[string]func(*execBinding){ + "resource_id": func(x *execBinding) { x.ResourceID = "ocid1.vcn.oc1..OTHER" }, + "host": func(x *execBinding) { x.Host = "iaas.us-ashburn-1.oraclecloud.com" }, + "base_path": func(x *execBinding) { x.BasePath = "/20990101" }, + "key_id": func(x *execBinding) { x.KeyID = "other/tenancy/key" }, + "revision": func(x *execBinding) { x.Revision = "etag-STALE" }, + } { + bb := b + f(&bb) + probe(t, "invoke: only binding."+name+" differs", ok200, inv(bb, "ChangeVcnCompartment", act)) + } + // the operation: any registry operation, whatever its role + for _, op := range []string{"DeleteVcn", "UpdateVcn", "CreateVcn", "GetVcn", "AddVcnCidr"} { + probe(t, "invoke: operation="+op+" (registry role "+resourceContracts()["cic:network:vcn"].operations[op].role+")", ok200, inv(b, op, act)) + } + // body fidelity is measured on raw request text in zz_di_raw_test.go (a request built with + // json.Marshal would rewrite the body text and make that measure meaningless) + probe(t, "invoke: unknown field names like an Execute plan would filter", ok200, inv(b, "UpdateVcn", `{"displayName":"x","lifecycleState":"TERMINATED","id":"other"}`)) + // replay + mock := &cicFlowMock{Reply: ok200} + restore := mock.wireHostSeam() + for i := 0; i < 5; i++ { + raw, _ := json.Marshal(invokeRequest{Kind: "cic:network:vcn", Operation: "ChangeVcnCompartment", Config: schemaPayload{Data: json.RawMessage(act)}, Binding: b}) + Invoke(nil, raw) + } + restore() + fmt.Printf("DI invoke: the same request 5x -> sign=%d actuate=%d\n", mock.signCount(), mock.actuateCount()) +} + +// What Execute's renderBody makes of the same fields, for comparison with Invoke's raw body. +func TestZZRenderBodyVsInvoke(t *testing.T) { + c := resourceContracts()["cic:network:vcn"] + cfg := map[string]json.RawMessage{ + "compartmentId": json.RawMessage(`"ocid1.compartment..c"`), + "displayName": json.RawMessage(`"X"`), + "lifecycleState": json.RawMessage(`"TERMINATED"`), + "evil": json.RawMessage(`1`), + } + for _, op := range []string{"ChangeVcnCompartment", "UpdateVcn", "CreateVcn", "DeleteVcn", "GetVcn"} { + fmt.Printf("DI renderBody(%s, role %s) = %q\n", op, c.operations[op].role, string(renderBody(c, providerOperation{Operation: op}, cfg))) + } +} diff --git a/docs/design/measurements/destroy-invoke/raw_probe_test.go.txt b/docs/design/measurements/destroy-invoke/raw_probe_test.go.txt new file mode 100644 index 0000000..35ac2d1 --- /dev/null +++ b/docs/design/measurements/destroy-invoke/raw_probe_test.go.txt @@ -0,0 +1,50 @@ +//go:build !wasip1 + +package main + +import ( + "fmt" + "testing" +) + +// The request TEXT is written byte by byte (a backslash is "\x5c") so that no editor or +// tool between this file and the module can rewrite an escape sequence on the way. +const bs = "\x5c" + +func rawInvoke(op, dataText string) []byte { + cfg := "" + if dataText != "" { + cfg = `"config":{"schema_id":"s","schema_version":"v","schema_hash":"abc123","encoding":"canonical-json","data":` + dataText + `},` + } + return []byte(`{"kind":"cic:network:vcn","operation":"` + op + `",` + cfg + + `"binding":{"host":"iaas.eu-frankfurt-1.oraclecloud.com","base_path":"/20160918","key_id":"t/u/f","resource_id":"ocid1.vcn.oc1..approved","revision":"etag-1"}}`) +} + +func TestZZInvokeRawBodies(t *testing.T) { + cases := []struct{ name, data string }{ + {"whitespace and key order are kept", ` { "z" : 1 , "compartmentId" : "ocid1.compartment..c" } `}, + {"a duplicate key is kept", `{"compartmentId":"ocid1.compartment..a","compartmentId":"ocid1.compartment..b"}`}, + {"a u-escape is kept as written", `{"s":"` + bs + `u00e9` + bs + `u003c"}`}, + {"2^53+1 and 1.0 are kept as written", `{"n":9007199254740993,"m":1.0}`}, + {"data is JSON null", `null`}, + {"data is an array", `[1,2,3]`}, + {"data is a string", `"just a string"`}, + {"extra fields besides the action's", `{"compartmentId":"ocid1.compartment..c","displayName":"EVIL","lifecycleState":"TERMINATED"}`}, + {"config absent entirely", ``}, + {"data is not JSON (the whole request is not JSON)", `{bad`}, + } + for _, c := range cases { + c := c + mock := &cicFlowMock{Reply: ok200} + restore := mock.wireHostSeam() + out, _ := Invoke(nil, rawInvoke("ChangeVcnCompartment", c.data)) + restore() + res := decodeResult(t, out) + line := fmt.Sprintf("RAW %-52s status=%-5s sign=%d actuate=%d", c.name, res.Status, mock.signCount(), mock.actuateCount()) + if mock.actuateCount() > 0 { + sent := string(mock.Actuated[0].Body) + line += fmt.Sprintf(" sent=%q identical_to_input=%v", sent, sent == c.data) + } + fmt.Println(line) + } +} diff --git a/docs/design/specs/destroy-invoke-binding.md b/docs/design/specs/destroy-invoke-binding.md new file mode 100644 index 0000000..e4b6209 --- /dev/null +++ b/docs/design/specs/destroy-invoke-binding.md @@ -0,0 +1,218 @@ +# Destroy and Invoke: what they do today, and a binding contract proposal + +Status: section 1 is a **measurement** of the module at `eda4aeb`. Sections 2 to 5 are a **proposal +(concept)**: nothing in them is implemented in the module or the Relay, and no approval is verified +anywhere by anything described here. + +The structure is deliberate. Section 1 records what was observed, without reference to any contract +we would like. Section 2 derives, from those observations only, the properties a contract needs. +Section 3 is the contract that has those properties. Section 4 says what the module can prove and +what stays with the Relay. Section 5 lists the decisions left open, and section 6 the questions that +follow from the survey and are handed to the D5 contract (the origin of the target data). + +Why it matters: the plan binding of `execute` (`plan-digest.md`) holds only for `execute`. +`destroy` and `invoke` reach the same host with the same key and are not covered by it. The Relay +requirements R7 and R10 (`../relay-requirements.md`) ask for an approval before any operation that +can change the provider's state; the module has to be able to give the Relay a value to approve for +these two entry points as well. + +## 1. Measured current behaviour + +### 1.1 Method + +Scratch probes in a copy of the module made with `git archive` of `eda4aeb`; the host seam is the +module's own test mock (`sign` returns a fixed signature, `actuate` answers as configured); no OCI +endpoint, credential or network. "sign" and "actuate" are the numbers of calls that reached the +mock. The probe sources, the full output, the exact environment and the way to rerun are in +[`../measurements/destroy-invoke/README.md`](../measurements/destroy-invoke/README.md). Sections +1.2 and 1.3 also rest on reading `module/provider.go` (`Destroy`, `Invoke`, `renderBody`, +`resolveOp`), marked "read". + +### 1.2 What the entry points do (read) + +- **`destroy`** resolves the kind's single delete-role operation (`resolveOp(kind, roleDelete, ...)`), + checks that `binding.resource_id` is a well-formed OCID, and sends a `DELETE` with no body and + `If-Match` = `binding.revision`. A 404 is reported as `not-found`. The request carries no plan, no + digest and no state; the result echoes no digest. +- **`invoke`** takes `kind`, `operation`, `config` and `binding`. It looks the operation up in the + kind's registry (`c.operations[req.Operation]`) and does not look at its role. The body is the + bytes of `config.data` as received (`{}` if there is no `config`). It checks the resource id like + `destroy`. The registry-conformance and plan-binding gates of `execute` do not apply to it. The + result echoes no digest. +- **`renderBody`** (used by `execute`) filters by role: a create keeps every contract field present; + an update keeps only `mutable` fields; an action keeps only the `action-managed` fields whose + `action` is the operation's name; a delete has no body; any other role (a read) gets `{}`. + +### 1.3 Observations + +**O1. Destroy: every target dimension changes the request that is signed and sent** (measured; +the resource was `ocid1.vcn.oc1..approved` on the baseline binding). + +| Change (one at a time) | sign | actuate | What went out | +|---|---|---|---| +| none (baseline) | 1 | 1 | `DELETE .../vcns/`, no body, `If-Match` = revision | +| `binding.resource_id` | 1 | 1 | another resource | +| `binding.host` | 1 | 1 | another region's host | +| `binding.base_path` | 1 | 1 | `/20990101/...` | +| `binding.key_id` | 1 | 1 | another key id in the signature | +| `binding.revision` | 1 | 1 | `If-Match: etag-STALE` | + +**O2. Invoke: the same five dimensions** (`ChangeVcnCompartment`, baseline body +`{"compartmentId": ...}`): each change runs with 1 sign and 1 actuate and the changed value on the wire. + +**O3. Invoke runs any registry operation, whatever its role** (measured; same binding, same body; +1 sign and 1 actuate each): + +| `operation` | registry role | What went out | +|---|---|---| +| `DeleteVcn` | delete | `DELETE .../vcns/` with the body that was given | +| `UpdateVcn` | update | `PUT .../vcns/` with the body that was given, unfiltered | +| `CreateVcn` | create | `POST .../vcns` with the body that was given | +| `GetVcn` | read | `GET .../vcns/` with a body | +| `AddVcnCidr` | action | `POST .../actions/addCidr` | + +**O4. Invoke sends the caller's body text as it is** (measured on raw request text): + +| `config.data` | Sent | +|---|---| +| inner whitespace and key order | unchanged (the outer whitespace is trimmed) | +| a duplicated key with two values | **both sent** | +| a u-escape sequence | as written, escape kept | +| `9007199254740993`, `1.0` | as written | +| fields other than the action's (`displayName`, `lifecycleState`) | **all sent** | +| JSON `null`, an array, a string | `null`, the array, the string | +| `config` absent | `{}` | +| not JSON (the whole request is then not JSON) | request refused, nothing signed | + +**O5. For the same operation `execute` filters the input and `invoke` does not** (`renderBody` was +measured directly on the input fields `compartmentId`, `displayName`, `lifecycleState`, `evil`; +the `invoke` column is what O3 and O4 measured: the body as it was given, with extra fields sent +for `ChangeVcnCompartment` and `UpdateVcn`, and the given body for the other operations): + +| Operation (role) | `renderBody` (execute) | `invoke` | +|---|---|---| +| `ChangeVcnCompartment` (action) | `{"compartmentId":...}` | the body as given | +| `UpdateVcn` (update) | `{"displayName":"X"}` | the body as given | +| `CreateVcn` (create) | `compartmentId` and `displayName` | the body as given | +| `DeleteVcn` (delete) | no body | the body as given | +| `GetVcn` (read) | `{}` | the body as given | + +**O6. Replay: each repeat signs and sends again**, separately from what the provider answers +(measured): + +| Case | Results | sign | actuate | +|---|---|---|---| +| `destroy`, the same request 3 times; the provider answers 204, then 404 | `ok`, `not-found`, `not-found` | **3** | **3** | +| `invoke`, the same request 5 times | all `ok` | **5** | **5** | + +The 404 on the repeats is the provider's idempotent behaviour (the resource is gone). It is not the +module declining to act: every repeat is signed and sent. A repeated `invoke` can have a new side +effect. + +**O7. Consumers.** In the locally cloned CentralInfraCore repositories and in the cic-factory +`tools/` there is no consumer of the module's `destroy` or `invoke` entry points apart from the +module's own tests and manual harness. Repositories not cloned here and other environments were not +checked. + +### 1.4 What was not measured + +The real provider, the Relay (what it does with calls that reach it), whether OCI accepts any of +the bodies above, and the Relay's behaviour when `invoke` names a `delete` operation. + +## 2. Properties derived from the observations + +| Property | From | +|---|---| +| **P1.** The target dimensions (`host`, `base_path`, `key_id`, `resource_id`, `revision`) are bound to the request by a value the module computes from the request itself, and the module refuses a request whose value does not match, before it signs. | O1, O2 | +| **P2.** `invoke` does not run a delete, update, create or read operation: those have `execute`, `destroy` and `observe`. | O3 | +| **P3.** The body that is sent is a defined function of the value that was bound, not the caller's text. | O4, O5 | +| **P4.** The body of an `invoke` has a defined shape: an object, only the operation's declared fields, strict JSON with no repeated key. | O4 | +| **P5.** Single use is not provided by the module and cannot be: it has to be the Relay's, and the measure of it is the number of sign and actuate calls, not the provider's answer. | O6 | +| **P6.** A result names the digest of what was executed, so a receipt can be tied to what was approved. | 1.2 (no echo today) | + +## 3. Proposed contract (concept) + +One field name for all three entry points, `execution_digest` (the `execute` one already exists); +the digest envelope's tag `v` tells the kinds apart, so a value of one kind cannot be taken for +another. All envelopes are `cic-canonical-json/1` objects with structural framing, as in +`plan-digest.md`. + +- **`destroy`**, `v` = `cic-oracle-destroy/1`: members `action` (`destroy`), `contract` (the kind's + contract digest), `kind`, `operation` (the resolved registry operation: `name`, `method`, `path`, + `role`), `target_digest` (the `execute` model: `host`, `base_path`, `key_id`, `resource_id`, + `revision`), `v`. +- **`invoke`**, `v` = `cic-oracle-invoke/1`: the same members and `body`, the canonical value of + the body that will be sent. + +The `target_digest` function is the one `execute` uses; the full digest still covers the action +kind, the kind, the registry's operation contract and the target. (P1, P6) + +**The body (P2, P3, P4).** + +1. `invoke` runs only operations whose registry role is `action`; any other role is refused + (`validation`, `operation`). +2. The body is a JSON object, not an array, a string or `null`. +3. It has only the operation's declared fields; any other field is refused (not silently dropped, + so that what was approved and what was meant cannot drift apart). +4. It is strict, bounded JSON (`canonical-json.md`): a repeated key or a value beyond the bounds is + refused. +5. The bytes that are sent are produced by one function from the canonical value, `wire(value)`. + The digest binds the value, the module sends exactly `wire(value)`, and a test pins that the + canonical form of the sent bytes is the value that was digested. The digest is therefore about + the HTTP body that goes out, not about the input text. +6. The number form of `wire` is open (D-DI-3): the canonical `e` (valid JSON, but a + provider may refuse an exponent for an integer field), or a plain decimal when it is lossless + and bounded (`1`, `0.5`, `100`), refused otherwise. Whether OCI accepts either was not measured. + +**Before it signs**, the module resolves the operation from the registry, checks the target (`host` +and `key_id` present, every field bounded, `resource_id` a well-formed OCID and always required here), +checks the body (invoke), computes the digest and requires it to equal the `execution_digest` in the +request. A refusal is `validation` with the dimension in `field_path`, with no sign and no actuate +call. The result echoes `execution_digest`. A request without the field is refused (an ABI break, +as for `execute`; there is no consumer, 1.3 O7). + +There is no plan: `destroy` and `invoke` are not derived from desired and observed state, so +there is nothing to derive again. The module therefore cannot judge whether a given delete is +needed; that is a policy question for the Relay. + +## 4. What can be proved where + +| Statement | Who can prove it | +|---|---| +| The request's content is what the digest was made from; the body's meaning is bound; the operation is the registry's `action` | the module (it recomputes) | +| The five target values are the ones in the digest | the module (it recomputes) | +| That those values name the right OCI environment, tenant or resource | not the digest: the Relay and the D5 contract (section 6) | +| That someone approved it, who, under which policy | the Relay (R7) | +| Single use, concurrency, an unknown outcome | the Relay (R8). O6 is the measured absence of it in the module | +| That the HTTP request that leaves is the one the module built | the Relay's host boundary (R7): `sign` and `actuate` act on the bytes the guest hands them | +| That `destroy` and `invoke` cannot be reached without an approval at all | the Relay (R10). The module's digest is a precondition, not an enforcement | + +The module-side digest lets the Relay approve one concrete, content-bound value. It does not by +itself stop a caller from reaching the provider by another path through the host boundary. + +## 5. Open decisions + +- **D-DI-1.** `invoke` runs only `action` operations (proposed: yes; it ends the O3 bypass). +- **D-DI-2.** A field outside the operation's declared set: refuse (proposed) or drop silently. +- **D-DI-3.** The number form of `wire` (3, item 6). Not decidable without a measurement. +- **D-DI-4.** One field name `execution_digest`, told apart by the tag `v` (proposed). +- **D-DI-5.** Keeping the old digest-less `destroy` and `invoke` request: no (proposed; no consumer found). + +## 6. Questions handed to D5 (the origin of the target data) + +These follow from the survey (O1, O2) and are not answered here. The digest binds the values +together; it does not show that they name the right OCI environment or entitlement. + +| Field | Today (measured or read) | What D5 has to settle | +|---|---|---| +| `host` | given by the caller in `binding`; every change in O1 and O2 ran; the only host-side limit read is the Relay's egress allowlist (host, method, path, port, from the module manifest) | where the value comes from (region and realm), who authenticates it, and how the manifest allowlist relates to an approval | +| `base_path` | given by the caller and put in front of the path unchecked by `observe`, `execute`, `destroy` and `invoke`; only `poll` is strict (`/` and 8 digits); the `poll` alias takes the version from the caller when `base_path` is empty | where the service's API version comes from (the registry), and the rule for an empty value | +| `key_id` | given by the caller; the module writes it into the `Authorization` header; the actual signature comes from the Relay's own Vault key (read: `cicFlowDeps.sign`) | how a key is tied to a tenant and a principal: the Relay's (R6, R7); the module only knows the name | +| `revision` | given by the caller (an etag from an observation); the digest binds it | freshness does not follow from the digest; the observation's source and time are needed for that | + +## What this does not claim + +Nothing here is implemented. The Relay does not require or verify an approval for `destroy` or +`invoke` today, and the proposed digest would not make it do so. The measurements were made +against a mock host seam, not a provider, and show what the module does with a request, not what +OCI or the Relay does with it.