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.