From 8499a5d03072a1814e96f42b39a670bab4760c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Z=20Sink=C3=B3?= Date: Sun, 8 Nov 2026 13:28:50 +0100 Subject: [PATCH 1/2] module: bind execute to its plan (M4a): plan, target and execution digests, and a derive-again gate Execute ran whatever plan_id, config, binding and operations the request carried: with the same plan_id a changed config, resource_id, host, base_path, key_id, revision, or a swapped (M3-valid) delete all executed, and the same request ran any number of times. The old plan_hash also left out schema_version, encoding, the provider operations and the target, hashed raw JSON bytes, and joined its fields with no separator. plan now takes a target and returns plan_digest, target_digest and execution_digest: sha256 over canonical JSON (cic-canonical-json/1) envelopes with their own domain tags, no string concatenation, an absent field and an empty one the same null. execution_digest binds plan and target and is the value an approval will name. plan_id is retired, with no alias. Plan's lifecycle decides what the target must carry: no observed state and no resource id is a create; observed state needs a well-formed resource id; the other combinations are refused (the cic-module-oracle-cloud#31 guard stays). Execute derives the plan again from desired, observed and the binding and requires the same operation and provider operations in the same order, the three digests, and config equal to desired as a JSON value, all before the first sign. The result echoes execution_digest. The expectations for the digests are language-neutral vectors made by an independent implementation. This shows a request is internally consistent. It does not show approval, authority or single use (M4b, the Relay): two tests pin that a self-consistent request is not approved and that the same one can run again. Existing Execute tests now build their requests through the real Plan; the step-output bytes test runs the steps directly. --- [signing-metadata] key = cic-my-sign-key signature = vault:v1:MEQCIBdcnJ/2KyY5S7fLMOOG9p5Ce5hLaCI7TdW5Z2DylRU3AiAv/aYJVHMA/ks3sAeyB7rvoYAqzY6IeEqjtrd0C22PxA== hash-algorithm = sha256 digest = aFAtqS2oAdhBiPEVcrY0QiRVQxpPnwRcO3WT8Zauq5A= [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 | 35 +- docs/design/README.md | 6 +- docs/design/manual-verification.md | 13 +- docs/design/specs/canonical-json.md | 8 +- docs/design/specs/plan-digest.md | 107 ++++ docs/design/specs/provider-abi.md | 18 +- module/binding_resource_id_test.go | 84 +-- module/execute_bytes_test.go | 19 +- module/execute_create_id_guest_test.go | 7 +- module/execute_create_id_test.go | 36 +- module/execute_registry_conformance_test.go | 97 ++- module/m4a_gate_test.go | 658 ++++++++++++++++++++ module/m4a_helpers_test.go | 88 +++ module/manual_real_oci_test.go | 116 +++- module/module_loadtest_test.go | 13 +- module/plan_binding.go | 210 +++++++ module/plan_noop_exactness_test.go | 1 + module/provider.go | 269 ++++++-- module/provider_test.go | 30 +- module/relay_integration_test.go | 23 +- module/testdata/plan_digest_vectors.json | 621 ++++++++++++++++++ 21 files changed, 2194 insertions(+), 265 deletions(-) create mode 100644 docs/design/specs/plan-digest.md create mode 100644 module/m4a_gate_test.go create mode 100644 module/m4a_helpers_test.go create mode 100644 module/plan_binding.go create mode 100644 module/testdata/plan_digest_vectors.json diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 784c207..54b669b 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -2,7 +2,6 @@ 01956821ece300a66ff61849618cb03b0b3fd5aefce4dfd9bff7a4a33c867e1e docs/design/specs/capability-manifest.md 052458dca6bed674aec47f25561534ce72cda99a62faf33e2e947474400c7374 tools/vault-rootCA-sign-agent.sh 05fca2112ed6319a91d0f373f9dcab23df35687daea510498110e9f1ce80e498 module/testdata/vcn-v1/cases/field-absent/expected/verdict.fields.json -07dd65e321c4eeb0e26ea6d7154d93d2a9b79b7a5ac0f19c2a32c7621b9844c2 module/relay_integration_test.go 0991a93d56b1e24dc9285369a6f636b50fcc5fa6dec48305f76647828c6fbd19 mk/wasm.mk 0d08c6a7982760ef2c1bac82bf587e8d2c0e30fadc6638056e63aaa5e259a1cd tools/finalize_release.py 0e290d5b03efe23941d250236579dbed1c3cd6afdee70234f757b44aa7f6cc77 md.meta.schema.yaml @@ -31,6 +30,8 @@ 247687949482cd6e8645802f2daaaa50d2b0fe546b5b66c97a887830ae6a9e17 module/oci_sign.go 256506d6120cd3ad29d63578dc2c4f8f1dffb8f087270d0a74dd5683deb107cc module/testdata/vcn-v1/cases/desired-absent-observed-present/expected/evidence.claims.json 256506d6120cd3ad29d63578dc2c4f8f1dffb8f087270d0a74dd5683deb107cc module/testdata/vcn-v1/cases/field-absent/expected/evidence.claims.json +2631dbec077e6263a54cf9e2a0389395819859ae0619d5a163e8bd54b8643bff module/m4a_helpers_test.go +2a1559feea3ff94f981c19b44d3e963cb3ff5632ebc7e17f3dace9ef52341624 module/plan_binding.go 2a6bc913a5c877b2b2dfb30f68d165950d9c881606819d6873047f1122c63abf docs/hu/architecture.yaml 2ab20175d0b7d0f2a7ebe960903a08dc7fe69620615f253ae1fa3e584747edaa pytest.ini 2b08eed0184b63b5bd565e386bd50431a7da83afb8510dcb1fc17cad8482de4a module/testdata/vcn-v1/README.md @@ -47,11 +48,14 @@ 311e9395faf9ba5ebf11644ba26be1e052614d0b4db06dd12bddb6a78a354769 module/testdata/vcn-v1/cases/partial-read/expected/evidence.claims.json 31c160fcfce1bab71012e39794a54dba00fd746b58cddeeada9fa5982445bd55 tools/verify_release.py 3277b43930c89ab1464116c882e00bd1df1d62a4dc0c80c62f2da1f79841c2ed module/abi.go +32e5081516b2e9287606aca5ac6680645a3348acdacd7cf4a3f08989aa6ac882 module/execute_bytes_test.go 33e349f5d148ab506960230d4ac33d719da7e65f5b316ad4dfff191bd7dc3588 module/correspondence.go +348110ff94ae537c350c6326f8591fd24b4899aec8ec37c06e6006e0d7f7484c module/provider.go 35bd42c0d55ea02ea9701070141dbb7ebc01d6b020e1cea9103bc5f0ec22c243 module/testdata/vcn-v1/cases/desired-absent/oci/response.json 35bd42c0d55ea02ea9701070141dbb7ebc01d6b020e1cea9103bc5f0ec22c243 module/testdata/vcn-v1/cases/field-absent/oci/response.json 374a2c54d3fcd2b684d2a1699fe7e67c69dffa51e302001c13f55b1468c8c0c2 tests/test_tools/test_compiler.py 3766bf4bce6194763f838cc1f2aebff0801d4451956b8446916b17ecf846c2d7 abi.schema.yaml +38cc687d4b502af22a62ea96ac29a45e9a414fe72a4b0d2a584d4a859bd18ba5 module/plan_noop_exactness_test.go 395199128c561de3e991d635b3d93a2f2a8d7809de5ebe7195877ac37c2d92ba README.md 3afc5e1977e9258dd478d592606874b8fad4229f72306b15ef072c2b7fe67665 module/abi_negative_test.go 3c9bd79e75e97207cc669159b980e331ae8f75b36c0e9899b4a66f3deac0846d module/testdata/vcn-v1/cases/partial-read/expected/verdict.fields.json @@ -60,29 +64,31 @@ 4052911ff15fb58c6072a0cc9de1119286ccece4491fac76f4c27817cdf1447a docs/contracts/en/envelope.md 4165cf6ae04fe133b57dfe070307029c25999af63b9afd718453eb5d6729f07b module/testdata/vcn-v1/cases/desired-absent-observed-present/expected/verdict.fields.json 41ab08a0eb4f0739f352e60b35512c5402644f16fc349552cd185c490cb4d5a3 module/contracts_test.go -439445a5ad606bc5cd95a94cb4fba54f3f4dca17ed402d28c45c355cb235f301 module/module_loadtest_test.go +4262f6c980e336c488f4f3926d76f467777058677ba12cfeca8c2c09cd206659 docs/design/manual-verification.md 441f681287c170544f90844b29e4a103f89e91ac23a6cc8e974635d8bcde5a2b docs/en/architecture.md 46120946bf939cfeae2c4806a39aef398f904f89b61c670ee5dd0d3c769138c2 tools/oci-extract/resolve_test.go +47d07882f79fb0febf2f767db0c43ede48e26c7f4a2d71a77f4264f3b5d488eb module/binding_resource_id_test.go 49ea29dcc165db8a6b406e82492500fd4761a7121c2420116e31f8e58f865dec tools/oci-extract/resolve.go 4ac6d59c5e3cd88c9dde821511c970827ae56e40a89a308a210cd985ecfdc41e CLAUDE.md 4d7369474566105752ec28aefab51de748b237342c71d05b3189fa8c2ae1fdef Makefile 4fffcdac095c0623fefb8515ab80b3e9629c0febc454456cd08b3cdf3c941720 LICENSE.md 526ab17415fc9a42967d35159db9438c3e696bf5a246e57a9f68c2dd0e596837 module/testdata/vcn-v1/cases/drift/expected/observed.oci.json -53bc2df597bc242dd2f8d2176b3a68e3d0681079132431c8e9900e14d34314c7 module/provider_test.go 5432506f15d65fa59572362a70db4bc403c13b163ec961ecb2951d7f98d093d0 module/go.mod 546e7c533da4b86b8817ee426865df0d11e24d2aa1580647634345f452a5017e module/canonical_json_test.go -56934c1ce7b4de656087d0b2ddb9e9dcbf56dd0c541d9e279c19c7535e84445a module/provider.go 57980b8a84a13ffad62b7295e3095bd635f8894716665d0654dfb54eebbf10e5 docs/contracts/en/host-expectations.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 +62cf6cb9d0e03f8d6922a625bcfc0d8dfc91a26319739f47006606a1a3ee2662 module/execute_registry_conformance_test.go 635384b1db18a7654d1e30b660ba825645d88a89e7bc0e808500a213b584f278 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.registry.json 66181e4c6a0b2735b5a8b3088b37e6a844e2c692399a5b72b036059da0188e0a tools/releaselib/__init__.py 688dffd2264877f3a64fca1d3526c57bb34cb1c18f5a01aa906667311831a052 docs/en/workflow.md 69294730cf43d081b23a484cf0b22dc383626154e5933f5353f60b025ee5c832 module/envelope.go 6a37f4257d4d1d1714e43b43bef32af8f00d71b219500f8e2820f46b046fc0b3 module/testdata/vcn-v1/cases/not-found/expected/verdict.fields.json 6a37f4257d4d1d1714e43b43bef32af8f00d71b219500f8e2820f46b046fc0b3 module/testdata/vcn-v1/cases/transport-error/expected/verdict.fields.json +6a3af509e23680369efafaf2197e6e1ab57fb48bd865aae072da2dc32038978c docs/design/specs/provider-abi.md 6cb9c6a0e685f8377a7ae3d8e7a417c22135b47863951937dc6647566de9ff7a module/execute_create_async_message_test.go 6d9c5cce2c2ce964f0add1b4aeb97a9bce828ad46dd76b22929e284741273fc4 schemas/index.yaml 6db64092fb2c2dc8bcbd3e321faf04f6cc40b16e890c8dec3e56e4178df01402 LICENSE.yaml @@ -90,15 +96,16 @@ 6f3655bf787efee284225bcb6096913d0ec556771dddb42845311e649c21e7ad docs/hu/concept/declarative_ecosystem_integration.md 7068014ecee4111cb4456d3d3328c33b53a79c7803d68f040036f34449423f65 tools/oci-extract/testdata/instance.go 7080b3423beded8b64891afdcf2e6f6d8bec6ebeaa88aecbb1b06e149b54ef86 module/module.wasm -70d185e08cc84d9c493f925bd3e4153c6a69078b7a1b560d290031e4ad602933 module/plan_noop_exactness_test.go 7136d8366bbd7c5ccc19a3d810a7f55c31541acec9b0f05bd84e45a39107ded7 tests/test_capability_manifest.py 7426752feb1140b2023192728df35248e4f802617c150bb0523ab97dc1638c0b tools/init-hooks.sh +74d6780f57986510dae0ac2222e83a0c2fd1535fe723f2508656e7fc1eb60cb9 docs/design/specs/plan-digest.md 74d93981f1935d21001ed0475ebb84132ab67c71840ca82ca0cbfac5aca29ea2 docs/contracts/en/wasm-abi.md 763f2814427dc8faaa7fb1ccf3e8afe2f6460273a2dfe852287b3ef66bc43b96 tools/oci-extract/policy_test.go 768d54fe8ec5fde38c41ec075ce02b51d93cf3bf5f47e8d81e33932f3e3ece27 module/ocid_boundary_test.go 76acd23cb8753e630beb9477b071c8e9c3234584db1a077e20bbcf47737cb23f tests/test_tools/test_releaselib/test_git_service.py 79273b206c6143ede2e8a28f7c3d667d93c5978c764b3db4f543c7f4a477253b module/testdata/vcn-v1/cases/not-found/expected/evidence.claims.json 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 7b84214b85a3fd95e9153528df1fa1522ff026df1f561752d95c715250301ca6 tools/vault-sign-agent.md 7df714ce12b9abfb18357c4fc25e67b8f23e6557b72ee1ea4c2a4a5d4a596c43 docs/contracts/hu/host-expectations.md @@ -106,7 +113,6 @@ 7efd45c44d808639d5fe607cf4182e930be6b8d82709e716114e63512f69b55f module/testdata/vcn-v1/cases/conformant/expected/verdict.fields.json 805c05942247d6327f0f006a8799119ace530c4ae288ad637d0f8faddca21d86 docs/hu/architecture.md 806851c10862d6900eb858a229e3ba7138943abe3fd75e5b1e8f8ca4b57d1d6f mk/golang.mk -81eab1d1dbbeffa23d53c33e39f0bf6977348178640c1032827651d27180ffa9 docs/design/specs/canonical-json.md 836f908676723485f9648e69e184c49e51304febd4bcdc500c54038522a6092d module/observe_derivation_test.go 8400c953622cd5ba5414898bbc85e51494eaa3dae6ac9051ab4ac67c4a6e3384 module/testdata/vcn-v1/cases/conformant/expected/evidence.claims.json 8400c953622cd5ba5414898bbc85e51494eaa3dae6ac9051ab4ac67c4a6e3384 module/testdata/vcn-v1/cases/desired-absent/expected/evidence.claims.json @@ -114,7 +120,6 @@ 84e51775f32f77fad38796b52a8b247a7b6ce773247a9cba45e39d2a652a1e9f module/testdata/vcn-v1/cases/desired-absent/intent.registry.json 85d63071061eab5921536bb9bd27e2117212ab18b931c36573903ce57444ec03 module/testdata/vcn-v1/cases/conformant/oci/response.json 85d63071061eab5921536bb9bd27e2117212ab18b931c36573903ce57444ec03 module/testdata/vcn-v1/cases/desired-absent-observed-present/oci/response.json -876ececb416c0a7f5450c243c7d8d3141759a4e4ef69959f890917c173083d01 docs/design/manual-verification.md 87b9bfd20b27104bbc113bc3e64f91cba9eb7f678a931a55123d0209131d3c67 module/testdata/vcn-v1/cases/partial-read/oci/response.json 885e419c23b97b7558a4f599267ef904a9f059ed3c455d3a95eda787ea7ab117 tools/releaselib/git_service.py 8a269de9b6799fbda2837466293f020125b5376b4e0794eb25d8d4146af8aa0b docs/design/specs/relay-sign-send-interface.md @@ -123,14 +128,13 @@ 8c956255f18ea7baa83eec246e38e9138b9e29284972a2fa95084a70ea97d6a9 capability-manifest.schema.yaml 8db6dccfdd2342641ad77532d9f66b305f74920ecc116371e81d8a36c873c7fb tools/oci-extract/policy.go 8e2542ec21d36fd70c95f3f80573839906d137478edca225b81342f8cbda16e9 tests/test_ci_workflow_order.py -8ee9f44e00990bbd4f73472ccd8b22e2b4eb30cd26e9201b3f955ac72416dbac module/execute_registry_conformance_test.go 9162efab5184e7d9546bbd6b7fbed2fff74d5b1564a3b6847aada81ff7ec7ae3 project.schema.yaml 933d06b06c3c2e2fd53603e3865d99fe92a4cfaad49809a20b6ac368f41e5a0b .github/workflows/ci.yml -9385138656d5af30f40dfa831b0eb970115ebe90e646fefa7aee17d9531bdb34 module/execute_create_id_test.go 941db4ccb700b939d937831c1d34ce432564c5d86fd82a91f01231149f3096c8 tools/oci-extract/cmd/oci-extract/main.go -9670f6d8b90323f9a87cbe0f4ab414e4acc1ebe2268187f237b75c039fdedfb3 module/binding_resource_id_test.go 976bee7ffb9a4a599d5aee841d941ea7d12fecb6fdbb05d44238754b67148cb1 module/envelope_test.go +97b1851090675543e10f2d61ec390cf54d218b1bd0985b4f8195c6aa0ddef677 module/testdata/plan_digest_vectors.json 97f3f81e90d839c74efbe7ad2e1732b08ac0612a38499d6ef5dedcc637c64eb6 module/testdata/vcn-v1/cases/malformed-cidr/oci/response.json +9824bb348604556c7f55452fcc9e396c203f51311736e1c501f53ba4fdc127aa module/module_loadtest_test.go 9b18a2109d250d26d57d3cec5ed9c67bd803d34e9592ab98fb1826a5dc457a8f module/cicflow_mock_test.go 9b6f006a0e21f7baaad8509b3323e4c7e5bc4d16ab8cc165a70af855f78da460 module/testdata/canonical_json_vectors.json 9ba2ca374fd1900e82a2b6a9c27233e1603dce89b1908592b2a6cc93313be84c docs/hu/wasm-module-authoring.md @@ -141,18 +145,19 @@ a1cba33d61cd932030481da78da553eb0f31b536a7c3879d5bdd9992c69d189a module/testdat a3f497092a2fe0fc41467edefff241687affff6f01adbbbf0dadbdefd791bd21 tools/compiler.py a44687b269fac17633c80d8e735cf3402d5f65fd0bb2c24e9996d0a89e134c94 docs/contracts/hu/envelope.md a4c522ffc80fc87ff4244ba3da8f19591d8206ed9acb4d1d7896ced9f75714a3 docs/design/specs/state-model.md +a6728222647cd2d265b28a522ecd3f8d779e01bb8bea5367912f4cd7fb8c72d2 module/execute_create_id_test.go a6c2baaaf307c0718ecaa43f654742269c3830fd9ff899a000af5e56713e5082 tools/__init__.py a85fe8169218b1abe90093cc5d7f8bccc0cb1eb98a4670881b31d37834e54893 tools/oci-extract/client_test.go a8ecf9c2551505fd5a4c477c735c88fff9c4627136dade955cdcbfc16d8ca131 docs/en/makefile-cheatsheet.md aedb718131b5633d08f58ac67b23d3940b4fbffd98e93c9f75db3cf80903f1f8 module/schemas/core/instance.json b1609aa1ad37f8f682e3025d866eaebe9719802b34aac580387d3bce65935f30 module/observed.go b2c3bf78eb4368f14eb5d380cd7aee88bf33c39f88613fa1fae00f551f7e1d83 module/testdata/vcn-v1/plan.json -b2d22256890100dc3903cd2207321829521b1a39b6881dec0ca86d2bd7a08d73 module/execute_create_id_guest_test.go b304ac8143d669cbb505d50ffbeb11deac010d54cfab4fa2f0d6ac2355382e7f module/cicflow_host.go b3585cfa2c622b28548424d092f4ee05a679d7ced46a762f7c1f5bc4931e5c27 tools/oci-extract/testdata/bucket.go b3d0d0dd71c294b5fa8bad4fcdd9784feae35141aeb71e1755abd8bbd0cfca58 pyproject.toml b4243f2ae7001af0ff5fe747ce968866becba32ec759c0adcf3b7eba008f6ed3 README.hu.md b579f80d62ce67a1be2da3093c3d26e63c4cde31000ddb4fd4787eeda57f423a .gitignore +b5b105b792741f2103911ab66377ca99cc3d06b249a14e97ce94b39849e457c8 docs/design/README.md b5fdf8f066fa2295376d100b62429c7a20edbb04dd0f31f1b7a83507a3374b2e module/testdata/vcn-v1/cases/transport-error/oci/error.json b73da39348121a3ae5bf42915a4ab3c35653b7c9fb5a4c575a9ce0d89da66c7a docs/en/wasm-module-authoring.md b8107f8b819135d4ba3d56804ecca545c24bde54a381f5ddde7ac19815253734 docs/design/forms-integration-direction.md @@ -160,7 +165,6 @@ b8e7c23cfe46a0daf7d3441b15657e9d4b35c4bf50f9421c2841dfbf19cacc82 docs/en/archit b9325f58a95216b240f3715a773e34fe8cdfa4d5fe48a5e7b32002a40be5cc2e tests/test_tools/test_finalize_release.py bb8a1743ab9f77a309bd851b4a1660f6f2420900455a5a0dfb83f4e6905968ff module/abi_manifest_test.go bbe93c718d90a03de5aab2999031c7097bd63a8e40ec14589c8dbe4364a4ec52 renovate.json -bcb406d5d10094dafb465d317935b4b8656e895a3331278765065669c8681fcf docs/design/specs/provider-abi.md bef22a55d1559676a57b4a05b48efc7cd226d3d733a1c932e183666679046b53 module/testdata/vcn-v1/cases/drift/expected/evidence.claims.json bfccbd3cd778ac6f0458b7d45d7b25e2cbdc0a789f40e81c1de476f8aab44363 tools/schemalib/loader.py c31a1313ace9ae3da9483d7ac7bbc0938d5e2f6c7c0460d785d94db5bb931e85 docs/contracts/en/release-artifact.md @@ -168,6 +172,7 @@ c54659558693be5238ff020322eb00ca80ce7d14aea461ee1ae24361e80fe84f tools/releasel c55c74bb201c91330aa8bda11dd256fe35f84b3a3a0e24a1cebe3e487bd60135 requirements.in c637082ac035d4dda23ff27a31dfd7bd9a62437db24b53d48d585897fa1874a7 module/testdata/vcn-v1/not-planned.json c6919cec77cbf13916932f91cdc9b9d32d9290282c5d87244af7adc1a30d441a module/go.sum +c6f4ed120394d5d71b37e9b44293561cc76be2c7a8087d3facb12458c1b08805 module/m4a_gate_test.go c83bb77196b26e0f656d38b38736a488ff2aa316d995908bf9abade63fccdd67 tools/wasm_fresh_guest_test.sh ca7e4a9412ff28a5ca3e3fd9c759a62126564d82e8892b12b6efa1cbf11d5810 tools/oci-extract/client.go cb147348de6aadbcef95a82efb66d8e5252d1c82f34aba14c716747dea57d10f module/testdata/vcn-v1/cases/malformed-cidr/expected/evidence.claims.json @@ -178,8 +183,8 @@ d08117cca9ab07e9035ddd59f5e7cb0a610517f95b85542988a3222f08a015da module/testdat d1beaffed059ebbdbee38bc20b465a0e82bb0a4e11b0dc4ee801887cde0f358b docs/design/relay-requirements.md d2c71865652ef4c4f9bed86eaeac6e5a63570e8677987b90a97cb756258ab234 feature-list.md d2dfadbd6532e7de6ae35af273fbdc446ffbb3e7f5b24d7092b3dea70ec2001b module/poll_contract_test.go +d3bce87b8745997eb0fb912dcd61c4ee8fd6d6596be6fec41a51649edcf32684 docs/design/specs/canonical-json.md d3ee577820563d1e062dae6243711e4467c2920e1001fad8c4933f5cd4a320be docs/contracts/hu/release-artifact.md -d4e1cd1efc9258f891b5d525fdc4a183af74b3e7090fceae583f8c072c6e96f4 module/manual_real_oci_test.go d5c7049ae62583fb629ada0f600be689015ffcbaa5290982d989dbe260ff96c5 tools/oci-extract/testdata/compute_client.go d5daed86d55d8b22d307e8b4df9ab5af7cfa9c2647750d28c5344daeb40f92f8 module/testdata/vcn-v1/cases/conformant/intent.registry.json d5daed86d55d8b22d307e8b4df9ab5af7cfa9c2647750d28c5344daeb40f92f8 module/testdata/vcn-v1/cases/drift/intent.registry.json @@ -191,7 +196,6 @@ d5daed86d55d8b22d307e8b4df9ab5af7cfa9c2647750d28c5344daeb40f92f8 module/testdat d73ece9aa8ca3e7e76655705dd0c074869a83fe66a8745212f95a6a4db8916cf tests/test_oci_sdk_lock.py d75063538a29c37caad5e4f1ba680c81bc4827ee935e78281ab168b72d5e53a2 tools/check_doc_links.py d8e2e8a0c3377de19d2e1f04a7bf7f4dacce1a3bcc25ac67884ac2dab4be1bdc docker-compose.yml -da89091049f1e5ad288de12df086c679803c8e56a86bf42f569256437e672571 docs/design/README.md dbe54644234ddff2e00a48a38e44b2b21fa4eeb3142f17872614ea656deb5836 tools/oci-extract/testdata/vcn_client.go dbfb640d40533eeb73ececd170189f951f16ad07c549a8321dbb12251e78d067 features/feature-001/spec.md dc0c98d1e3ca6fac0ef40227c6f8c026397180059c95188d9adb4567cef2e11d tests/test_tools/test_infra.py @@ -202,7 +206,7 @@ df4af3ee8dfbb9616d0409f9975d7caf232a84517813debddce5a1aa06920f69 oci-sdk.lock.y e08953038f05a27cb31a632c32821b549741bade6bb8a8a1377d22f95ba62081 module/schemas/core/vcn.json e0a0284215350127f8a8d9d74c6dd2889b41e88b5e189d28762f87b20ffc19d6 project.yaml e17623644dcf3c28de7ad5692e1b576506cb33ae009b8c57a11b19c1c32c91ed tools/oci-extract/testdata/identity_client.go -e270d338cd1f96774b0ac184327df0c5cd07814815cc26d78d44b45cd696cb49 module/execute_bytes_test.go +e2929f427eb30a3ca7ac596735cfb4d33d0ad771fdb7cf254a59a51a6a1b4f8c module/provider_test.go e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 configs/schemas/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 source/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 tests/compiler/__init__.py @@ -227,6 +231,7 @@ ef24355cc6f0e86d0771a1186b5d865336b69c4a4c0e217359a9c14ae0a90a88 module/guest_b efd889b86e9d669c00c32667d728747612d9c224a8e438e2f381b48145e284e7 features/feature-002/spec.md f000102bd4c1a767896b393391d11934a6c1deba7d610c6d36c6686514d7222a .editorconfig f028b1fe4510303e10a66bb3057bce7d5c241002a60da8d837ebe8b0dccbca45 tools/oci-extract/regression_test.go +f0b9f0aa25d9e40ae111e6f25ccd8d7d81149fb18aba8fce0306d837dd063f0d module/execute_create_id_guest_test.go f0d2a62342b11b6d6c0434df363178fbf3450d563beda1e56cd9aa52047300e9 tools/oci-extract/schema.go f15514d11405c92b8f7e0e95fa4161da00cf4a23e4cb0fa6aadc262cae80b3e1 tests/test_wasm_integrity_gate.py f2b6dde95429bd1562c057c5ca974958608e8c4524fa33619d9095c195fd6df7 module/testdata/vcn-v1/cases/drift/expected/observed.registry.json diff --git a/docs/design/README.md b/docs/design/README.md index fe09dca..68b44b2 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -27,8 +27,10 @@ namespace it opens. Written 2026-07-18 from the design session recorded in - [specs/state-model.md](specs/state-model.md) — intent/state object-level correspondence and the `effective_config` projection. - [specs/canonical-json.md](specs/canonical-json.md) — `cic-canonical-json/1`: the exact JSON - value-equality and canonical form `plan` decides on (and a later plan digest will be - computed over). + value-equality and canonical form `plan` decides on and the plan digests are + 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. ## Direction diff --git a/docs/design/manual-verification.md b/docs/design/manual-verification.md index 159726f..caa5ac4 100644 --- a/docs/design/manual-verification.md +++ b/docs/design/manual-verification.md @@ -196,14 +196,19 @@ running this against anything but an empty/disposable tenancy): ```bash # ... same OCI_KEY_PATH/OCI_TENANCY_OCID/OCI_USER_OCID/OCI_FINGERPRINT/OCI_REGION/OCI_TEST_KIND as above, plus: -OCI_EXEC_OPERATION=CreateVcn OCI_EXEC_METHOD=POST OCI_EXEC_PATH=/vcns \ +OCI_EXEC_OPERATION=CreateVcn \ OCI_EXEC_CONFIG_JSON='{"compartmentId":"...","displayName":"...","cidrBlock":"10.0.0.0/16"}' \ REAL_OCI_TEST=1 go test -tags manual_real_oci -count=1 -run TestManualRealOCIExecute -v ./module/ ``` -Omit `OCI_TEST_RESOURCE_ID` for `Create*` (the path has no `{param}` to fill); -set it for `Update*`/`Delete*` (`OCI_EXEC_PATH=/vcns/{vcnId}`, etc.). The -harness has no "list" capability of its own (the module ABI operates on one +Omit `OCI_TEST_RESOURCE_ID` for a create (the plan then has no resource to name); +set it to update an existing resource. Since the M4a plan binding (`docs/design/specs/plan-digest.md`) +the harness no longer takes `OCI_EXEC_METHOD`/`OCI_EXEC_PATH`: `Execute` only runs a plan it +can derive again from desired and observed state, so the harness observes the resource +(none for a create), asks `Plan` for the plan of `OCI_EXEC_CONFIG_JSON` on that target and +sends `Plan`'s answer to `Execute`. `OCI_EXEC_OPERATION` is an optional check that the plan +starts with that operation. A lone delete is `Destroy`'s job, not `Execute`'s. This rewritten +harness has not been run against real OCI (it compiles under its build tag). The harness has no "list" capability of its own (the module ABI operates on one resource_id at a time) — after a `Create`, find the new resource's OCID with the `oci` CLI (e.g. `oci network vcn list --query "...displayName=='...'"`) to feed into the next `Observe`/`Update`/`Destroy` call. diff --git a/docs/design/specs/canonical-json.md b/docs/design/specs/canonical-json.md index 0aef6a0..2a1a8c9 100644 --- a/docs/design/specs/canonical-json.md +++ b/docs/design/specs/canonical-json.md @@ -1,14 +1,14 @@ # cic-canonical-json/1 The one definition this module uses for "these two JSON texts are the same value". -It decides whether a plan has any work to do (`plan`), and is the form a later digest -of a plan will be computed over. Go implements it (`module/canonical_json.go`); the +It decides whether a plan has any work to do (`plan`), and is the form the digests of a +plan are computed over. Go implements it (`module/canonical_json.go`); the **normative expectations are the language-neutral vectors** in `module/testdata/canonical_json_vectors.json`, so another implementation can be checked against the same file. -Status: `plan` uses it today (**implemented**). Digests over it (`plan_digest`, -`target_digest`, `execution_digest`) are **concept** (not yet built). +Status: **implemented**. `plan` decides with it, and the plan, target and execution +digests are computed over it (`plan-digest.md`). ## Why not the standard library diff --git a/docs/design/specs/plan-digest.md b/docs/design/specs/plan-digest.md new file mode 100644 index 0000000..7baaed5 --- /dev/null +++ b/docs/design/specs/plan-digest.md @@ -0,0 +1,107 @@ +# Plan binding: plan_digest, target_digest, execution_digest + +M4a of the plan-binding work. `plan` returns three digests; `execute` derives the plan +again from the same inputs and refuses anything that does not match, before it signs +anything. Everything is computed over `cic-canonical-json/1` (`canonical-json.md`). +Go implements it (`module/plan_binding.go`); the **normative expectations are the +language-neutral vectors** in `module/testdata/plan_digest_vectors.json`, generated by +an independent implementation (no Go), so another implementation can be checked +against the same file. + +Status: **implemented** in the module (derivation, the three digests, the gate in +`execute`). Approval, authority and single use of a plan are **concept** (M4b, the +Relay): nothing here provides them. + +## What it proves, and what it does not + +A request whose plan, desired state, observed state and target agree with each other, +and with the plan the provider derives from them again, is **internally consistent**. +That is all. It does not show that anyone approved the plan, that the caller may run +it, that the key belongs to the right tenant, or that it has not run before. A caller +who can change every input and every digest builds another consistent request. The +Relay must verify an approval that names `execution_digest` (M4b); until it does, a +consistent request runs (pinned by `TestM4aDoesNotClaimApproval` and +`TestM4aDoesNotPreventReplay`). + +## The three digests + +Each is `sha256:` and 64 hex digits over the canonical JSON text of an envelope. The +envelope is a JSON object, so every field is delimited by the JSON structure; nothing +is built by joining strings. Each has its own domain tag (`v`), so objects of +different roles never share a hash namespace. + +- **`plan_digest`** (`v` = `cic-oracle-execution-plan/1`): everything the plan will + execute. Members: `contract`, `desired`, `kind`, `observed`, `operation`, + `provider_operations`, `v`. + - `desired` / `observed`: `data` (the canonical form of the payload's JSON), + `encoding`, `schema_hash`, `schema_id`, `schema_version`. + - `provider_operations`: the ordered list of `{method, operation, path}`. + - `contract`: the digest of the kind's registry contract (below). +- **`target_digest`** (`v` = `cic-oracle-execution-target/1`): where it runs. Members: + `base_path`, `host`, `key_id`, `kind`, `resource_id`, `revision`, `v`. `host` and + `key_id` are strings; `base_path`, `resource_id` and `revision` are `null` when + empty (an absent field and an empty one are the same value, by rule). +- **`execution_digest`** (`v` = `cic-oracle-execution/1`): `{plan_digest, + target_digest, v}`. It binds the two, so a plan cannot be paired with another, + equally valid, target. It is the value an approval will name. + +Not in `plan_digest`: the `reason` of an operation and the plan's `notes`. They are +explanation; nothing executes or decides on them, and they can change without changing +the digest, on purpose. If any decision, policy or approval rule ever reads them they +must move into the envelope; an audit that must keep their exact text needs its own +evidence digest or signed record. + +The `contract` digest covers what shapes a plan or an execution in the kind's +registry: its operations (name, method, path template, role) and fields (policy, JSON +type, action) and required fields. A module version that maps a change to other +operations has another contract digest, so an older plan no longer matches. + +The name `plan_digest` is not the engine's `conformance_plan_digest`: that digests a +conformance plan and is a different thing. The domain tag keeps them apart. + +## Plan: the lifecycle and the target + +`plan` takes a `target` (`host`, `base_path`, `key_id`, `resource_id`, `revision`). +`host` and `key_id` are required and every field is bounded (1024 bytes); +`resource_id`, when present, must be a well-formed OCID of the kind. Whether a +resource id is required follows the lifecycle: + +| observed | `target.resource_id` | Result | +|---|---|---| +| empty | empty | a **create** plan: `operation: create`, the kind's create operation | +| empty | present | refused (`observed.data`): a named resource has state | +| state | empty | refused (`target.resource_id`): an existing resource needs its id | +| state | present | the diff: `noop`, `update`, `action` or `replace` | + +The second row keeps the guard from cic-module-oracle-cloud#31: an empty observed +state can no longer be read as "every field changed" for a resource that was never +created. + +## Execute: the gate + +After the M1 id check and the M3 registry check, and before the first `sign`: + +1. `plan.plan_id` must be absent (it is retired; `plan.plan_id` otherwise); +2. `plan.plan_digest`, `plan.target_digest`, `plan.execution_digest`, `desired` and + `observed` must be present; +3. the plan is **derived again** from `desired`, `observed` and the binding, and must + have the same `operation` and the same provider operations (name, method, path, in + the same order) as the plan received: swapping an update for an M3-valid delete is + caught here, not only by a digest; +4. the three digests must equal the ones computed from the request; +5. `config.data` must be the same JSON value as `desired.data` (canonical equality); +6. only then the first step is signed. The result echoes `execution_digest`. + +Every refusal is `validation`, makes no sign and no actuate call, and names the +dimension in `field_path` (`plan.operation`, `plan.provider_operations[i]`, +`plan.plan_digest`, `plan.target_digest`, `plan.execution_digest`, `config.data`, +`desired.data`, `observed.data`, `binding.*`). + +## Compatibility + +`plan_id` is retired: `plan` no longer emits it and `execute` refuses a request that +carries one (even an empty or null one). No alias: an alias would be a way around the +digest check. A survey of the locally cloned CentralInfraCore repositories and the +cic-factory found no consumer of the module's `plan_id` (only a length check in this +module's own test); repositories not cloned here and other environments were not +checked. diff --git a/docs/design/specs/provider-abi.md b/docs/design/specs/provider-abi.md index 0f6afa9..608798d 100644 --- a/docs/design/specs/provider-abi.md +++ b/docs/design/specs/provider-abi.md @@ -81,7 +81,9 @@ observation: observed_at: 2026-07-18T... execution-plan: - plan_id: sha256:... # the plan is hashable and signable + plan_digest: sha256:... # identifies what the plan executes (plan-digest.md) + target_digest: sha256:... # identifies where it runs + execution_digest: sha256:... # binds the two; the value an approval names operation: create | update | replace | action | noop provider_operations: - { service: core, client: VirtualNetworkClient, operation: CreateVcn, request_model: CreateVcnDetails } @@ -162,7 +164,7 @@ different values always do (two integers above 2^53 are never equal). ### What `execute` accepts before it sends anything -Two independent checks run over the **whole** plan before the first step is signed +Three independent checks run over the **whole** plan before the first step is signed or sent, so a plan never half-executes. Each refusal is a `validation` error with no sign and no actuate call. @@ -182,9 +184,17 @@ no sign and no actuate call. read is `observe`'s job and `plan` never emits one. `i` is the first non-conforming step. +- **Plan binding (M4a).** `execute` takes the `desired` and `observed` state the plan was + made from, derives the plan again, and requires the same operation and the same + provider operations in the same order; the three digests (`plan_digest`, + `target_digest`, `execution_digest`) to equal the ones computed from the request; + `config.data` to be the same JSON value as `desired.data`; and no retired `plan_id`. + The details are in `plan-digest.md`. This shows the request is internally consistent, + not that it was approved or that it has not run before. + What these checks do **not** say: whether the id names the right resource, whether -the plan was approved, or whether the caller may run it. Those are the plan-binding -and Relay-authorization layers (status: concept). +the plan was approved, whether the caller may run it, or whether it ran already. Those +are the Relay-authorization layer (status: concept). ### What `poll` accepts diff --git a/module/binding_resource_id_test.go b/module/binding_resource_id_test.go index 5e78837..048deca 100644 --- a/module/binding_resource_id_test.go +++ b/module/binding_resource_id_test.go @@ -61,12 +61,18 @@ var bindingOps = []struct { return Destroy(nil, marshalReq(t, destroyRequest{Kind: bindingKind, Binding: bindingWith(id)})) }}, {"Execute (update step)", func(t *testing.T, id string) ([]byte, error) { - return Execute(nil, marshalReq(t, executeRequest{ - Kind: bindingKind, - Plan: executionPlan{ProviderOperations: []providerOperation{updateVcnOp}}, - Config: schemaPayload{Data: json.RawMessage(`{"displayName":"prod"}`)}, - Binding: bindingWith(id), - })) + // A consistent M4a request for a well-formed id; for a bad one, the same + // request with the id replaced by hand, so the id check is what refuses it. + build := func(id string) executeRequest { + return m4aForOp(t, bindingKind, updateVcnOp, `{"displayName":"prod"}`, bindingWith(id)) + } + req := build("ocid1.vcn.oc1..base") + if wellFormedOCID(bindingKind, id) { + req = build(id) + } else { + req.Binding.ResourceID = id + } + return Execute(nil, marshalReq(t, req)) }}, } @@ -190,28 +196,45 @@ func TestAMixedPlanWithABadBindingIDRunsNoStep(t *testing.T) { } } -// A create has no id to splice (its path is /vcns), so a plan with only create steps -// must keep working whatever the binding carries in resource_id. -func TestACreateOnlyPlanIgnoresTheBindingResourceID(t *testing.T) { - for _, id := range []string{"", "not-an-ocid"} { - t.Run("binding id "+id, func(t *testing.T) { +// A create has no id to splice (its path is /vcns), so the M1 id check does not ask +// for one, and a create with no resource id runs. A create that names a resource id +// is not a create at all: the plan's lifecycle (M4a) refuses it, because a resource +// that does not exist yet has no id. M1 alone would ignore it; the refusal is M4a's. +func TestACreateRunsWithoutAResourceIDAndIsRefusedWithOne(t *testing.T) { + create := providerOperation{Operation: "CreateVcn", Method: "POST", Path: "/vcns"} + t.Run("no resource id", func(t *testing.T) { + mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)} + restore := mock.wireHostSeam() + defer restore() + req := m4aForOp(t, bindingKind, create, `{"compartmentId":"ocid1.compartment..c"}`, bindingWith("")) + out, err := Execute(nil, marshalReq(t, req)) + if err != nil { + t.Fatalf("Execute returned a Go error: %v", err) + } + if res := decodeResult(t, out); res.Status != "ok" { + t.Fatalf("status = %q, want ok (error: %+v)", res.Status, res.Error) + } + if mock.actuateCount() != 1 { + t.Errorf("%d provider requests, want exactly 1", mock.actuateCount()) + } + }) + for _, id := range []string{"not-an-ocid", "ocid1.vcn.oc1..existing"} { + t.Run("resource id "+id, func(t *testing.T) { mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)} restore := mock.wireHostSeam() defer restore() - out, err := Execute(nil, marshalReq(t, executeRequest{ - Kind: bindingKind, - Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}}}, - Config: schemaPayload{Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..c"}`)}, - Binding: bindingWith(id), - })) + req := m4aForOp(t, bindingKind, create, `{"compartmentId":"ocid1.compartment..c"}`, bindingWith("")) + req.Binding.ResourceID = id // changed by hand after the plan was built + out, err := Execute(nil, marshalReq(t, req)) if err != nil { t.Fatalf("Execute returned a Go error: %v", err) } - if res := decodeResult(t, out); res.Status != "ok" { - t.Fatalf("status = %q, want ok: a create must not depend on binding.resource_id (error: %+v)", res.Status, res.Error) + res := decodeResult(t, out) + if res.Status != "error" || res.Error == nil || res.Error.Class != classValidation { + t.Fatalf("status %q error %+v, want a validation error", res.Status, res.Error) } - if mock.actuateCount() != 1 { - t.Errorf("%d provider requests, want exactly 1", mock.actuateCount()) + if mock.signCount() != 0 || mock.actuateCount() != 0 { + t.Errorf("%d sign / %d actuate calls, want none", mock.signCount(), mock.actuateCount()) } }) } @@ -264,22 +287,3 @@ func TestACreateNamedStepWhosePathSplicesTheIDIsRefusedOnABadID(t *testing.T) { }) } } - -// A create-named step whose path has no placeholder still does not need the id. -func TestACreateNamedStepWithAPlainPathStillIgnoresTheID(t *testing.T) { - mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)} - restore := mock.wireHostSeam() - defer restore() - out, err := Execute(nil, marshalReq(t, executeRequest{ - Kind: bindingKind, - Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}}}, - Config: schemaPayload{Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..c"}`)}, - Binding: bindingWith("not-an-ocid"), - })) - if err != nil { - t.Fatal(err) - } - if res := decodeResult(t, out); res.Status != "ok" { - t.Fatalf("status %q, want ok (error: %+v)", res.Status, res.Error) - } -} diff --git a/module/execute_bytes_test.go b/module/execute_bytes_test.go index 0adcec5..d078948 100644 --- a/module/execute_bytes_test.go +++ b/module/execute_bytes_test.go @@ -95,15 +95,18 @@ func TestExecuteNonCreateOutputBytesUnchanged(t *testing.T) { restore := mock.wireHostSeam() defer restore() - req, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{ProviderOperations: []providerOperation{tc.po}}, - Config: schemaPayload{Data: json.RawMessage(tc.config)}, - Binding: execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"}, - }) - out, err := Execute(nil, req) + // The step mapping is checked on the steps themselves (executeSteps), so a + // single DeleteVcn or ChangeVcnCompartment step can be exercised on its own; + // whether a plan may run at all is the gate's business (m4a_gate_test.go), + // and TestExecuteOutputOnlyAddsTheExecutionDigest ties the two together. + config, err := decodeObject(json.RawMessage(tc.config)) if err != nil { - t.Fatalf("Execute returned a Go error: %v", err) + t.Fatalf("config: %v", err) + } + out, err := okResult(executeSteps(resourceContracts()["cic:network:vcn"], []providerOperation{tc.po}, config, + execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"})) + if err != nil { + t.Fatalf("okResult returned a Go error: %v", err) } if string(out) != tc.want { t.Errorf("Execute output changed for a non-create operation.\n got: %s\nwant: %s", out, tc.want) diff --git a/module/execute_create_id_guest_test.go b/module/execute_create_id_guest_test.go index 5cf1883..78913ce 100644 --- a/module/execute_create_id_guest_test.go +++ b/module/execute_create_id_guest_test.go @@ -52,12 +52,7 @@ func execStepThroughWasm(t *testing.T, mock *cicFlowMock, po providerOperation, t.Helper() ctx, instance, callFn, allocateFn, deallocateFn := loadGuestWithCicFlow(t, mock) - reqJSON, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{Operation: "create", ProviderOperations: []providerOperation{po}}, - Config: schemaPayload{SchemaID: "cic:network:vcn-config", Encoding: encCanonicalJSON, Data: json.RawMessage(configJSON)}, - Binding: binding, - }) + reqJSON, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", po, configJSON, binding)) env := callOp(t, ctx, instance, callFn, allocateFn, deallocateFn, "execute", "{}", string(reqJSON)) if string(env.Error) != "null" { t.Fatalf("execute transport error = %s, want null", env.Error) diff --git a/module/execute_create_id_test.go b/module/execute_create_id_test.go index c61948d..d2bc915 100644 --- a/module/execute_create_id_test.go +++ b/module/execute_create_id_test.go @@ -42,12 +42,7 @@ func execVCN(t *testing.T, mock *cicFlowMock, po providerOperation, binding exec restore := mock.wireHostSeam() defer restore() - req, _ := json.Marshal(executeRequest{ - Kind: vcnKindForExec, - Plan: executionPlan{ProviderOperations: []providerOperation{po}}, - Config: schemaPayload{Data: json.RawMessage(configJSON)}, - Binding: binding, - }) + req, _ := json.Marshal(m4aForOp(t, vcnKindForExec, po, configJSON, binding)) out, err := Execute(nil, req) if err != nil { t.Fatalf("Execute returned a Go error: %v", err) @@ -242,19 +237,16 @@ func TestExecuteCreateRejectsAnAmbiguousBody(t *testing.T) { // stale and at worst someone else's resource: the step must report the answer's // id, never the binding's. func TestExecuteCreateIDComesFromTheResponseNotTheBinding(t *testing.T) { - const decoy = "ocid1.vcn.oc1..DECOY-FROM-THE-BINDING" const actual = "ocid1.vcn.oc1..theonejustcreated" mock := &cicFlowMock{Reply: replyWith(200, `{"id":"`+actual+`"}`, nil)} - binding := createBinding - binding.ResourceID = decoy - _, er := execVCN(t, mock, createVcnOp, binding, `{"compartmentId":"ocid1.compartment..c"}`) + _, er := execVCN(t, mock, createVcnOp, createBinding, `{"compartmentId":"ocid1.compartment..c"}`) if er.Status != "succeeded" || len(er.Steps) != 1 { t.Fatalf("execute = %+v, want succeeded with 1 step", er) } if got := er.Steps[0].ResourceID; got != actual { - t.Errorf("steps[0].resource_id = %q, want the response id %q (not the binding's %q)", got, actual, decoy) + t.Errorf("steps[0].resource_id = %q, want the response id %q", got, actual) } // ...and the create really did go to the collection path, with no id templated in. if url := mock.lastActuate(t).URL; !strings.HasSuffix(url, "/20160918/vcns") { @@ -262,6 +254,28 @@ func TestExecuteCreateIDComesFromTheResponseNotTheBinding(t *testing.T) { } } +// A binding that names a resource while the plan creates one is refused before anything +// is signed: a create has no id yet, so the only source of the created id is the answer. +func TestExecuteCreateRefusesABindingThatAlreadyNamesAResource(t *testing.T) { + const decoy = "ocid1.vcn.oc1..DECOY-FROM-THE-BINDING" + mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..theonejustcreated"}`, nil)} + restore := mock.wireHostSeam() + defer restore() + req := m4aForOp(t, vcnKindForExec, createVcnOp, `{"compartmentId":"ocid1.compartment..c"}`, createBinding) + req.Binding.ResourceID = decoy // changed by hand after the plan was built + raw, _ := json.Marshal(req) + out, err := Execute(nil, raw) + if err != nil { + t.Fatalf("Execute returned a Go error: %v", err) + } + if res := decodeResult(t, out); res.Status != "error" || res.Error == nil || res.Error.Class != classValidation { + t.Fatalf("status %q error %+v, want a validation error", res.Status, res.Error) + } + if mock.signCount() != 0 || mock.actuateCount() != 0 { + t.Errorf("%d sign / %d actuate calls, want none", mock.signCount(), mock.actuateCount()) + } +} + // TestExecuteFailedCreateIsNotRetried proves the absence of a retry. A create // that fails must leave exactly ONE provider request behind: a second attempt // would be a second VCN. diff --git a/module/execute_registry_conformance_test.go b/module/execute_registry_conformance_test.go index 05cca4c..db86e6f 100644 --- a/module/execute_registry_conformance_test.go +++ b/module/execute_registry_conformance_test.go @@ -173,35 +173,65 @@ func TestTheRefusalNamesTheFirstNonConformingStep(t *testing.T) { wantPlanRefusal(t, res, mock, 1) } -// An existing, valid plan keeps its result: every plannable operation of every kind, -// run with exactly the registry's method and path, still executes once and sends the -// request the registry describes. -func TestExecuteStillRunsEveryRegistryOperationWithItsOwnMethodAndPath(t *testing.T) { +// The registry check accepts every executable operation of every kind with its own +// method and path (a read is refused, see the test above). This is the check alone; +// which plans may run at all is the plan-binding gate's business (m4a_gate_test.go). +func TestRegistryCheckAcceptsEveryExecutableOperationWithItsOwnMethodAndPath(t *testing.T) { for _, kind := range sortedKinds() { c := resourceContracts()[kind] - var names []string - for n, o := range c.operations { - if o.role != roleRead { // a read is refused by Execute (D-M3-1), see its own test - names = append(names, n) + for name, op := range c.operations { + if op.role == roleRead { + continue } + if pe := checkPlanAgainstRegistry(c, []providerOperation{{Operation: name, Method: op.method, Path: op.path}}); pe != nil { + t.Errorf("%s/%s with its own method and path was refused: %+v", kind, name, pe) + } + } + } +} + +// Through Execute, every plan Plan can derive still runs, step by step, with exactly +// the method and URL the registry describes. +func TestExecuteStillRunsEveryDerivablePlanWithTheRegistrysMethodAndPath(t *testing.T) { + type derivable struct{ name, desired, observed string } + byKind := map[string][]derivable{ + "cic:network:vcn": { + {"create", `{"compartmentId":"ocid1.compartment..c"}`, `{}`}, + {"update", `{"displayName":"new"}`, `{"displayName":"old"}`}, + {"action", `{"compartmentId":"ocid1.compartment..b"}`, `{"compartmentId":"ocid1.compartment..a"}`}, + {"replace", `{"dnsLabel":"a"}`, `{"dnsLabel":"b"}`}, + }, + "cic:network:subnet": { + {"create", `{"compartmentId":"ocid1.compartment..c"}`, `{}`}, + {"update", `{"displayName":"new"}`, `{"displayName":"old"}`}, + {"action", `{"compartmentId":"ocid1.compartment..b"}`, `{"compartmentId":"ocid1.compartment..a"}`}, + }, + "cic:compute:instance": { + {"create", `{"compartmentId":"ocid1.compartment..c"}`, `{}`}, + {"update", `{"displayName":"new"}`, `{"displayName":"old"}`}, + {"action", `{"compartmentId":"ocid1.compartment..b"}`, `{"compartmentId":"ocid1.compartment..a"}`}, + }, + } + for _, kind := range sortedKinds() { + cases := byKind[kind] + if len(cases) == 0 { + t.Fatalf("%s has no derivable-plan case: the test would skip a kind", kind) } - sort.Strings(names) - for _, name := range names { - op := c.operations[name] - t.Run(kind+"/"+name, func(t *testing.T) { - reply := `{}` - if op.role == roleCreate { - reply = `{"id":"ocid1.` + kindType(kind) + `.oc1..created"}` + c := resourceContracts()[kind] + for _, d := range cases { + t.Run(kind+"/"+d.name, func(t *testing.T) { + b := bindingWith("ocid1." + kindType(kind) + ".oc1..conformance") + if d.name == "create" { + b.ResourceID = "" } - mock := &cicFlowMock{Reply: replyWith(200, reply, nil)} + req := m4aRequest(t, kind, d.desired, d.observed, b) + if len(req.Plan.ProviderOperations) == 0 { + t.Fatalf("test setup: Plan derived no operation (%s)", req.Plan.Operation) + } + mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.`+kindType(kind)+`.oc1..created"}`, nil)} restore := mock.wireHostSeam() defer restore() - out, err := Execute(nil, marshalReq(t, executeRequest{ - Kind: kind, - Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: name, Method: op.method, Path: op.path}}}, - Config: schemaPayload{Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..c"}`)}, - Binding: bindingWith("ocid1." + kindType(kind) + ".oc1..conformance"), - })) + out, err := Execute(nil, marshalReq(t, req)) if err != nil { t.Fatalf("Execute returned a Go error: %v", err) } @@ -209,16 +239,21 @@ func TestExecuteStillRunsEveryRegistryOperationWithItsOwnMethodAndPath(t *testin if res.Status != "ok" { t.Fatalf("status = %q, want ok (error: %+v)", res.Status, res.Error) } - if mock.signCount() != 1 || mock.actuateCount() != 1 { - t.Fatalf("%d sign / %d actuate calls, want exactly 1 each", mock.signCount(), mock.actuateCount()) - } - call := mock.lastActuate(t) - if call.Method != op.method { - t.Errorf("request method = %q, want the registry's %q", call.Method, op.method) + ops := req.Plan.ProviderOperations + if mock.signCount() != len(ops) || mock.actuateCount() != len(ops) { + t.Fatalf("%d sign / %d actuate calls, want %d each (one per step)", mock.signCount(), mock.actuateCount(), len(ops)) } - wantURL := "https://" + bindingHost + "/20160918" + templatePath(op.path, "ocid1."+kindType(kind)+".oc1..conformance") - if call.URL != wantURL { - t.Errorf("request URL = %q, want %q", call.URL, wantURL) + for i, po := range ops { + reg := c.operations[po.Operation] + call := mock.Actuated[i] + if call.Method != reg.method { + t.Errorf("step %d (%s): method = %q, want the registry's %q", i, po.Operation, call.Method, reg.method) + } + id := b.ResourceID + wantURL := "https://" + bindingHost + "/20160918" + templatePath(reg.path, id) + if call.URL != wantURL { + t.Errorf("step %d (%s): URL = %q, want %q", i, po.Operation, call.URL, wantURL) + } } }) } diff --git a/module/m4a_gate_test.go b/module/m4a_gate_test.go new file mode 100644 index 0000000..ae045fc --- /dev/null +++ b/module/m4a_gate_test.go @@ -0,0 +1,658 @@ +//go:build !wasip1 + +package main + +// m4a_gate_test.go pins the M4a plan-binding gate: what Execute requires of a request +// BEFORE it signs anything, and the three digests it rests on. +// +// The refusal tests do not build their bad request with the helper that builds good +// ones: they take a request Plan produced and change one thing in it by hand (a +// field, or the JSON text itself), so the gate is checked against what arrives, not +// against how the helper builds it. Each refusal is judged on the outcome only: the +// result status, the CIC error class, the field path, and zero sign / zero actuate +// calls at the host seam. All of this runs on the mocked host seam, never on a real +// provider. +// +// What M4a does NOT show is pinned too (TestM4aDoesNotClaimApproval, +// TestM4aDoesNotPreventReplay): a request that is consistent with itself is not an +// approved one, and the same one can run again. Those belong to M4b (the Relay). + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// ---- the digests: language-neutral vectors ---- + +type digestVectors struct { + Format string `json:"format"` + Plan []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Contract string `json:"contract"` + Operation string `json:"operation"` + Desired vecPayload + Observed vecPayload + ProviderOperations []providerOperation `json:"provider_operations"` + Envelope string `json:"envelope"` + Digest string `json:"digest"` + } `json:"plan"` + Target []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Host string `json:"host"` + BasePath string `json:"base_path"` + KeyID string `json:"key_id"` + ResourceID string `json:"resource_id"` + Revision string `json:"revision"` + Envelope string `json:"envelope"` + Digest string `json:"digest"` + } `json:"target"` + Execution []struct { + Name string `json:"name"` + PlanDigest string `json:"plan_digest"` + TargetDigest string `json:"target_digest"` + Envelope string `json:"envelope"` + Digest string `json:"digest"` + } `json:"execution"` +} + +type vecPayload struct { + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + SchemaHash string `json:"schema_hash"` + Encoding string `json:"encoding"` + Data string `json:"data"` +} + +func (v vecPayload) payload() schemaPayload { + return schemaPayload{SchemaID: v.SchemaID, SchemaVersion: v.SchemaVersion, SchemaHash: v.SchemaHash, Encoding: v.Encoding, Data: json.RawMessage(v.Data)} +} + +func loadDigestVectors(t *testing.T) digestVectors { + t.Helper() + raw, err := os.ReadFile("testdata/plan_digest_vectors.json") + if err != nil { + t.Fatalf("vectors: %v", err) + } + var v digestVectors + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("the vectors are not valid JSON: %v", err) + } + if v.Format != "cic-oracle-digests/1" || len(v.Plan) == 0 || len(v.Target) == 0 || len(v.Execution) == 0 { + t.Fatalf("vector file is empty or has the wrong format") + } + return v +} + +func TestDigestsMatchTheLanguageNeutralVectors(t *testing.T) { + v := loadDigestVectors(t) + for _, c := range v.Plan { + t.Run("plan/"+c.Name, func(t *testing.T) { + env, err := planEnvelope(c.Kind, c.Contract, c.Desired.payload(), c.Observed.payload(), c.Operation, c.ProviderOperations) + if err != nil { + t.Fatalf("envelope: %v", err) + } + if string(env) != c.Envelope { + t.Errorf("envelope\n got: %s\nwant: %s", env, c.Envelope) + } + if got := digestOf(env); got != c.Digest { + t.Errorf("digest = %s, want %s", got, c.Digest) + } + }) + } + for _, c := range v.Target { + t.Run("target/"+c.Name, func(t *testing.T) { + env := targetEnvelope(c.Kind, execBinding{Host: c.Host, BasePath: c.BasePath, KeyID: c.KeyID, ResourceID: c.ResourceID, Revision: c.Revision}) + if string(env) != c.Envelope { + t.Errorf("envelope\n got: %s\nwant: %s", env, c.Envelope) + } + if got := digestOf(env); got != c.Digest { + t.Errorf("digest = %s, want %s", got, c.Digest) + } + }) + } + for _, c := range v.Execution { + t.Run("execution/"+c.Name, func(t *testing.T) { + env := executionEnvelope(c.PlanDigest, c.TargetDigest) + if string(env) != c.Envelope { + t.Errorf("envelope\n got: %s\nwant: %s", env, c.Envelope) + } + if got := digestOf(env); got != c.Digest { + t.Errorf("digest = %s, want %s", got, c.Digest) + } + }) + } +} + +// The vectors are only useful if they separate what must be separate and join what +// must be joined; check the file itself says so. +func TestDigestVectorsSeparateAndJoinWhatTheyShould(t *testing.T) { + v := loadDigestVectors(t) + plan := map[string]string{} + for _, c := range v.Plan { + plan[c.Name] = c.Digest + } + target := map[string]string{} + for _, c := range v.Target { + target[c.Name] = c.Digest + } + same := func(m map[string]string, a, b string) { + t.Helper() + if m[a] == "" || m[b] == "" || m[a] != m[b] { + t.Errorf("%q and %q must have one digest (%s / %s)", a, b, m[a], m[b]) + } + } + differ := func(m map[string]string, a, b string) { + t.Helper() + if m[a] == "" || m[b] == "" || m[a] == m[b] { + t.Errorf("%q and %q must have different digests (%s / %s)", a, b, m[a], m[b]) + } + } + same(plan, "update plan", "same plan, other spelling of the JSON (key order, whitespace, escapes)") + same(plan, "numbers: 1, 1.0 and 1e0 are one value", "numbers: other spelling of the same values") + differ(plan, "2^53+1 is kept exactly", "2^53 differs from 2^53+1") + differ(plan, "update plan", "another contract digest") + differ(plan, "update plan", "another desired schema_version") + differ(plan, "update plan", "another observed encoding") + differ(plan, "update plan", "another observed schema_id") + differ(plan, "update plan", "another operation name, same ordered operations") + differ(plan, "two operations in one order", "the same two operations in the other order") + differ(plan, "boundary: kind AB + schema_id CD", "boundary: kind A + schema_id BCD") + differ(target, "boundary: host ab + base_path c", "boundary: host a + base_path bc") + for _, other := range []string{"other resource id", "other revision", "other key_id", "other host", "other base_path", "another kind"} { + differ(target, "full target", other) + } + // Every digest of one role differs from the digest of another role over the same text. + seen := map[string]string{} + for _, m := range []map[string]string{plan, target} { + for name, d := range m { + if prev, dup := seen[d]; dup && prev != name { + // the only allowed repeats are the ones asserted equal above + if !(strings.Contains(name, "other spelling") || strings.Contains(prev, "other spelling") || + strings.Contains(name, "update plan") || strings.Contains(prev, "update plan") || + strings.Contains(name, "numbers") || strings.Contains(prev, "numbers")) { + t.Errorf("%q and %q unexpectedly share a digest", prev, name) + } + } + seen[d] = name + } + } +} + +// ---- Plan: the lifecycle and the target ---- + +func TestPlanReturnsThreeDigestsAndNoLegacyPlanID(t *testing.T) { + p := m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, bindingWith("ocid1.vcn.oc1..a")) + for name, d := range map[string]string{"plan_digest": p.PlanDigest, "target_digest": p.TargetDigest, "execution_digest": p.ExecutionDigest} { + if len(d) != len("sha256:")+64 || !strings.HasPrefix(d, "sha256:") { + t.Errorf("%s = %q, want sha256:<64 hex>", name, d) + } + } + if p.PlanDigest == p.TargetDigest || p.PlanDigest == p.ExecutionDigest || p.TargetDigest == p.ExecutionDigest { + t.Errorf("the three digests must differ: %s %s %s", p.PlanDigest, p.TargetDigest, p.ExecutionDigest) + } + if env := executionEnvelope(p.PlanDigest, p.TargetDigest); digestOf(env) != p.ExecutionDigest { + t.Errorf("execution_digest is not the digest of {plan_digest, target_digest}") + } + raw, _ := json.Marshal(p) + if strings.Contains(string(raw), "plan_id") { + t.Errorf("a plan must not carry the retired plan_id: %s", raw) + } +} + +func TestPlanIsDeterministic(t *testing.T) { + first := m4aPlan(t, bindingKind, `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`, bindingWith("ocid1.vcn.oc1..a")) + for i := 0; i < 40; i++ { + again := m4aPlan(t, bindingKind, `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`, bindingWith("ocid1.vcn.oc1..a")) + a, _ := json.Marshal(first) + b, _ := json.Marshal(again) + if string(a) != string(b) { + t.Fatalf("Plan differs between runs:\n%s\n%s", a, b) + } + } +} + +func TestPlanLifecycleDecidesWhatTheTargetMustCarry(t *testing.T) { + plan := func(observed string, b execBinding) (providerResult, executionPlan) { + raw, _ := json.Marshal(planRequest{Kind: bindingKind, + Desired: m4aPayload("d", `{"compartmentId":"ocid1.compartment..c"}`), Observed: m4aPayload("o", observed), Target: b}) + out, _ := Plan(nil, raw) + res := decodeResult(t, out) + var p executionPlan + if res.Status == "ok" { + json.Unmarshal(res.Result, &p) + } + return res, p + } + t.Run("no state and no resource id is a create", func(t *testing.T) { + res, p := plan(`{}`, bindingWith("")) + if res.Status != "ok" || p.Operation != "create" || len(p.ProviderOperations) != 1 || p.ProviderOperations[0].Operation != "CreateVcn" { + t.Fatalf("got %q %+v (%+v), want a create plan with CreateVcn", res.Status, p, res.Error) + } + }) + refused := []struct { + name, observed string + b execBinding + field string + }{ + {"no state but a resource id", `{}`, bindingWith("ocid1.vcn.oc1..a"), "observed.data"}, + {"state but no resource id", `{"displayName":"x"}`, bindingWith(""), "target.resource_id"}, + {"state and a malformed resource id", `{"displayName":"x"}`, bindingWith("vcn-42"), "target.resource_id"}, + {"no host", `{"displayName":"x"}`, execBinding{KeyID: "k", ResourceID: "ocid1.vcn.oc1..a"}, "target.host"}, + {"no key_id", `{"displayName":"x"}`, execBinding{Host: "h", ResourceID: "ocid1.vcn.oc1..a"}, "target.key_id"}, + {"a host longer than the bound", `{"displayName":"x"}`, execBinding{Host: strings.Repeat("h", 2000), KeyID: "k", ResourceID: "ocid1.vcn.oc1..a"}, "target.host"}, + } + for _, c := range refused { + t.Run(c.name, func(t *testing.T) { + res, _ := plan(c.observed, c.b) + if res.Status != "error" || res.Error == nil || res.Error.Class != classValidation || res.Error.FieldPath != c.field { + t.Fatalf("status %q error %+v, want a validation error at %s", res.Status, res.Error, c.field) + } + }) + } +} + +// ---- Execute: the gate ---- + +// consistentUpdate returns a request that passes the gate, an update of displayName. +func consistentUpdate(t *testing.T) executeRequest { + t.Helper() + b := bindingWith("ocid1.vcn.oc1..approved") + b.Revision = "etag-approved" + return m4aRequest(t, bindingKind, `{"displayName":"approved-name"}`, `{"displayName":"old-name"}`, b) +} + +// consistentTwoStep is an update followed by an action: two provider operations. +func consistentTwoStep(t *testing.T) executeRequest { + t.Helper() + req := m4aRequest(t, bindingKind, + `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, + `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`, bindingWith("ocid1.vcn.oc1..approved")) + if len(req.Plan.ProviderOperations) != 2 { + t.Fatalf("test setup: want a 2-step plan, got %+v", req.Plan.ProviderOperations) + } + return req +} + +func runExecute(t *testing.T, req executeRequest) (providerResult, *cicFlowMock) { + t.Helper() + return runExecuteJSON(t, marshalReq(t, req)) +} + +func runExecuteJSON(t *testing.T, raw []byte) (providerResult, *cicFlowMock) { + t.Helper() + mock := &cicFlowMock{Reply: replyWith(200, `{}`, nil)} + restore := mock.wireHostSeam() + defer restore() + out, err := Execute(nil, raw) + if err != nil { + t.Fatalf("Execute returned a Go error: %v", err) + } + return decodeResult(t, out), mock +} + +func wantGateRefusal(t *testing.T, res providerResult, mock *cicFlowMock, field string) { + t.Helper() + if res.Status != "error" || res.Error == nil { + t.Fatalf("status = %q, want an error (the request was run)", res.Status) + } + if res.Error.Class != classValidation { + t.Errorf("error class = %q, want %q", res.Error.Class, classValidation) + } + if field != "" && res.Error.FieldPath != field { + t.Errorf("field_path = %q, want %q", res.Error.FieldPath, field) + } + if mock.signCount() != 0 || mock.actuateCount() != 0 { + t.Errorf("%d sign / %d actuate calls, want none: refuse before anything is signed or sent", mock.signCount(), mock.actuateCount()) + } +} + +func TestExecuteRunsAConsistentRequestAndEchoesTheExecutionDigest(t *testing.T) { + req := consistentUpdate(t) + res, mock := runExecute(t, req) + if res.Status != "ok" { + t.Fatalf("status = %q (%+v)", res.Status, res.Error) + } + if mock.signCount() != 1 || mock.actuateCount() != 1 { + t.Fatalf("%d sign / %d actuate calls, want 1 each", mock.signCount(), mock.actuateCount()) + } + var er executionResult + json.Unmarshal(res.Result, &er) + if er.ExecutionDigest != req.Plan.ExecutionDigest { + t.Errorf("execution_digest = %q, want the request's %q", er.ExecutionDigest, req.Plan.ExecutionDigest) + } +} + +// The step results are what they were; M4a adds only the digest to the output. +func TestExecuteOutputOnlyAddsTheExecutionDigest(t *testing.T) { + req := consistentUpdate(t) + mock := &cicFlowMock{Signature: "S", Reply: replyWith(200, "{}", map[string]string{"etag": "etag-1", "opc-request-id": "req-1"})} + restore := mock.wireHostSeam() + out, err := Execute(nil, marshalReq(t, req)) + restore() + if err != nil { + t.Fatal(err) + } + var wrapped struct { + Status string `json:"status"` + Result json.RawMessage `json:"result"` + } + json.Unmarshal(out, &wrapped) + var er executionResult + json.Unmarshal(wrapped.Result, &er) + er.ExecutionDigest = "" + + mock2 := &cicFlowMock{Signature: "S", Reply: replyWith(200, "{}", map[string]string{"etag": "etag-1", "opc-request-id": "req-1"})} + restore2 := mock2.wireHostSeam() + config, _ := decodeObject(req.Desired.Data) + want, _ := okResult(executeSteps(resourceContracts()[bindingKind], req.Plan.ProviderOperations, config, req.Binding)) + restore2() + got, _ := okResult(er) + if string(got) != string(want) { + t.Errorf("Execute's result minus execution_digest differs from the steps alone.\n got: %s\nwant: %s", got, want) + } +} + +// Every refusal below changes ONE thing in a request that Plan produced. +func TestExecuteRefusesARequestThatDoesNotMatchItsPlan(t *testing.T) { + otherID := "ocid1.vcn.oc1..someoneelse" + cases := []struct { + name string + base func(*testing.T) executeRequest + change func(*executeRequest) + field string + }{ + // config and states + {"config differs from desired", consistentUpdate, func(r *executeRequest) { r.Config.Data = json.RawMessage(`{"displayName":"EVIL-RENAME"}`) }, "config.data"}, + {"config is empty", consistentUpdate, func(r *executeRequest) { r.Config.Data = nil }, "config.data"}, + {"desired changed after the plan", consistentUpdate, func(r *executeRequest) { + r.Desired.Data = json.RawMessage(`{"displayName":"changed-after"}`) + r.Config.Data = r.Desired.Data + }, "plan.plan_digest"}, + {"observed changed after the plan", consistentUpdate, func(r *executeRequest) { r.Observed.Data = json.RawMessage(`{"displayName":"other-old"}`) }, "plan.plan_digest"}, + {"desired schema_version changed", consistentUpdate, func(r *executeRequest) { r.Desired.SchemaVersion = "v9" }, "plan.plan_digest"}, + {"observed encoding changed", consistentUpdate, func(r *executeRequest) { r.Observed.Encoding = "cbor" }, "plan.plan_digest"}, + {"desired missing", consistentUpdate, func(r *executeRequest) { r.Desired = schemaPayload{} }, "desired.data"}, + {"observed missing", consistentUpdate, func(r *executeRequest) { r.Observed = schemaPayload{} }, "observed.data"}, + // operations + {"an update replaced by an M3-valid delete", consistentUpdate, func(r *executeRequest) { + r.Plan.ProviderOperations = []providerOperation{{Operation: "DeleteVcn", Method: "DELETE", Path: "/vcns/{vcnId}"}} + }, "plan.provider_operations[0]"}, + {"operation field changed", consistentUpdate, func(r *executeRequest) { r.Plan.Operation = "replace" }, "plan.operation"}, + {"an operation appended", consistentUpdate, func(r *executeRequest) { + r.Plan.ProviderOperations = append(r.Plan.ProviderOperations, providerOperation{Operation: "DeleteVcn", Method: "DELETE", Path: "/vcns/{vcnId}"}) + }, "plan.provider_operations"}, + {"the operations removed", consistentUpdate, func(r *executeRequest) { r.Plan.ProviderOperations = nil }, "plan.provider_operations"}, + {"the order of two operations swapped", consistentTwoStep, func(r *executeRequest) { + ops := r.Plan.ProviderOperations + ops[0], ops[1] = ops[1], ops[0] + }, "plan.provider_operations[0]"}, + {"the second of two operations replaced", consistentTwoStep, func(r *executeRequest) { + r.Plan.ProviderOperations[1] = providerOperation{Operation: "DeleteVcn", Method: "DELETE", Path: "/vcns/{vcnId}"} + }, "plan.provider_operations[1]"}, + // target + {"resource_id changed", consistentUpdate, func(r *executeRequest) { r.Binding.ResourceID = otherID }, "plan.target_digest"}, + {"host changed", consistentUpdate, func(r *executeRequest) { r.Binding.Host = "iaas.us-ashburn-1.oraclecloud.com" }, "plan.target_digest"}, + {"base_path changed", consistentUpdate, func(r *executeRequest) { r.Binding.BasePath = "/20990101" }, "plan.target_digest"}, + {"key_id changed", consistentUpdate, func(r *executeRequest) { r.Binding.KeyID = "other/tenancy/key" }, "plan.target_digest"}, + {"revision changed", consistentUpdate, func(r *executeRequest) { r.Binding.Revision = "etag-STALE" }, "plan.target_digest"}, + {"revision removed", consistentUpdate, func(r *executeRequest) { r.Binding.Revision = "" }, "plan.target_digest"}, + // digests + {"plan_digest missing", consistentUpdate, func(r *executeRequest) { r.Plan.PlanDigest = "" }, "plan.plan_digest"}, + {"target_digest missing", consistentUpdate, func(r *executeRequest) { r.Plan.TargetDigest = "" }, "plan.target_digest"}, + {"execution_digest missing", consistentUpdate, func(r *executeRequest) { r.Plan.ExecutionDigest = "" }, "plan.execution_digest"}, + {"plan_digest nonsense", consistentUpdate, func(r *executeRequest) { r.Plan.PlanDigest = "sha256:not-a-hash" }, "plan.plan_digest"}, + {"target_digest nonsense", consistentUpdate, func(r *executeRequest) { r.Plan.TargetDigest = "x" }, "plan.target_digest"}, + {"execution_digest nonsense", consistentUpdate, func(r *executeRequest) { r.Plan.ExecutionDigest = "sha256:" + strings.Repeat("0", 64) }, "plan.execution_digest"}, + {"plan_digest and target_digest swapped", consistentUpdate, func(r *executeRequest) { + r.Plan.PlanDigest, r.Plan.TargetDigest = r.Plan.TargetDigest, r.Plan.PlanDigest + }, "plan.plan_digest"}, + {"the digests of another plan", consistentUpdate, func(r *executeRequest) { + o := m4aPlan(t, bindingKind, `{"displayName":"another"}`, `{"displayName":"old-name"}`, bindingWith("ocid1.vcn.oc1..approved")) + r.Plan.PlanDigest, r.Plan.ExecutionDigest = o.PlanDigest, o.ExecutionDigest + }, "plan.plan_digest"}, + // a valid plan paired with another, equally valid, target + {"a plan paired with another target's digests", consistentUpdate, func(r *executeRequest) { + other := bindingWith(otherID) + other.Revision = "etag-approved" + o := m4aPlan(t, bindingKind, `{"displayName":"approved-name"}`, `{"displayName":"old-name"}`, other) + r.Binding = other + r.Plan.TargetDigest = o.TargetDigest // the new target, with its own correct digest + // execution_digest is left as it was: it binds the plan to the ORIGINAL target + }, "plan.execution_digest"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := c.base(t) + c.change(&req) + res, mock := runExecute(t, req) + wantGateRefusal(t, res, mock, c.field) + }) + } +} + +func TestExecuteRefusesTheRetiredPlanID(t *testing.T) { + for _, legacy := range []string{`"sha256:abc"`, `""`, `null`} { + t.Run(legacy, func(t *testing.T) { + var m map[string]interface{} + json.Unmarshal(marshalReq(t, consistentUpdate(t)), &m) + m["plan"].(map[string]interface{})["plan_id"] = json.RawMessage(legacy) + raw, _ := json.Marshal(m) + res, mock := runExecuteJSON(t, raw) + wantGateRefusal(t, res, mock, "plan.plan_id") + }) + } +} + +// A plan with a bad step in the middle runs no step at all: the check is over the whole +// plan, not step by step. +func TestAMultiStepPlanWithOneChangedStepRunsNoStep(t *testing.T) { + for _, pos := range []int{0, 1} { + t.Run(strings.Repeat("x", pos)+"changed step", func(t *testing.T) { + req := consistentTwoStep(t) + req.Plan.ProviderOperations[pos] = providerOperation{Operation: "DeleteVcn", Method: "DELETE", Path: "/vcns/{vcnId}"} + res, mock := runExecute(t, req) + wantGateRefusal(t, res, mock, "") + }) + } +} + +// The request is edited as TEXT, the way a caller (or an attacker) would: no struct, no +// helper between the edit and Execute. +func TestExecuteRefusesAHandEditedRequestText(t *testing.T) { + good := string(marshalReq(t, consistentUpdate(t))) + edits := []struct{ name, from, to, field string }{ + {"config renamed in the text", `"config":{"schema_id":"cic:network:vcn-config","schema_version":"v0.1.0","schema_hash":"abc123","encoding":"canonical-json","data":{"displayName":"approved-name"}}`, + `"config":{"schema_id":"cic:network:vcn-config","schema_version":"v0.1.0","schema_hash":"abc123","encoding":"canonical-json","data":{"displayName":"X"}}`, "config.data"}, + {"resource id in the text", `"resource_id":"ocid1.vcn.oc1..approved"`, `"resource_id":"ocid1.vcn.oc1..approveX"`, "plan.target_digest"}, + {"method of the step in the text", `"method":"PUT"`, `"method":"DELETE"`, "plan.provider_operations[0]"}, + {"path of the step in the text", `"path":"/vcns/{vcnId}"`, `"path":"/vcns"`, "plan.provider_operations[0]"}, + } + for _, e := range edits { + t.Run(e.name, func(t *testing.T) { + if !strings.Contains(good, e.from) { + t.Fatalf("test setup: %q is not in the request text:\n%s", e.from, good) + } + res, mock := runExecuteJSON(t, []byte(strings.Replace(good, e.from, e.to, 1))) + wantGateRefusal(t, res, mock, e.field) + }) + } +} + +// The same intent and state written differently produce the same plan_digest, and a +// request that spells them differently from the one the plan was made from still runs. +func TestEquivalentSpellingsOfDesiredAndObservedGiveOnePlanDigest(t *testing.T) { + b := bindingWith("ocid1.vcn.oc1..a") + spellings := []struct{ name, desired, observed string }{ + {"plain", `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`}, + {"key order", `{"compartmentId":"ocid1.compartment..b","displayName":"new"}`, `{"compartmentId":"ocid1.compartment..a","displayName":"old"}`}, + {"whitespace", ` { "displayName" : "new" , "compartmentId" : "ocid1.compartment..b" } `, "{\n\t\"displayName\": \"old\",\n\t\"compartmentId\": \"ocid1.compartment..a\"\n}"}, + {"escapes", `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`}, + } + want := m4aPlan(t, bindingKind, spellings[0].desired, spellings[0].observed, b) + for _, s := range spellings[1:] { + t.Run(s.name, func(t *testing.T) { + got := m4aPlan(t, bindingKind, s.desired, s.observed, b) + if got.PlanDigest != want.PlanDigest || got.ExecutionDigest != want.ExecutionDigest { + t.Errorf("plan_digest %s, want %s: the same state spelled differently must give one digest", got.PlanDigest, want.PlanDigest) + } + // the request made with one spelling runs when it carries another one + req := m4aRequest(t, bindingKind, spellings[0].desired, spellings[0].observed, b) + req.Desired.Data = json.RawMessage(s.desired) + req.Observed.Data = json.RawMessage(s.observed) + req.Config.Data = json.RawMessage(s.desired) + res, mock := runExecute(t, req) + if res.Status != "ok" || mock.actuateCount() != 2 { + t.Errorf("status %q with %d requests (%+v), want ok with 2", res.Status, mock.actuateCount(), res.Error) + } + }) + } + t.Run("numbers", func(t *testing.T) { + a := m4aPlan(t, "cic:compute:instance", `{"shapeConfig":{"ocpus":1}}`, `{"shapeConfig":{"ocpus":2}}`, bindingWith("ocid1.instance..a")) + c := m4aPlan(t, "cic:compute:instance", `{"shapeConfig":{"ocpus":1.0}}`, `{"shapeConfig":{"ocpus":2e0}}`, bindingWith("ocid1.instance..a")) + d := m4aPlan(t, "cic:compute:instance", `{"shapeConfig":{"ocpus":10e-1}}`, `{"shapeConfig":{"ocpus":2.00}}`, bindingWith("ocid1.instance..a")) + if a.PlanDigest != c.PlanDigest || a.PlanDigest != d.PlanDigest { + t.Errorf("1, 1.0 and 10e-1 must give one plan_digest: %s %s %s", a.PlanDigest, c.PlanDigest, d.PlanDigest) + } + }) +} + +func TestEveryRelevantInputChangesItsDigest(t *testing.T) { + b := bindingWith("ocid1.vcn.oc1..a") + base := m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, b) + t.Run("desired data", func(t *testing.T) { + if o := m4aPlan(t, bindingKind, `{"displayName":"newer"}`, `{"displayName":"old"}`, b); o.PlanDigest == base.PlanDigest { + t.Error("plan_digest did not change") + } + }) + t.Run("observed data", func(t *testing.T) { + if o := m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"older"}`, b); o.PlanDigest == base.PlanDigest { + t.Error("plan_digest did not change") + } + }) + t.Run("the operation (a second changed field)", func(t *testing.T) { + if o := m4aPlan(t, bindingKind, `{"displayName":"new","compartmentId":"ocid1.compartment..b"}`, `{"displayName":"old","compartmentId":"ocid1.compartment..a"}`, b); o.PlanDigest == base.PlanDigest { + t.Error("plan_digest did not change") + } + }) + for name, change := range map[string]func(*execBinding){ + "host": func(x *execBinding) { x.Host = "other.example" }, + "base_path": func(x *execBinding) { x.BasePath = "/20990101" }, + "key_id": func(x *execBinding) { x.KeyID = "other/key" }, + "resource_id": func(x *execBinding) { x.ResourceID = "ocid1.vcn.oc1..b" }, + "revision": func(x *execBinding) { x.Revision = "etag-9" }, + } { + t.Run("target "+name, func(t *testing.T) { + nb := b + change(&nb) + o := m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, nb) + if o.TargetDigest == base.TargetDigest { + t.Error("target_digest did not change") + } + if o.PlanDigest != base.PlanDigest { + t.Error("plan_digest must not depend on the target") + } + if o.ExecutionDigest == base.ExecutionDigest { + t.Error("execution_digest did not change") + } + }) + } + t.Run("an absent revision and an empty one are the same", func(t *testing.T) { + x := b + x.Revision = "" + y := b + if m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, x).TargetDigest != + m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, y).TargetDigest { + // y has a revision? bindingWith sets none, so both are empty: this documents the rule + t.Error("absent and empty must be one value") + } + }) + t.Run("the contract of the kind", func(t *testing.T) { + c := resourceContracts()[bindingKind] + d1 := digestOf(contractEnvelope(bindingKind, c, true)) + changed := c + changed.operations = map[string]httpOp{} + for k, v := range c.operations { + changed.operations[k] = v + } + op := changed.operations["UpdateVcn"] + op.method = "PATCH" + changed.operations["UpdateVcn"] = op + if d2 := digestOf(contractEnvelope(bindingKind, changed, true)); d2 == d1 { + t.Error("a changed registry operation did not change the contract digest") + } + }) +} + +func TestDigestInputsAreNotAmbiguousAtFieldBoundaries(t *testing.T) { + d := func(sid string) schemaPayload { return m4aPayload(sid, `{}`) } + a, _ := planEnvelope("AB", "c", d("CD"), d("o"), "noop", nil) + b, _ := planEnvelope("A", "c", d("BCD"), d("o"), "noop", nil) + if digestOf(a) == digestOf(b) { + t.Error("moving bytes from kind to schema_id must change the plan digest") + } + x := digestOf(targetEnvelope("k", execBinding{Host: "ab", BasePath: "c", KeyID: "k"})) + y := digestOf(targetEnvelope("k", execBinding{Host: "a", BasePath: "bc", KeyID: "k"})) + if x == y { + t.Error("moving bytes from host to base_path must change the target digest") + } +} + +// A noop plan has nothing to run: it is derived, digested and accepted like any other, +// and runs no step. +func TestExecuteOfANoopPlanRunsNoStep(t *testing.T) { + req := m4aRequest(t, bindingKind, `{"displayName":"same"}`, `{"displayName":"same"}`, bindingWith("ocid1.vcn.oc1..a")) + if req.Plan.Operation != "noop" { + t.Fatalf("test setup: plan = %q", req.Plan.Operation) + } + res, mock := runExecute(t, req) + if res.Status != "ok" || mock.signCount() != 0 || mock.actuateCount() != 0 { + t.Fatalf("status %q, %d sign / %d actuate (%+v), want ok and nothing sent", res.Status, mock.signCount(), mock.actuateCount(), res.Error) + } + var er executionResult + json.Unmarshal(res.Result, &er) + if er.ExecutionDigest != req.Plan.ExecutionDigest || len(er.Steps) != 0 { + t.Errorf("result %+v, want the execution digest echoed and no steps", er) + } +} + +// ---- what M4a does not show ---- + +// A request that agrees with itself is not an approved one. A caller who writes the +// desired state, the observed state and the target, asks Plan, and sends the result, +// is accepted: M4a checks consistency, never approval. The approval is the Relay's +// (M4b); when it exists this test is the one to turn around. +func TestM4aDoesNotClaimApproval(t *testing.T) { + // Nothing here was approved by anyone: the caller invented every input. + req := m4aRequest(t, bindingKind, `{"displayName":"invented-by-the-caller"}`, `{"displayName":"whatever-the-caller-says"}`, bindingWith("ocid1.vcn.oc1..anything")) + res, mock := runExecute(t, req) + if res.Status != "ok" || mock.actuateCount() != 1 { + t.Fatalf("status %q with %d requests (%+v): this test records that M4a runs a self-consistent, unapproved request", res.Status, mock.actuateCount(), res.Error) + } +} + +// The same consistent request runs every time it is sent: the module keeps no state +// between calls, so it cannot tell a first execution from a repeat. Single use is the +// Relay's (M4b). This records the absence of that protection; it is not a property to +// keep. +func TestM4aDoesNotPreventReplay(t *testing.T) { + req := consistentUpdate(t) + mock := &cicFlowMock{Reply: replyWith(200, `{}`, nil)} + restore := mock.wireHostSeam() + defer restore() + for i := 1; i <= 3; i++ { + out, err := Execute(nil, marshalReq(t, req)) + if err != nil { + t.Fatal(err) + } + if res := decodeResult(t, out); res.Status != "ok" { + t.Fatalf("run %d: status %q (%+v)", i, res.Status, res.Error) + } + } + if mock.actuateCount() != 3 { + t.Fatalf("%d provider requests after 3 identical executions, want 3: M4a does not stop a repeat", mock.actuateCount()) + } +} diff --git a/module/m4a_helpers_test.go b/module/m4a_helpers_test.go new file mode 100644 index 0000000..4da0fa4 --- /dev/null +++ b/module/m4a_helpers_test.go @@ -0,0 +1,88 @@ +//go:build !wasip1 + +package main + +// m4a_helpers_test.go builds Execute requests that pass the M4a gate by asking the +// real Plan for them, never by writing digests by hand. A test of the GATE does not +// use these to build the request it expects to be refused: it changes the built +// request (or writes a whole one) directly, so the gate is checked against the +// request that arrives, not against how the helper builds it. + +import ( + "encoding/json" + "testing" +) + +const m4aHash = "abc123" + +func m4aPayload(id, data string) schemaPayload { + return schemaPayload{SchemaID: id, SchemaVersion: "v0.1.0", SchemaHash: m4aHash, Encoding: encCanonicalJSON, Data: json.RawMessage(data)} +} + +// m4aPlan asks Plan for the plan of desired/observed on binding b. +func m4aPlan(t *testing.T, kind, desired, observed string, b execBinding) executionPlan { + t.Helper() + raw, _ := json.Marshal(planRequest{ + Kind: kind, + Desired: m4aPayload(kind+"-config", desired), + Observed: m4aPayload(kind+"-state", observed), + Target: b, + }) + out, err := Plan(nil, raw) + if err != nil { + t.Fatalf("Plan transport error: %v", err) + } + res := decodeResult(t, out) + if res.Status != "ok" { + t.Fatalf("Plan status = %q, want ok (error: %+v)", res.Status, res.Error) + } + var p executionPlan + if err := json.Unmarshal(res.Result, &p); err != nil { + t.Fatalf("plan result: %v", err) + } + return p +} + +// m4aRequest is the consistent Execute request for desired/observed on binding b: the +// plan Plan returned, the two states it came from, and desired as the config. +func m4aRequest(t *testing.T, kind, desired, observed string, b execBinding) executeRequest { + t.Helper() + return executeRequest{ + Kind: kind, + Plan: m4aPlan(t, kind, desired, observed, b), + Desired: m4aPayload(kind+"-config", desired), + Observed: m4aPayload(kind+"-state", observed), + Config: m4aPayload(kind+"-config", desired), + Binding: b, + } +} + +// m4aForOp builds the consistent request whose plan is exactly the one provider +// operation po, so a test about one operation's outcome (a create answer, an update's +// If-Match) can still run through the whole gate. The observed state is chosen from +// the operation's role: none for a create (and no resource id then), a different value +// for every field of desired otherwise. If Plan does not derive exactly [po] the setup +// is wrong and the test stops, so the helper can never quietly test another operation. +func m4aForOp(t *testing.T, kind string, po providerOperation, desired string, b execBinding) executeRequest { + t.Helper() + observed := `{}` + if resourceContracts()[kind].operations[po.Operation].role == roleCreate { + b.ResourceID = "" + } else { + var d map[string]json.RawMessage + if err := json.Unmarshal([]byte(desired), &d); err != nil { + t.Fatalf("desired is not an object: %v", err) + } + o := map[string]string{} + for k := range d { + o[k] = "observed-before" + } + raw, _ := json.Marshal(o) + observed = string(raw) + } + req := m4aRequest(t, kind, desired, observed, b) + if ops := req.Plan.ProviderOperations; len(ops) != 1 || ops[0].Operation != po.Operation || ops[0].Method != po.Method || ops[0].Path != po.Path { + t.Fatalf("test setup: Plan derived %+v, the test wants exactly [%+v]", ops, po) + } + return req +} diff --git a/module/manual_real_oci_test.go b/module/manual_real_oci_test.go index fb397a0..d1c8138 100644 --- a/module/manual_real_oci_test.go +++ b/module/manual_real_oci_test.go @@ -336,6 +336,7 @@ func TestManualRealOCIPlan(t *testing.T) { Kind: kind, Desired: payloadFor(kind, desiredData), Observed: observedPayload, + Target: obsReq.Binding, } planReqJSON, _ := json.Marshal(planReq) planResultJSON, err := Plan(nil, planReqJSON) @@ -371,23 +372,30 @@ func ociHost(region string) string { return fmt.Sprintf("iaas.%s.%s", region, domain) } -// TestManualRealOCIExecute runs ONE approved provider_operation for real — -// e.g. CreateVcn or UpdateVcn — against a live tenancy. Unlike Observe/ -// Validate/Plan, this MUTATES real OCI state: it creates, changes, or (via -// Delete) deletes whatever OCI_EXEC_OPERATION/METHOD/PATH describe. -// Never run this without knowing exactly which tenancy/compartment/resource -// it targets — see docs/design/manual-verification.md. +// TestManualRealOCIExecute runs a plan for real — a create, an update or an action — +// against a live tenancy. Unlike Observe/Validate/Plan, this MUTATES real OCI state. +// Never run this without knowing exactly which tenancy/compartment/resource it +// targets — see docs/design/manual-verification.md. +// +// Execute only runs a plan it can derive again from desired and observed state +// (docs/design/specs/plan-digest.md), so this harness does what a caller does: it +// takes the observed state (Observe, for an existing resource; none for a create), +// asks Plan for the plan of the desired config on this target, and sends Plan's +// answer to Execute. Which operations run is therefore decided by the difference +// between the two states, not by operation/method/path variables. // // Config (env): // -// OCI_EXEC_OPERATION e.g. CreateVcn, UpdateVcn, DeleteVcn (registry name) -// OCI_EXEC_METHOD e.g. POST, PUT, DELETE -// OCI_EXEC_PATH e.g. /vcns or /vcns/{vcnId} (resourceID fills {..}) -// OCI_EXEC_CONFIG_JSON the config body fields as a JSON object (renderBody -// filters it per the op: Create keeps all contract -// fields present, Update keeps only "mutable" ones) -// OCI_TEST_RESOURCE_ID required for Update/Delete paths with a {param}; -// leave unset for Create (the path has none to fill) +// OCI_EXEC_CONFIG_JSON the desired config as a JSON object (renderBody filters it +// per operation: a create keeps all contract fields present, +// an update keeps only "mutable" ones) +// OCI_TEST_RESOURCE_ID the existing resource to update; leave unset for a create +// OCI_EXEC_OPERATION optional: the operation the plan is expected to START with +// (e.g. CreateVcn, UpdateVcn); the run is refused before +// Execute if Plan derives another first operation +// +// OCI_EXEC_METHOD and OCI_EXEC_PATH are no longer read: a plan cannot choose its own +// method or path (they come from the registry). A lone Delete is Destroy's job. func TestManualRealOCIExecute(t *testing.T) { if os.Getenv("REAL_OCI_TEST") == "" { t.Skip("set REAL_OCI_TEST=1 to run against real OCI") @@ -398,16 +406,14 @@ func TestManualRealOCIExecute(t *testing.T) { user := os.Getenv("OCI_USER_OCID") fingerprint := os.Getenv("OCI_FINGERPRINT") region := os.Getenv("OCI_REGION") - resourceID := os.Getenv("OCI_TEST_RESOURCE_ID") // may be "" for Create + resourceID := os.Getenv("OCI_TEST_RESOURCE_ID") // "" for a create kind := os.Getenv("OCI_TEST_KIND") operation := os.Getenv("OCI_EXEC_OPERATION") - method := os.Getenv("OCI_EXEC_METHOD") - path := os.Getenv("OCI_EXEC_PATH") configJSON := os.Getenv("OCI_EXEC_CONFIG_JSON") if keyPath == "" || tenancy == "" || user == "" || fingerprint == "" || region == "" || - kind == "" || operation == "" || method == "" || path == "" || configJSON == "" { + kind == "" || configJSON == "" { t.Fatal("OCI_KEY_PATH, OCI_TENANCY_OCID, OCI_USER_OCID, OCI_FINGERPRINT, OCI_REGION, " + - "OCI_TEST_KIND, OCI_EXEC_OPERATION, OCI_EXEC_METHOD, OCI_EXEC_PATH, OCI_EXEC_CONFIG_JSON must all be set") + "OCI_TEST_KIND, OCI_EXEC_CONFIG_JSON must all be set") } if !json.Valid([]byte(configJSON)) { t.Fatalf("OCI_EXEC_CONFIG_JSON is not valid JSON: %q", configJSON) @@ -416,20 +422,64 @@ func TestManualRealOCIExecute(t *testing.T) { rsaKey := loadRSAKey(t, keyPath) wireRealHostCalls(rsaKey) + binding := execBinding{ + Host: ociHost(region), + BasePath: "/20160918", + KeyID: tenancy + "/" + user + "/" + fingerprint, + ResourceID: resourceID, + } + + // The observed state: none for a create, Observe's answer for an existing resource. + observed := payloadFor(kind, json.RawMessage(`{}`)) + if resourceID != "" { + obsJSON, _ := json.Marshal(observeRequest{Kind: kind, Binding: binding}) + obsOut, err := Observe(nil, obsJSON) + if err != nil { + t.Fatalf("Observe returned Go error: %v", err) + } + var obs struct { + Status string `json:"status"` + Result struct { + EffectiveConfig json.RawMessage `json:"effective_config"` + ProviderMetadata struct { + Etag string `json:"etag"` + } `json:"provider_metadata"` + } `json:"result"` + } + if err := json.Unmarshal(obsOut, &obs); err != nil || obs.Status != "ok" { + t.Fatalf("observe failed: %s (err=%v)", obsOut, err) + } + observed = payloadFor(kind, obs.Result.EffectiveConfig) + if binding.Revision == "" { + binding.Revision = obs.Result.ProviderMetadata.Etag + } + } + + desired := payloadFor(kind, json.RawMessage(configJSON)) + planJSON, _ := json.Marshal(planRequest{Kind: kind, Desired: desired, Observed: observed, Target: binding}) + planOut, err := Plan(nil, planJSON) + if err != nil { + t.Fatalf("Plan returned Go error: %v", err) + } + var planned struct { + Status string `json:"status"` + Result executionPlan `json:"result"` + } + if err := json.Unmarshal(planOut, &planned); err != nil || planned.Status != "ok" { + t.Fatalf("plan failed: %s (err=%v)", planOut, err) + } + fmt.Printf("=== Plan (%s) ===\n%s\n", planned.Result.Operation, planOut) + if operation != "" && (len(planned.Result.ProviderOperations) == 0 || planned.Result.ProviderOperations[0].Operation != operation) { + t.Fatalf("Plan derived %+v, the run expects it to start with %s: refusing to execute", planned.Result.ProviderOperations, operation) + } + execReq := executeRequest{ - Kind: kind, - Plan: executionPlan{ - ProviderOperations: []providerOperation{ - {Operation: operation, Method: method, Path: path}, - }, - }, - Config: payloadFor(kind, json.RawMessage(configJSON)), - Binding: execBinding{ - Host: ociHost(region), - BasePath: "/20160918", - KeyID: tenancy + "/" + user + "/" + fingerprint, - ResourceID: resourceID, - }, + Kind: kind, + Plan: planned.Result, + Desired: desired, + Observed: observed, + Config: desired, + Binding: binding, } execReqJSON, _ := json.Marshal(execReq) @@ -437,7 +487,7 @@ func TestManualRealOCIExecute(t *testing.T) { if err != nil { t.Fatalf("Execute returned Go error: %v", err) } - fmt.Printf("=== Execute result (%s) ===\n%s\n", operation, resultJSON) + fmt.Printf("=== Execute result (%s) ===\n%s\n", planned.Result.Operation, resultJSON) var wrapper struct { Status string `json:"status"` diff --git a/module/module_loadtest_test.go b/module/module_loadtest_test.go index ed2d898..eef046a 100644 --- a/module/module_loadtest_test.go +++ b/module/module_loadtest_test.go @@ -359,15 +359,10 @@ func TestHostLoadExecute(t *testing.T) { ctx, instance, callFn, allocateFn, deallocateFn := loadGuestWithCicFlow(t, mock) // An approved plan (as `plan` would emit) + desired config + binding. - exReq := executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{ - Operation: "update", - ProviderOperations: []providerOperation{{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}}, - }, - Config: schemaPayload{SchemaID: "cic:network:vcn-config", Encoding: encCanonicalJSON, Data: json.RawMessage(`{"displayName":"prod-vcn"}`)}, - Binding: execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", KeyID: "tenancy/user/fp", ResourceID: "ocid1.vcn.oc1..xyz"}, - } + exReq := m4aForOp(t, "cic:network:vcn", + providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, + `{"displayName":"prod-vcn"}`, + execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", KeyID: "tenancy/user/fp", ResourceID: "ocid1.vcn.oc1..xyz"}) reqJSON, _ := json.Marshal(exReq) env := callOp(t, ctx, instance, callFn, allocateFn, deallocateFn, "execute", "{}", string(reqJSON)) diff --git a/module/plan_binding.go b/module/plan_binding.go new file mode 100644 index 0000000..461fa57 --- /dev/null +++ b/module/plan_binding.go @@ -0,0 +1,210 @@ +package main + +// plan_binding.go is the M4a plan-binding model: three digests over canonical JSON +// (cic-canonical-json/1) and the derivation Execute repeats before it signs anything. +// The shape of every digest input is in docs/design/specs/plan-digest.md and its +// expected values are in testdata/plan_digest_vectors.json, so another implementation +// can compute the same digests: Go is one implementation of that specification. +// +// What this proves, and what it does not: a request whose plan, desired state, +// observed state and target agree with each other, and with the plan the provider +// derives from them, is internally consistent. It does not prove that anyone approved +// it, that the caller may run it, or that it was not run before; a caller who can +// change every input and every digest builds another consistent request. Approval, +// authority and single use are the Relay's (M4b). + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" +) + +// Domain tags. Each digest has its own, so objects of different roles never share a +// hash namespace, and a new definition changes the tag. +const ( + planDigestTag = "cic-oracle-execution-plan/1" + targetDigestTag = "cic-oracle-execution-target/1" + executionDigestTag = "cic-oracle-execution/1" +) + +// canonical JSON builders: the digest input is itself canonical JSON text, so every +// field is delimited by the JSON structure (quotes, brackets, commas), never by plain +// concatenation of strings. +type jmember struct { + key string + val []byte +} + +func jstr(s string) []byte { return canonString(nil, []byte(s)) } + +// jnullable writes an empty value as null: a field that is absent and a field that is +// the empty string are one thing in the digest, by rule. +func jnullable(s string) []byte { + if s == "" { + return []byte("null") + } + return jstr(s) +} + +func jobj(ms ...jmember) []byte { + sort.Slice(ms, func(i, j int) bool { return ms[i].key < ms[j].key }) + out := []byte{'{'} + for i, m := range ms { + if i > 0 { + out = append(out, ',') + } + out = append(out, jstr(m.key)...) + out = append(out, ':') + out = append(out, m.val...) + } + return append(out, '}') +} + +func jarr(vs ...[]byte) []byte { + out := []byte{'['} + for i, v := range vs { + if i > 0 { + out = append(out, ',') + } + out = append(out, v...) + } + return append(out, ']') +} + +func digestOf(envelope []byte) string { + sum := sha256.Sum256(envelope) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// contractEnvelope is the canonical form of everything in a kind's registry that +// shapes a plan or an execution: its operations (name, method, path template, role) +// and its fields (policy, type, action). A module version that maps a change to other +// operations has another contract digest, so an older plan no longer matches. +func contractEnvelope(kind string, c resourceContract, known bool) []byte { + if !known { + return jobj(jmember{"kind", jstr(kind)}, jmember{"known", []byte("false")}) + } + var ops []jmember + for name, o := range c.operations { + ops = append(ops, jmember{name, jobj( + jmember{"method", jstr(o.method)}, jmember{"path", jstr(o.path)}, jmember{"role", jstr(o.role)})}) + } + var fields []jmember + for name, f := range c.fields { + fields = append(fields, jmember{name, jobj( + jmember{"action", jnullable(f.action)}, jmember{"json_type", jnullable(f.jsonType)}, jmember{"policy", jstr(f.policy)})}) + } + req := append([]string(nil), c.required...) + sort.Strings(req) + var reqs [][]byte + for _, r := range req { + reqs = append(reqs, jstr(r)) + } + return jobj( + jmember{"fields", jobj(fields...)}, + jmember{"kind", jstr(kind)}, + jmember{"known", []byte("true")}, + jmember{"operations", jobj(ops...)}, + jmember{"required", jarr(reqs...)}, + ) +} + +func contractDigest(kind string) string { + c, ok := resourceContracts()[kind] + return digestOf(contractEnvelope(kind, c, ok)) +} + +func payloadEnvelope(p schemaPayload) ([]byte, error) { + data, err := canonicalJSON(p.Data) + if err != nil { + return nil, err + } + return jobj( + jmember{"data", data}, + jmember{"encoding", jstr(p.Encoding)}, + jmember{"schema_hash", jstr(p.SchemaHash)}, + jmember{"schema_id", jstr(p.SchemaID)}, + jmember{"schema_version", jstr(p.SchemaVersion)}, + ), nil +} + +// planEnvelope is the input of plan_digest. provider_operations keep their order; +// their reason text and the plan's notes are explanations nothing executes, so they +// are not part of it (and can change without changing the digest, on purpose). +func planEnvelope(kind, contract string, desired, observed schemaPayload, operation string, ops []providerOperation) ([]byte, error) { + d, err := payloadEnvelope(desired) + if err != nil { + return nil, fmt.Errorf("desired: %w", err) + } + o, err := payloadEnvelope(observed) + if err != nil { + return nil, fmt.Errorf("observed: %w", err) + } + var po [][]byte + for _, op := range ops { + po = append(po, jobj(jmember{"method", jstr(op.Method)}, jmember{"operation", jstr(op.Operation)}, jmember{"path", jstr(op.Path)})) + } + return jobj( + jmember{"contract", jstr(contract)}, + jmember{"desired", d}, + jmember{"kind", jstr(kind)}, + jmember{"observed", o}, + jmember{"operation", jstr(operation)}, + jmember{"provider_operations", jarr(po...)}, + jmember{"v", jstr(planDigestTag)}, + ), nil +} + +// targetEnvelope is the input of target_digest: where the plan runs. host and key_id +// are always strings; base_path, resource_id and revision are null when empty. +func targetEnvelope(kind string, b execBinding) []byte { + return jobj( + jmember{"base_path", jnullable(b.BasePath)}, + jmember{"host", jstr(b.Host)}, + jmember{"key_id", jstr(b.KeyID)}, + jmember{"kind", jstr(kind)}, + jmember{"resource_id", jnullable(b.ResourceID)}, + jmember{"revision", jnullable(b.Revision)}, + jmember{"v", jstr(targetDigestTag)}, + ) +} + +// executionEnvelope binds the plan and the target in one value: the one an approval +// (M4b) names, so a plan cannot be paired with another, equally valid, target. +func executionEnvelope(planDigest, targetDigest string) []byte { + return jobj( + jmember{"plan_digest", jstr(planDigest)}, + jmember{"target_digest", jstr(targetDigest)}, + jmember{"v", jstr(executionDigestTag)}, + ) +} + +// Bounds on the free-text target fields: they end up in a signed request. +const maxTargetField = 1024 + +// checkTarget is the STRUCTURE of a target: host and key_id present, every field +// bounded, resource_id (when there is one) a well-formed OCID of the kind. Whether a +// resource id is REQUIRED is the lifecycle's business (see planFromRequest), not this +// function's. field names the object in a refusal ("target" for plan, "binding" for +// execute). +func checkTarget(kind string, b execBinding, field string) *providerError { + bad := func(name, why string) *providerError { + return &providerError{Class: classValidation, FieldPath: field + "." + name, Message: field + "." + name + " " + why} + } + if b.Host == "" { + return bad("host", "is required") + } + if b.KeyID == "" { + return bad("key_id", "is required") + } + for name, v := range map[string]string{"host": b.Host, "base_path": b.BasePath, "key_id": b.KeyID, "resource_id": b.ResourceID, "revision": b.Revision} { + if len(v) > maxTargetField { + return bad(name, fmt.Sprintf("is longer than %d bytes", maxTargetField)) + } + } + if b.ResourceID != "" && !wellFormedOCID(kind, b.ResourceID) { + return bad("resource_id", "is not a well-formed OCID of the type for "+kind+" (got \""+shortASCII(b.ResourceID)+"\")") + } + return nil +} diff --git a/module/plan_noop_exactness_test.go b/module/plan_noop_exactness_test.go index 2bf9ea4..4dba931 100644 --- a/module/plan_noop_exactness_test.go +++ b/module/plan_noop_exactness_test.go @@ -19,6 +19,7 @@ func planInstance(t *testing.T, desired, observed string) (providerResult, execu Kind: instanceKind, Desired: schemaPayload{SchemaID: "cic:compute:instance-config", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(desired)}, Observed: schemaPayload{SchemaID: "cic:compute:instance-state", SchemaVersion: "v0.1.0", SchemaHash: "abc124", Encoding: encCanonicalJSON, Data: json.RawMessage(observed)}, + Target: execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.instance..t"}, } raw, _ := json.Marshal(req) out, err := Plan(nil, raw) diff --git a/module/provider.go b/module/provider.go index dc230f6..6de1d29 100644 --- a/module/provider.go +++ b/module/provider.go @@ -23,7 +23,6 @@ package main import ( "bytes" - "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" @@ -334,10 +333,24 @@ type planRequest struct { Kind string `json:"kind"` Desired schemaPayload `json:"desired"` Observed schemaPayload `json:"observed"` + // Target is where the plan will run: host, base_path, key_id, resource_id and + // revision, as Execute will receive them in its binding. host and key_id are + // required; resource_id is required for an existing resource and empty for one + // to be created. + Target execBinding `json:"target"` } type executionPlan struct { - PlanID string `json:"plan_id"` // sha256:... over the canonical plan inputs + // PlanDigest identifies everything the plan will execute: kind, contract, desired, + // observed, operation and the ordered provider operations. TargetDigest identifies + // where it runs. ExecutionDigest binds the two and is the value an approval names. + // All are sha256 over canonical JSON; see plan_binding.go. + PlanDigest string `json:"plan_digest,omitempty"` + TargetDigest string `json:"target_digest,omitempty"` + ExecutionDigest string `json:"execution_digest,omitempty"` + // PlanID is the retired identifier. Plan never emits it; it is declared only so that + // Execute can see a request that still carries one and refuse it. + PlanID json.RawMessage `json:"plan_id,omitempty"` Operation string `json:"operation"` // create|update|replace|action|noop ProviderOperations []providerOperation `json:"provider_operations,omitempty"` Notes []string `json:"notes,omitempty"` @@ -404,11 +417,11 @@ func planProviderOps(c resourceContract, op string, changed []string) []provider return ops } -// Plan produces an execution plan from desired + observed. This increment -// computes the trivial, fully-decidable case — desired == observed → noop — with -// a real, hashable plan_id. Any non-noop diff needs the OCI operation registry -// (roadmap P2.3) to map fields to provider_operations, so it returns a typed -// scaffold error rather than a fabricated plan. Pure, no mutation. +// Plan produces an execution plan from desired, observed and the target it will run +// on, and the three digests that identify it (plan_digest, target_digest, +// execution_digest; plan_binding.go). Pure, no mutation. Execute repeats this very +// derivation before it signs anything, so what Plan returns is also what Execute will +// accept, and nothing else. func Plan(auth, data []byte) ([]byte, error) { var req planRequest if err := json.Unmarshal(data, &req); err != nil { @@ -416,11 +429,25 @@ func Plan(auth, data []byte) ([]byte, error) { Class: classValidation, Message: "plan-request is not valid JSON: " + err.Error(), }) } + plan, perr := planFromRequest(req, "target") + if perr != nil { + return errResult(perr) + } + return okResult(plan) +} + +// planFromRequest derives the plan and its digests. targetField names the target in a +// refusal: "target" when Plan is called, "binding" when Execute re-derives. +// +// The lifecycle decides what the target must carry. A resource that does not exist yet +// (observed is empty) has no resource_id and is created; an existing resource (observed +// has state) needs a well-formed resource_id; any other combination is refused. +func planFromRequest(req planRequest, targetField string) (executionPlan, *providerError) { if envErrs := checkEnvelope("desired", req.Desired); len(envErrs) > 0 { - return errResult(&envErrs[0]) + return executionPlan{}, &envErrs[0] } if envErrs := checkEnvelope("observed", req.Observed); len(envErrs) > 0 { - return errResult(&envErrs[0]) + return executionPlan{}, &envErrs[0] } // Plan decides "noop" and which fields changed by comparing JSON values, so both // payloads must be strict, bounded JSON with exact numbers and no repeated key @@ -431,50 +458,64 @@ func Plan(auth, data []byte) ([]byte, error) { data json.RawMessage }{{"desired.data", req.Desired.Data}, {"observed.data", req.Observed.Data}} { if _, err := canonicalJSON(pl.data); err != nil { - return errResult(&providerError{Class: classValidation, FieldPath: pl.name, - Message: pl.name + " is not canonicalizable JSON: " + err.Error()}) + return executionPlan{}, &providerError{Class: classValidation, FieldPath: pl.name, + Message: pl.name + " is not canonicalizable JSON: " + err.Error()} } } + if pe := checkTarget(req.Kind, req.Target, targetField); pe != nil { + return executionPlan{}, pe + } - c, ok := resourceContracts()[req.Kind] - if !ok { + c, known := resourceContracts()[req.Kind] + if !known { // No generated contract for this kind: only the no-op case is decidable. if canonicalEqual(req.Desired.Data, req.Observed.Data) { - return okResult(noopPlan(req)) + return finishPlan(req, executionPlan{ + Operation: "noop", + Notes: []string{"desired == observed; no provider operations"}, + }) } - return errResult(&providerError{ + return executionPlan{}, &providerError{ Class: classInternal, ProviderCode: "PLAN_CONTRACT_UNAVAILABLE", Retryable: false, Message: "plan: no generated contract for kind " + req.Kind + "; only the no-op case is decidable.", - }) + } } desired, err := decodeObject(req.Desired.Data) if err != nil { - return errResult(&providerError{Class: classSchema, FieldPath: "desired.data", Message: err.Error()}) + return executionPlan{}, &providerError{Class: classSchema, FieldPath: "desired.data", Message: err.Error()} } observed, err := decodeObject(req.Observed.Data) if err != nil { - return errResult(&providerError{Class: classSchema, FieldPath: "observed.data", Message: err.Error()}) - } - // Plan reconciles an already-observed, existing resource — it is not how - // a brand-new resource gets created. An empty observed (no fields at all) - // cannot be a real Observe() result: Observe() requires binding.resource_id - // and errors (class: not-found) on a 404 rather than returning empty state, - // so there is no legitimate caller path that produces this shape. Without - // this guard, every desired field reads as "changed" against the empty - // map, and a single create-only field among them escalates the plan to - // "replace" — emitting a Delete operation against a resource that was - // never created, with no real resource id to bind into its path - // (cic-module-oracle-cloud#31). A brand-new resource is created via a - // directly-constructed Execute(Create) call instead (see - // docs/design/manual-verification.md's "execute (Create)" row) — Plan is - // never involved in that path, so this is a caller-error case, not a - // creation feature Plan lacks. - if len(observed) == 0 { - return errResult(&providerError{ - Class: classValidation, FieldPath: "observed.data", - Message: "observed must be the state of an already-existing resource (from Observe()); Plan does not create new resources — call Execute directly with the create operation for that", + return executionPlan{}, &providerError{Class: classSchema, FieldPath: "observed.data", Message: err.Error()} + } + + // Lifecycle. No observed state and no resource id: the resource does not exist + // yet, so the plan is its creation. Observed state without a resource id, or a + // resource id without observed state, describes no real resource and is refused + // (cic-module-oracle-cloud#31: an empty observed used to read as "everything + // changed" and could escalate to a replace that deleted a resource never created). + switch { + case len(observed) == 0 && req.Target.ResourceID == "": + name, h, ok := c.opByRole(roleCreate) + if !ok { + return executionPlan{}, &providerError{Class: classInternal, Message: "no create operation for kind " + req.Kind} + } + return finishPlan(req, executionPlan{ + Operation: "create", + ProviderOperations: []providerOperation{{Operation: name, Method: h.method, Path: h.path, Reason: "the resource does not exist yet"}}, + Notes: []string{"create: no observed state and no resource id"}, }) + case len(observed) == 0: + return executionPlan{}, &providerError{ + Class: classValidation, FieldPath: "observed.data", + Message: "observed is empty but " + targetField + ".resource_id names a resource: observed must be the state of an already-existing resource (from Observe())", + } + case req.Target.ResourceID == "": + return executionPlan{}, &providerError{ + Class: classValidation, FieldPath: targetField + ".resource_id", + Message: targetField + ".resource_id is required: observed describes an existing resource", + } } // Diff over the intent's declared fields. An absent desired field is @@ -497,20 +538,23 @@ func Plan(auth, data []byte) ([]byte, error) { } sort.Strings(changed) - return okResult(executionPlan{ - PlanID: "sha256:" + planHash(req.Kind, req.Desired, req.Observed), + return finishPlan(req, executionPlan{ Operation: op, ProviderOperations: planProviderOps(c, op, changed), Notes: planNotes(op, changed), }) } -func noopPlan(req planRequest) executionPlan { - return executionPlan{ - PlanID: "sha256:" + planHash(req.Kind, req.Desired, req.Observed), - Operation: "noop", - Notes: []string{"desired == observed; no provider operations"}, +// finishPlan adds the three digests to a derived plan. +func finishPlan(req planRequest, p executionPlan) (executionPlan, *providerError) { + env, err := planEnvelope(req.Kind, contractDigest(req.Kind), req.Desired, req.Observed, p.Operation, p.ProviderOperations) + if err != nil { + return executionPlan{}, &providerError{Class: classValidation, Message: "plan inputs cannot be digested: " + err.Error()} } + p.PlanDigest = digestOf(env) + p.TargetDigest = digestOf(targetEnvelope(req.Kind, req.Target)) + p.ExecutionDigest = digestOf(executionEnvelope(p.PlanDigest, p.TargetDigest)) + return p, nil } func decodeObject(raw json.RawMessage) (map[string]json.RawMessage, error) { @@ -563,19 +607,6 @@ func planNotes(op string, changed []string) []string { return []string{op + ": changed fields " + strings.Join(changed, ", ")} } -// planHash is a deterministic hash over the plan inputs, so a plan is identifiable -// and signable (provider-abi.md: plan_id is hashable). -func planHash(kind string, desired, observed schemaPayload) string { - h := sha256.New() - h.Write([]byte(kind)) - h.Write([]byte(desired.SchemaID)) - h.Write([]byte(desired.SchemaHash)) - h.Write([]byte(desired.Data)) - h.Write([]byte(observed.SchemaHash)) - h.Write([]byte(observed.Data)) - return hex.EncodeToString(h.Sum(nil)) -} - // canonicalEqual reports whether two JSON texts are the same value, by their // cic-canonical-json/1 form (canonical_json.go): exact numbers, key order and // whitespace and escape spelling ignored. A text that is not strict JSON, or beyond @@ -1045,10 +1076,15 @@ func effectiveConfig(c resourceContract, state map[string]json.RawMessage) map[s // ---- execute (implemented against the relay cic-flow host module, R1/R2) ---- type executeRequest struct { - Kind string `json:"kind"` - Plan executionPlan `json:"plan"` // the approved plan (with provider_operations) - Config schemaPayload `json:"config"` // desired config, to render request bodies - Binding execBinding `json:"binding"` + Kind string `json:"kind"` + Plan executionPlan `json:"plan"` // the plan, with its three digests + // Desired and Observed are the inputs the plan was derived from. Execute derives + // the plan from them again and requires it to match Plan, so the plan it runs is + // the one that follows from them. + Desired schemaPayload `json:"desired"` + Observed schemaPayload `json:"observed"` + Config schemaPayload `json:"config"` // must be the same JSON value as desired.data + Binding execBinding `json:"binding"` } // execBinding carries the provider coordinates the module needs to actuate: the @@ -1072,6 +1108,9 @@ func opPath(b execBinding, rawPath string) string { type executionResult struct { Status string `json:"status"` // succeeded | failed Steps []executionStep `json:"steps"` + // ExecutionDigest echoes the digest of the plan and target that ran, so a receipt + // names exactly what was executed. Set by Execute only. + ExecutionDigest string `json:"execution_digest,omitempty"` } type executionStep struct { @@ -1144,11 +1183,6 @@ func Execute(auth, data []byte) ([]byte, error) { if !ok { return errResult(&providerError{Class: classValidation, FieldPath: "kind", Message: "no contract for kind " + req.Kind}) } - config, err := decodeObject(req.Config.Data) - if err != nil { - return errResult(&providerError{Class: classSchema, FieldPath: "config.data", Message: err.Error()}) - } - // binding.resource_id is spliced into the path of every step whose EXECUTED path // has a placeholder (templatePath), and into every step the registry does not // know as a create. The decision follows the path that will really be sent, not @@ -1171,11 +1205,108 @@ func Execute(auth, data []byte) ([]byte, error) { return errResult(pe) } + // M4a: the plan, the desired and observed state it comes from, and the target it + // runs on must agree with each other and with the plan derived from them again. + derived, pe := checkPlanBinding(req) + if pe != nil { + return errResult(pe) + } + config, err := decodeObject(req.Desired.Data) + if err != nil { + return errResult(&providerError{Class: classSchema, FieldPath: "desired.data", Message: err.Error()}) + } + + result := executeSteps(c, req.Plan.ProviderOperations, config, req.Binding) + result.ExecutionDigest = derived.ExecutionDigest + return okResult(result) +} + +// checkPlanBinding is the M4a gate. Before anything is signed it requires that the +// request names no retired plan_id and carries all three digests and both payloads; +// that the plan derived again from desired, observed and the target has the same +// operation and the same provider operations in the same order as the plan received +// (a derivation, not just a digest check: swapping an update for an M3-valid delete +// is caught here); that the three digests match the ones computed here; and that the +// config is the same JSON value as desired.data. +// +// A request that passes is internally consistent. That is all it shows: not that the +// plan was approved, that the caller may run it, or that it has not run before. +func checkPlanBinding(req executeRequest) (executionPlan, *providerError) { + missing := func(field string) *providerError { + return &providerError{Class: classValidation, FieldPath: field, Message: field + " is required"} + } + if len(req.Plan.PlanID) > 0 { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: "plan.plan_id", + Message: "plan_id is retired: send plan_digest, target_digest and execution_digest"} + } + for _, d := range []struct{ field, value string }{ + {"plan.plan_digest", req.Plan.PlanDigest}, + {"plan.target_digest", req.Plan.TargetDigest}, + {"plan.execution_digest", req.Plan.ExecutionDigest}, + } { + if d.value == "" { + return executionPlan{}, missing(d.field) + } + } + // An absent payload decodes to an empty or JSON-null Data; neither is a payload. + if isAbsent(req.Desired.Data) { + return executionPlan{}, missing("desired.data") + } + if isAbsent(req.Observed.Data) { + return executionPlan{}, missing("observed.data") + } + + derived, pe := planFromRequest(planRequest{ + Kind: req.Kind, Desired: req.Desired, Observed: req.Observed, Target: req.Binding, + }, "binding") + if pe != nil { + return executionPlan{}, pe + } + if req.Plan.Operation != derived.Operation { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: "plan.operation", + Message: "plan.operation is " + shortASCII(req.Plan.Operation) + ", the plan derived from desired and observed is " + derived.Operation} + } + got, want := req.Plan.ProviderOperations, derived.ProviderOperations + if len(got) != len(want) { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: "plan.provider_operations", + Message: fmt.Sprintf("the plan has %d provider operations, the plan derived from desired and observed has %d", len(got), len(want))} + } + for i := range want { + if got[i].Operation != want[i].Operation || got[i].Method != want[i].Method || got[i].Path != want[i].Path { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: fmt.Sprintf("plan.provider_operations[%d]", i), + Message: "this step differs from the plan derived from desired and observed (" + want[i].Operation + ")"} + } + } + for _, d := range []struct{ field, got, want string }{ + {"plan.plan_digest", req.Plan.PlanDigest, derived.PlanDigest}, + {"plan.target_digest", req.Plan.TargetDigest, derived.TargetDigest}, + {"plan.execution_digest", req.Plan.ExecutionDigest, derived.ExecutionDigest}, + } { + if d.got != d.want { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: d.field, + Message: d.field + " does not match the digest computed from the request"} + } + } + if !canonicalEqual(req.Config.Data, req.Desired.Data) { + return executionPlan{}, &providerError{Class: classValidation, FieldPath: "config.data", + Message: "config.data is not the same JSON value as desired.data"} + } + return derived, nil +} + +func isAbsent(raw json.RawMessage) bool { + return len(raw) == 0 || string(raw) == "null" +} + +// executeSteps runs the provider operations in order and stops at the first failing +// step. It assumes the plan has passed every gate in Execute; it decides nothing about +// whether the plan may run. +func executeSteps(c resourceContract, ops []providerOperation, config map[string]json.RawMessage, b execBinding) executionResult { date := nowFunc().Format(ociTimeFormat) result := executionResult{Status: "succeeded"} async := false - for _, po := range req.Plan.ProviderOperations { - step, fatal := executeOne(c, po, config, req.Binding, date) + for _, po := range ops { + step, fatal := executeOne(c, po, config, b, date) result.Steps = append(result.Steps, step) if step.WorkRequestID != "" { async = true @@ -1190,7 +1321,7 @@ func Execute(auth, data []byte) ([]byte, error) { if result.Status == "succeeded" && async { result.Status = "accepted" } - return okResult(result) + return result } // executeOne runs a single provider operation: render → sign → actuate. fatal is diff --git a/module/provider_test.go b/module/provider_test.go index c4b47c2..0f9ed6c 100644 --- a/module/provider_test.go +++ b/module/provider_test.go @@ -204,6 +204,8 @@ func TestPlanRejectsEmptyObserved(t *testing.T) { Kind: "cic:network:vcn", Desired: schemaPayload{SchemaID: "cic:network:vcn-config", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..a","displayName":"new-vcn","cidrBlocks":["10.0.0.0/16"]}`)}, Observed: schemaPayload{SchemaID: "cic:network:vcn-state", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(`{}`)}, + // The target names a resource, so an empty observed state contradicts it. + Target: execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.vcn..t"}, } raw, _ := json.Marshal(req) out, err := Plan(nil, raw) @@ -253,6 +255,7 @@ func TestPlanSubnet(t *testing.T) { Kind: "cic:network:subnet", Desired: schemaPayload{SchemaID: "cic:network:subnet-config", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(desired)}, Observed: schemaPayload{SchemaID: "cic:network:subnet-state", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(observed)}, + Target: execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.subnet..t"}, } raw, _ := json.Marshal(req) out, err := Plan(nil, raw) @@ -295,12 +298,10 @@ func TestExecuteAsyncAndBasePath(t *testing.T) { } defer func() { testCallHostSign = nil; testCallHostActuate = nil }() - req, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}}}, - Config: schemaPayload{Data: json.RawMessage(`{"displayName":"x"}`)}, - Binding: execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z"}, - }) + req, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", + providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, + `{"displayName":"x"}`, + execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z"})) out, _ := Execute(nil, req) var er executionResult json.Unmarshal(decodeResult(t, out).Result, &er) @@ -333,12 +334,10 @@ func TestExecuteErrorAndIfMatch(t *testing.T) { } defer func() { testCallHostSign = nil; testCallHostActuate = nil }() - req, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}}}, - Config: schemaPayload{Data: json.RawMessage(`{"displayName":"x"}`)}, - Binding: execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"}, - }) + req, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", + providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, + `{"displayName":"x"}`, + execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"})) out, _ := Execute(nil, req) var er executionResult json.Unmarshal(decodeResult(t, out).Result, &er) @@ -458,6 +457,7 @@ func planVcn(t *testing.T, desired, observed string) executionPlan { Kind: "cic:network:vcn", Desired: schemaPayload{SchemaID: "cic:network:vcn-config", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(desired)}, Observed: schemaPayload{SchemaID: "cic:network:vcn-state", SchemaVersion: "v0.1.0", SchemaHash: "abc123", Encoding: encCanonicalJSON, Data: json.RawMessage(observed)}, + Target: execBinding{Host: "h", KeyID: "k", ResourceID: "ocid1.vcn..t"}, } raw, _ := json.Marshal(req) out, err := Plan(nil, raw) @@ -496,8 +496,10 @@ func TestPlanClassifiesDiff(t *testing.T) { if p.Operation != c.wantOp { t.Errorf("operation = %q, want %q (notes: %v)", p.Operation, c.wantOp, p.Notes) } - if len(p.PlanID) < len("sha256:") { - t.Errorf("plan_id = %q, want a sha256 hash", p.PlanID) + for name, d := range map[string]string{"plan_digest": p.PlanDigest, "target_digest": p.TargetDigest, "execution_digest": p.ExecutionDigest} { + if len(d) != len("sha256:")+64 || d[:7] != "sha256:" { + t.Errorf("%s = %q, want sha256:<64 hex>", name, d) + } } }) } diff --git a/module/relay_integration_test.go b/module/relay_integration_test.go index 2aea6ca..b64bf0f 100644 --- a/module/relay_integration_test.go +++ b/module/relay_integration_test.go @@ -120,15 +120,10 @@ func TestRelayIntegrationExecuteSigned(t *testing.T) { ctx, instance, callFn, allocateFn, deallocateFn := loadWithCICFlow(t, wasmBytes, sign, actuate) - exReq, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{ - Operation: "update", - ProviderOperations: []providerOperation{{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}}, - }, - Config: schemaPayload{Encoding: encCanonicalJSON, Data: json.RawMessage(`{"displayName":"prod-vcn"}`)}, - Binding: execBinding{Host: host, KeyID: "tenancy/user/fp", ResourceID: "ocid1.vcn..z"}, - }) + exReq, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", + providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, + `{"displayName":"prod-vcn"}`, + execBinding{Host: host, KeyID: "tenancy/user/fp", ResourceID: "ocid1.vcn..z"})) env := callOp(t, ctx, instance, callFn, allocateFn, deallocateFn, "execute", "{}", string(exReq)) if string(env.Error) != "null" { @@ -211,12 +206,10 @@ func TestRelayIntegrationRejectsBadSignature(t *testing.T) { } ctx, instance, callFn, allocateFn, deallocateFn := loadWithCICFlow(t, wasmBytes, sign, actuate) - exReq, _ := json.Marshal(executeRequest{ - Kind: "cic:network:vcn", - Plan: executionPlan{Operation: "update", ProviderOperations: []providerOperation{{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}}}, - Config: schemaPayload{Encoding: encCanonicalJSON, Data: json.RawMessage(`{"displayName":"x"}`)}, - Binding: execBinding{Host: strings.TrimPrefix(srv.URL, "https://"), KeyID: "t/u/f", ResourceID: "ocid1.vcn..z"}, - }) + exReq, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", + providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, + `{"displayName":"x"}`, + execBinding{Host: strings.TrimPrefix(srv.URL, "https://"), KeyID: "t/u/f", ResourceID: "ocid1.vcn..z"})) env := callOp(t, ctx, instance, callFn, allocateFn, deallocateFn, "execute", "{}", string(exReq)) var res struct { diff --git a/module/testdata/plan_digest_vectors.json b/module/testdata/plan_digest_vectors.json new file mode 100644 index 0000000..e468f83 --- /dev/null +++ b/module/testdata/plan_digest_vectors.json @@ -0,0 +1,621 @@ +{ + "format": "cic-oracle-digests/1", + "about": "Language-neutral vectors for the plan, target and execution digests (docs/design/specs/plan-digest.md). Generated by an independent implementation (no Go). For each entry: building the envelope from the inputs must give exactly `envelope`, and its sha256 must be `digest`. `contract` is an input here; in the module it is the digest of the kind's registry contract.", + "plan": [ + { + "name": "update plan", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629" + }, + { + "name": "same plan, other spelling of the JSON (key order, whitespace, escapes)", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": " { \"displayName\" : \"n\\u0065w\" } " + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{ \"displayName\":\"old\" }" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629" + }, + { + "name": "numbers: 1, 1.0 and 1e0 are one value", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"n\":1,\"m\":[1.0,1e0,100]}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"n\":2}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"m\":[1e0,1e0,1e2],\"n\":1e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"n\":2e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:2b106cdf97d8b7af3d91b567bd64b4aaa0c9a105d256a9c551ff544ed413dea0" + }, + { + "name": "numbers: other spelling of the same values", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"m\":[10e-1,1.00,1E2],\"n\":1.0}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"n\":2.0}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"m\":[1e0,1e0,1e2],\"n\":1e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"n\":2e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:2b106cdf97d8b7af3d91b567bd64b4aaa0c9a105d256a9c551ff544ed413dea0" + }, + { + "name": "2^53+1 is kept exactly", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"n\":9007199254740993}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"n\":1}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"n\":9007199254740993e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"n\":1e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:d03458c1d649d15a1b4e02dfd55903e800d0436ce3ccee8a8a864882ba2f268f" + }, + { + "name": "2^53 differs from 2^53+1", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"n\":9007199254740992}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"n\":1}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"n\":9007199254740992e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"n\":1e0},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:9e5f74abb448a4f14d383ff79b0c23d791fa26ea0f2e4ef99e00d6a01dbc48de" + }, + { + "name": "another contract digest", + "kind": "cic:network:vcn", + "contract": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:25bf0729a514f1ba6e43bf798b12c46d9a042c68ccbe81d3ce2681af2e249f4d" + }, + { + "name": "another desired schema_version", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v9", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v9\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:e34e1d0c018c6dfb147bc3865379edfce1583e07cc9e6fc64ab04da9b6f4b71a" + }, + { + "name": "another observed encoding", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "cbor", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"cbor\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:c46126ee5daf413dbb3f250578703981388990fa75920ebe83183758e4f358be" + }, + { + "name": "another observed schema_id", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "x", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"x\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:c11d1f414d0543cae8413d13c8e9a0021a8bf37a4e5ca7673907f3ab7e0b266c" + }, + { + "name": "another operation name, same ordered operations", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "replace", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"replace\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:c7fddcb10815cc5da8c50fc22c9efb4ec04bbff295a708bd1c6df91b7ddc6784" + }, + { + "name": "two operations in one order", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + }, + { + "operation": "ChangeVcnCompartment", + "method": "POST", + "path": "/vcns/{vcnId}/actions/changeCompartment" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"},{\"method\":\"POST\",\"operation\":\"ChangeVcnCompartment\",\"path\":\"/vcns/{vcnId}/actions/changeCompartment\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:148b71e6baba8ca7beebe62bff8507b6a1264b065d6fc104ac07da5d98f86d50" + }, + { + "name": "the same two operations in the other order", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"new\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "ChangeVcnCompartment", + "method": "POST", + "path": "/vcns/{vcnId}/actions/changeCompartment" + }, + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"new\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"POST\",\"operation\":\"ChangeVcnCompartment\",\"path\":\"/vcns/{vcnId}/actions/changeCompartment\"},{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:a1141f1133d5badb134fad802532619d404886aae25012b3611e4aa64dd8d6eb" + }, + { + "name": "boundary: kind AB + schema_id CD", + "kind": "AB", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "CD", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "noop", + "provider_operations": [], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"CD\",\"schema_version\":\"v0.1.0\"},\"kind\":\"AB\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"noop\",\"provider_operations\":[],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:6908ca2d55dffb30cf71331e20c3eab13bd2289cb5288ecccc33cbd67297c863" + }, + { + "name": "boundary: kind A + schema_id BCD", + "kind": "A", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "BCD", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "noop", + "provider_operations": [], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"BCD\",\"schema_version\":\"v0.1.0\"},\"kind\":\"A\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"noop\",\"provider_operations\":[],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:6f19fc63561d835a6f2fc04c18e33e671960d1e17b6cd54b80dca43a9df6b41a" + }, + { + "name": "non-ASCII text in data", + "kind": "cic:network:vcn", + "contract": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "desired": { + "schema_id": "cic:network:vcn-config", + "schema_version": "v0.1.0", + "schema_hash": "abc123", + "encoding": "canonical-json", + "data": "{\"displayName\":\"é\\u00e9 \\u003c\"}" + }, + "observed": { + "schema_id": "cic:network:vcn-state", + "schema_version": "v0.1.0", + "schema_hash": "abc124", + "encoding": "canonical-json", + "data": "{\"displayName\":\"old\"}" + }, + "operation": "update", + "provider_operations": [ + { + "operation": "UpdateVcn", + "method": "PUT", + "path": "/vcns/{vcnId}" + } + ], + "envelope": "{\"contract\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"desired\":{\"data\":{\"displayName\":\"éé <\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc123\",\"schema_id\":\"cic:network:vcn-config\",\"schema_version\":\"v0.1.0\"},\"kind\":\"cic:network:vcn\",\"observed\":{\"data\":{\"displayName\":\"old\"},\"encoding\":\"canonical-json\",\"schema_hash\":\"abc124\",\"schema_id\":\"cic:network:vcn-state\",\"schema_version\":\"v0.1.0\"},\"operation\":\"update\",\"provider_operations\":[{\"method\":\"PUT\",\"operation\":\"UpdateVcn\",\"path\":\"/vcns/{vcnId}\"}],\"v\":\"cic-oracle-execution-plan/1\"}", + "digest": "sha256:880e993c3d04eb8b7fecc9b29057381327bf44bf0987acb1398a4436b6a31108" + } + ], + "target": [ + { + "name": "full target", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7" + }, + { + "name": "a create: no resource id, no revision", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "", + "revision": "", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":null,\"revision\":null,\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:14c6ae8c9adf610f5d0f0f024a01fd161147435ace896b05d85fc9eab73e6060" + }, + { + "name": "no base_path", + "kind": "cic:network:vcn", + "host": "h", + "base_path": "", + "key_id": "k", + "resource_id": "ocid1.vcn..z", + "revision": "e", + "envelope": "{\"base_path\":null,\"host\":\"h\",\"key_id\":\"k\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn..z\",\"revision\":\"e\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:7149ace0f8bbf77f7dba277ac92768167a2919a96788abc6dfc14d5178d5443a" + }, + { + "name": "other resource id", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..b", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..b\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:c101bd00e655eec28f9671bd054838b9ac3dcce1f0cae3f0ca913f29b3b23b88" + }, + { + "name": "other revision", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-2", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-2\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:4e1751bf189e31c606ebf45ee5f7c221d9f2027c3abe18b85e58e52aa23b29ef" + }, + { + "name": "other key_id", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/g", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/g\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:d4460422db9ed0e17dcc22ffc874f67c126e6481b6a4e047c34baa92010487f9" + }, + { + "name": "other host", + "kind": "cic:network:vcn", + "host": "iaas.us-ashburn-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.us-ashburn-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:c1586cb78e044740b9e5b463281e7cdd9a322012119a2c7e2bfbb3920e6b6548" + }, + { + "name": "other base_path", + "kind": "cic:network:vcn", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20990101", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20990101\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:vcn\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:b7db11e2f332cd156bee044d7265301e7ce6b2eb7b0cbf70af181527b9325aa5" + }, + { + "name": "boundary: host ab + base_path c", + "kind": "cic:network:vcn", + "host": "ab", + "base_path": "c", + "key_id": "k", + "resource_id": "", + "revision": "", + "envelope": "{\"base_path\":\"c\",\"host\":\"ab\",\"key_id\":\"k\",\"kind\":\"cic:network:vcn\",\"resource_id\":null,\"revision\":null,\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:847f5d88fbb6b850866771db1e3c61b3844fbd1fe7a3a4bb4711aa77262b45ea" + }, + { + "name": "boundary: host a + base_path bc", + "kind": "cic:network:vcn", + "host": "a", + "base_path": "bc", + "key_id": "k", + "resource_id": "", + "revision": "", + "envelope": "{\"base_path\":\"bc\",\"host\":\"a\",\"key_id\":\"k\",\"kind\":\"cic:network:vcn\",\"resource_id\":null,\"revision\":null,\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:80a7690ae2d28c1147c4ee32e7bbabdfbb1e84c0c4ab60e2aad0010e1fb0deef" + }, + { + "name": "another kind", + "kind": "cic:network:subnet", + "host": "iaas.eu-frankfurt-1.oraclecloud.com", + "base_path": "/20160918", + "key_id": "t/u/f", + "resource_id": "ocid1.vcn.oc1..a", + "revision": "etag-1", + "envelope": "{\"base_path\":\"/20160918\",\"host\":\"iaas.eu-frankfurt-1.oraclecloud.com\",\"key_id\":\"t/u/f\",\"kind\":\"cic:network:subnet\",\"resource_id\":\"ocid1.vcn.oc1..a\",\"revision\":\"etag-1\",\"v\":\"cic-oracle-execution-target/1\"}", + "digest": "sha256:4337352a56d2950c24720b4d0b5e6012c5bea42ba175e5569ef2aa62115423cd" + } + ], + "execution": [ + { + "name": "plan 0 on target 0", + "plan_digest": "sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629", + "target_digest": "sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7", + "envelope": "{\"plan_digest\":\"sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629\",\"target_digest\":\"sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7\",\"v\":\"cic-oracle-execution/1\"}", + "digest": "sha256:e9bac474434141b57ddc250a21d2b9f7ed6c8f8fcfdc042a2c2da72ec61d10c4" + }, + { + "name": "the same plan on another target", + "plan_digest": "sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629", + "target_digest": "sha256:c101bd00e655eec28f9671bd054838b9ac3dcce1f0cae3f0ca913f29b3b23b88", + "envelope": "{\"plan_digest\":\"sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629\",\"target_digest\":\"sha256:c101bd00e655eec28f9671bd054838b9ac3dcce1f0cae3f0ca913f29b3b23b88\",\"v\":\"cic-oracle-execution/1\"}", + "digest": "sha256:769204bb2e29199ea4f58984aabd9511026968a4af22bca2913b4b6c7e0388b5" + }, + { + "name": "another plan on the same target", + "plan_digest": "sha256:25bf0729a514f1ba6e43bf798b12c46d9a042c68ccbe81d3ce2681af2e249f4d", + "target_digest": "sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7", + "envelope": "{\"plan_digest\":\"sha256:25bf0729a514f1ba6e43bf798b12c46d9a042c68ccbe81d3ce2681af2e249f4d\",\"target_digest\":\"sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7\",\"v\":\"cic-oracle-execution/1\"}", + "digest": "sha256:34069cc25054550ca3e7fc5cd07f6d322effcea08f7ee70724533850f197854d" + }, + { + "name": "swapped roles (target digest as plan digest)", + "plan_digest": "sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7", + "target_digest": "sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629", + "envelope": "{\"plan_digest\":\"sha256:336fabed217da903f25fa875e4e9fda18efa00fed82446059e7f5d2ae6f54ff7\",\"target_digest\":\"sha256:6a612e3638f8ff83bcfdc400ce6d7f861cea55834fe39d7bdc74a5c97b754629\",\"v\":\"cic-oracle-execution/1\"}", + "digest": "sha256:5592031175cd98b82307cd3f146c54568367eb26073148360907f9173cee940a" + } + ] +} From ba7bb5e4f13de81bddd44c88812221f87ba5376a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Z=20Sink=C3=B3?= Date: Sun, 8 Nov 2026 13:41:13 +0100 Subject: [PATCH 2/2] module: a create plan needs a config the kind's contract accepts (M4a, review F1) Plan's new create branch (no observed state, no resource id) derived a plan from any desired config, so a config without the kind's required fields, with a field no caller may set, or with a gross type error still produced a consistent plan that Execute would run. The branch now applies the contract check Validate already does (required, known, JSON kind) before it plans, and Execute, which derives the plan again, refuses the same configs. A refusal names desired.data. (validation for a missing required field, schema for an unknown field or a wrong type) and makes no sign or actuate call; with several problems the first by field path is named, so the answer does not depend on map order. An update is not held to the required rule. Subnet and instance create cases in the derivable-plan test now carry their required fields. --- [signing-metadata] key = cic-my-sign-key signature = vault:v1:MEYCIQC5h/pYGSTsJxkvsB+JDIM8hH+sqGo0nnl9shrKhRDEjAIhAJ44OObJDZciLoUn7eZFjAbDinbwyUekjfsHMlnVyxs6 hash-algorithm = sha256 digest = SwC6QhyfouXD/Yo6wj6dL6sBd3+jQqGZzM+FCkK3III= [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 | 9 +- docs/design/specs/plan-digest.md | 9 ++ docs/design/specs/provider-abi.md | 3 + module/execute_registry_conformance_test.go | 4 +- module/plan_create_conformance_test.go | 131 ++++++++++++++++++++ module/provider.go | 13 ++ 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 module/plan_create_conformance_test.go diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 54b669b..795aaa9 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -35,6 +35,7 @@ 2a6bc913a5c877b2b2dfb30f68d165950d9c881606819d6873047f1122c63abf docs/hu/architecture.yaml 2ab20175d0b7d0f2a7ebe960903a08dc7fe69620615f253ae1fa3e584747edaa pytest.ini 2b08eed0184b63b5bd565e386bd50431a7da83afb8510dcb1fc17cad8482de4a module/testdata/vcn-v1/README.md +2ce99d2a744e7d79e64bd7e1c9446543e7ac134d03dfbfb9f5c8fa8e10fda718 module/plan_create_conformance_test.go 2d0d9bf8861afacaec5796ebfa105b5fa8566981d732b9c146850515672f089e module/contracts.go 2d28d83b74f3b137a2e2d02d21178ad751b9b59ea81133ec42c9704ca535c986 tools/init_from_template.sh 2d71ac3c49cf88cc1d95b43948afe78d3f531e4ced96ee0675bb6fea05144dc6 docs/design/specs/host-functions.md @@ -50,7 +51,6 @@ 3277b43930c89ab1464116c882e00bd1df1d62a4dc0c80c62f2da1f79841c2ed module/abi.go 32e5081516b2e9287606aca5ac6680645a3348acdacd7cf4a3f08989aa6ac882 module/execute_bytes_test.go 33e349f5d148ab506960230d4ac33d719da7e65f5b316ad4dfff191bd7dc3588 module/correspondence.go -348110ff94ae537c350c6326f8591fd24b4899aec8ec37c06e6006e0d7f7484c module/provider.go 35bd42c0d55ea02ea9701070141dbb7ebc01d6b020e1cea9103bc5f0ec22c243 module/testdata/vcn-v1/cases/desired-absent/oci/response.json 35bd42c0d55ea02ea9701070141dbb7ebc01d6b020e1cea9103bc5f0ec22c243 module/testdata/vcn-v1/cases/field-absent/oci/response.json 374a2c54d3fcd2b684d2a1699fe7e67c69dffa51e302001c13f55b1468c8c0c2 tests/test_tools/test_compiler.py @@ -66,12 +66,14 @@ 41ab08a0eb4f0739f352e60b35512c5402644f16fc349552cd185c490cb4d5a3 module/contracts_test.go 4262f6c980e336c488f4f3926d76f467777058677ba12cfeca8c2c09cd206659 docs/design/manual-verification.md 441f681287c170544f90844b29e4a103f89e91ac23a6cc8e974635d8bcde5a2b docs/en/architecture.md +45ec051451a83cdf6566f69ee4393b566457aa6c8d13e39c4f95cafba36d1364 module/execute_registry_conformance_test.go 46120946bf939cfeae2c4806a39aef398f904f89b61c670ee5dd0d3c769138c2 tools/oci-extract/resolve_test.go 47d07882f79fb0febf2f767db0c43ede48e26c7f4a2d71a77f4264f3b5d488eb module/binding_resource_id_test.go 49ea29dcc165db8a6b406e82492500fd4761a7121c2420116e31f8e58f865dec tools/oci-extract/resolve.go 4ac6d59c5e3cd88c9dde821511c970827ae56e40a89a308a210cd985ecfdc41e CLAUDE.md 4d7369474566105752ec28aefab51de748b237342c71d05b3189fa8c2ae1fdef Makefile 4fffcdac095c0623fefb8515ab80b3e9629c0febc454456cd08b3cdf3c941720 LICENSE.md +51633b3d4487ca3d3a121f5512b68992bdc38ffb1ac0affb759cc50bfbf7e972 docs/design/specs/plan-digest.md 526ab17415fc9a42967d35159db9438c3e696bf5a246e57a9f68c2dd0e596837 module/testdata/vcn-v1/cases/drift/expected/observed.oci.json 5432506f15d65fa59572362a70db4bc403c13b163ec961ecb2951d7f98d093d0 module/go.mod 546e7c533da4b86b8817ee426865df0d11e24d2aa1580647634345f452a5017e module/canonical_json_test.go @@ -81,14 +83,12 @@ 5d51889fc8cd00c4c0bf1462b01be8c4d6754e2a4e59dd8e7f0599c610c64472 tools/vault-sign-agent.sh 5d8afd2b9d29065c768de8a21562049904b7c4ad3449448c3159af34e3a0b9df docs/en/concept/git-management.meta.yaml 5daa3bf50db9db492aac5721bddc0837a6a30dec9558d886abf45ad89632f68e module/manual_real_oci_test.go -62cf6cb9d0e03f8d6922a625bcfc0d8dfc91a26319739f47006606a1a3ee2662 module/execute_registry_conformance_test.go 635384b1db18a7654d1e30b660ba825645d88a89e7bc0e808500a213b584f278 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.registry.json 66181e4c6a0b2735b5a8b3088b37e6a844e2c692399a5b72b036059da0188e0a tools/releaselib/__init__.py 688dffd2264877f3a64fca1d3526c57bb34cb1c18f5a01aa906667311831a052 docs/en/workflow.md 69294730cf43d081b23a484cf0b22dc383626154e5933f5353f60b025ee5c832 module/envelope.go 6a37f4257d4d1d1714e43b43bef32af8f00d71b219500f8e2820f46b046fc0b3 module/testdata/vcn-v1/cases/not-found/expected/verdict.fields.json 6a37f4257d4d1d1714e43b43bef32af8f00d71b219500f8e2820f46b046fc0b3 module/testdata/vcn-v1/cases/transport-error/expected/verdict.fields.json -6a3af509e23680369efafaf2197e6e1ab57fb48bd865aae072da2dc32038978c docs/design/specs/provider-abi.md 6cb9c6a0e685f8377a7ae3d8e7a417c22135b47863951937dc6647566de9ff7a module/execute_create_async_message_test.go 6d9c5cce2c2ce964f0add1b4aeb97a9bce828ad46dd76b22929e284741273fc4 schemas/index.yaml 6db64092fb2c2dc8bcbd3e321faf04f6cc40b16e890c8dec3e56e4178df01402 LICENSE.yaml @@ -98,7 +98,6 @@ 7080b3423beded8b64891afdcf2e6f6d8bec6ebeaa88aecbb1b06e149b54ef86 module/module.wasm 7136d8366bbd7c5ccc19a3d810a7f55c31541acec9b0f05bd84e45a39107ded7 tests/test_capability_manifest.py 7426752feb1140b2023192728df35248e4f802617c150bb0523ab97dc1638c0b tools/init-hooks.sh -74d6780f57986510dae0ac2222e83a0c2fd1535fe723f2508656e7fc1eb60cb9 docs/design/specs/plan-digest.md 74d93981f1935d21001ed0475ebb84132ab67c71840ca82ca0cbfac5aca29ea2 docs/contracts/en/wasm-abi.md 763f2814427dc8faaa7fb1ccf3e8afe2f6460273a2dfe852287b3ef66bc43b96 tools/oci-extract/policy_test.go 768d54fe8ec5fde38c41ec075ce02b51d93cf3bf5f47e8d81e33932f3e3ece27 module/ocid_boundary_test.go @@ -129,6 +128,7 @@ 8db6dccfdd2342641ad77532d9f66b305f74920ecc116371e81d8a36c873c7fb tools/oci-extract/policy.go 8e2542ec21d36fd70c95f3f80573839906d137478edca225b81342f8cbda16e9 tests/test_ci_workflow_order.py 9162efab5184e7d9546bbd6b7fbed2fff74d5b1564a3b6847aada81ff7ec7ae3 project.schema.yaml +916a79713953c5dec593df2a1a0fbbd4c59a811f865f79e530c5c34a7ba53ec5 module/provider.go 933d06b06c3c2e2fd53603e3865d99fe92a4cfaad49809a20b6ac368f41e5a0b .github/workflows/ci.yml 941db4ccb700b939d937831c1d34ce432564c5d86fd82a91f01231149f3096c8 tools/oci-extract/cmd/oci-extract/main.go 976bee7ffb9a4a599d5aee841d941ea7d12fecb6fdbb05d44238754b67148cb1 module/envelope_test.go @@ -243,3 +243,4 @@ fa13c93d96137cd60abaf523186ba2a0fa48ec28df52c55a49767cd58f88ef29 module/testdat fb5779e0edb8a71595d9d8c81946bfb324b86066c6b94b73b036e3e110591b5c module/testdata/vcn-v1/cases/desired-absent/expected/verdict.fields.json fb885dd678928855a632f1411e364e0674409d8fa15da767ce07d036e2da824f tools/oci-extract/cmd/oci-sweep/main.go fc893fc83e0ba0aac68d4fa7813ec56ac425fe385e4d896fd77532ee94a39509 tools/oci-extract/testdata/vcn.go +fd1ca75aba965be594bceb3fe1ad0232750f0ba1a9232dbb5ed64eb280e8c60e docs/design/specs/provider-abi.md diff --git a/docs/design/specs/plan-digest.md b/docs/design/specs/plan-digest.md index 7baaed5..db154e0 100644 --- a/docs/design/specs/plan-digest.md +++ b/docs/design/specs/plan-digest.md @@ -73,6 +73,15 @@ resource id is required follows the lifecycle: | state | empty | refused (`target.resource_id`): an existing resource needs its id | | state | present | the diff: `noop`, `update`, `action` or `replace` | +A create is planned from the desired config alone, so `plan` first requires that config +to be one the kind's contract accepts, and `execute` (which derives the plan again) +refuses it the same way: a missing required field (`validation`), a field no caller may +set or a gross type error (`schema`), each as `desired.data.`; when there are +several problems the first by field path is named, so the answer does not depend on map +order. The check is the coarse one `validate` already does (required, known, JSON kind), +not a JSON Schema engine. An update is not held to the "required" rule: it is judged +by what changed. + The second row keeps the guard from cic-module-oracle-cloud#31: an empty observed state can no longer be read as "every field changed" for a resource that was never created. diff --git a/docs/design/specs/provider-abi.md b/docs/design/specs/provider-abi.md index 608798d..c627ebc 100644 --- a/docs/design/specs/provider-abi.md +++ b/docs/design/specs/provider-abi.md @@ -189,6 +189,9 @@ no sign and no actuate call. provider operations in the same order; the three digests (`plan_digest`, `target_digest`, `execution_digest`) to equal the ones computed from the request; `config.data` to be the same JSON value as `desired.data`; and no retired `plan_id`. + A create (no observed state, no resource id) additionally needs a desired config the + kind's contract accepts: required fields present, no field a caller cannot set, no + gross type error. The details are in `plan-digest.md`. This shows the request is internally consistent, not that it was approved or that it has not run before. diff --git a/module/execute_registry_conformance_test.go b/module/execute_registry_conformance_test.go index db86e6f..c165228 100644 --- a/module/execute_registry_conformance_test.go +++ b/module/execute_registry_conformance_test.go @@ -202,12 +202,12 @@ func TestExecuteStillRunsEveryDerivablePlanWithTheRegistrysMethodAndPath(t *test {"replace", `{"dnsLabel":"a"}`, `{"dnsLabel":"b"}`}, }, "cic:network:subnet": { - {"create", `{"compartmentId":"ocid1.compartment..c"}`, `{}`}, + {"create", `{"compartmentId":"ocid1.compartment..c","vcnId":"ocid1.vcn..v"}`, `{}`}, {"update", `{"displayName":"new"}`, `{"displayName":"old"}`}, {"action", `{"compartmentId":"ocid1.compartment..b"}`, `{"compartmentId":"ocid1.compartment..a"}`}, }, "cic:compute:instance": { - {"create", `{"compartmentId":"ocid1.compartment..c"}`, `{}`}, + {"create", `{"compartmentId":"ocid1.compartment..c","availabilityDomain":"AD-1"}`, `{}`}, {"update", `{"displayName":"new"}`, `{"displayName":"old"}`}, {"action", `{"compartmentId":"ocid1.compartment..b"}`, `{"compartmentId":"ocid1.compartment..a"}`}, }, diff --git a/module/plan_create_conformance_test.go b/module/plan_create_conformance_test.go new file mode 100644 index 0000000..804b026 --- /dev/null +++ b/module/plan_create_conformance_test.go @@ -0,0 +1,131 @@ +//go:build !wasip1 + +package main + +// A create is planned from the desired config alone, so Plan must not produce a plan, +// and Execute must not run one, for a config the kind's contract does not accept: a +// missing required field, a field the caller cannot set, a gross type error. These +// tests pin that (review of PR #43, F1). An update diffs against an existing resource +// and is not held to the "required" rule; Validate is where a partial intent is +// reviewed. + +import ( + "encoding/json" + "strings" + "testing" +) + +type createCase struct { + name, kind, desired, field, class string +} + +func createCases() []createCase { + return []createCase{ + {"vcn: required compartmentId missing", "cic:network:vcn", `{"displayName":"x"}`, "desired.data.compartmentId", classValidation}, + {"vcn: empty config", "cic:network:vcn", `{}`, "desired.data.compartmentId", classValidation}, + {"subnet: required vcnId missing", "cic:network:subnet", `{"compartmentId":"ocid1.compartment..c"}`, "desired.data.vcnId", classValidation}, + {"instance: required availabilityDomain missing", "cic:compute:instance", `{"compartmentId":"ocid1.compartment..c"}`, "desired.data.availabilityDomain", classValidation}, + {"vcn: a field the caller cannot set", "cic:network:vcn", `{"compartmentId":"ocid1.compartment..c","lifecycleState":"AVAILABLE"}`, "desired.data.lifecycleState", classSchema}, + {"vcn: an unknown field", "cic:network:vcn", `{"compartmentId":"ocid1.compartment..c","bogus":1}`, "desired.data.bogus", classSchema}, + {"vcn: a string where an array is wanted", "cic:network:vcn", `{"compartmentId":"ocid1.compartment..c","cidrBlocks":"10.0.0.0/16"}`, "desired.data.cidrBlocks", classSchema}, + {"vcn: a number where a string is wanted", "cic:network:vcn", `{"compartmentId":"ocid1.compartment..c","displayName":5}`, "desired.data.displayName", classSchema}, + } +} + +func TestPlanRefusesACreateThatTheContractDoesNotAccept(t *testing.T) { + for _, c := range createCases() { + t.Run(c.name, func(t *testing.T) { + raw, _ := json.Marshal(planRequest{Kind: c.kind, + Desired: m4aPayload(c.kind+"-config", c.desired), + Observed: m4aPayload(c.kind+"-state", `{}`), + Target: bindingWith("")}) + out, err := Plan(nil, raw) + if err != nil { + t.Fatalf("Plan returned a Go error: %v", err) + } + res := decodeResult(t, out) + if res.Status != "error" || res.Error == nil { + t.Fatalf("status %q, want an error: a plan was produced for a config the contract refuses", res.Status) + } + if res.Error.Class != c.class || res.Error.FieldPath != c.field { + t.Errorf("class %q field_path %q, want %q %q", res.Error.Class, res.Error.FieldPath, c.class, c.field) + } + }) + } +} + +// A create request is changed by hand: the desired config (and the config, so the two +// still agree) loses a required field, or gains a field no caller may set, while the +// plan and digests stay as Plan made them. The gate refuses it before anything is +// signed or sent. +func TestExecuteRefusesAHandEditedCreateConfig(t *testing.T) { + good := `{"compartmentId":"ocid1.compartment..c","displayName":"x"}` + edits := []struct{ name, desired, field, class string }{ + {"required field removed", `{"displayName":"x"}`, "desired.data.compartmentId", classValidation}, + {"a field the caller cannot set added", `{"compartmentId":"ocid1.compartment..c","displayName":"x","lifecycleState":"AVAILABLE"}`, "desired.data.lifecycleState", classSchema}, + {"a wrong type", `{"compartmentId":"ocid1.compartment..c","displayName":7}`, "desired.data.displayName", classSchema}, + } + refused := func(t *testing.T, res providerResult, mock *cicFlowMock, field, class string) { + t.Helper() + if res.Status != "error" || res.Error == nil { + t.Fatalf("status %q, want an error: the request was run", res.Status) + } + if res.Error.Class != class || res.Error.FieldPath != field { + t.Errorf("class %q field_path %q, want %q %q", res.Error.Class, res.Error.FieldPath, class, field) + } + if mock.signCount() != 0 || mock.actuateCount() != 0 { + t.Errorf("%d sign / %d actuate calls, want none", mock.signCount(), mock.actuateCount()) + } + } + for _, e := range edits { + t.Run(e.name, func(t *testing.T) { + req := m4aForOp(t, bindingKind, providerOperation{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}, good, bindingWith("")) + req.Desired.Data = json.RawMessage(e.desired) + req.Config.Data = json.RawMessage(e.desired) + res, mock := runExecute(t, req) + refused(t, res, mock, e.field, e.class) + }) + } + t.Run("as request text", func(t *testing.T) { + req := m4aForOp(t, bindingKind, providerOperation{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}, good, bindingWith("")) + text := string(marshalReq(t, req)) + from := `"data":{"compartmentId":"ocid1.compartment..c","displayName":"x"}` + if strings.Count(text, from) != 2 { + t.Fatalf("test setup: want desired and config as %q in the text:\n%s", from, text) + } + res, mock := runExecuteJSON(t, []byte(strings.ReplaceAll(text, from, `"data":{"displayName":"x"}`))) + refused(t, res, mock, "desired.data.compartmentId", classValidation) + }) +} + +// The refusal does not touch the other lifecycles: an update is not held to the +// "required" rule, and a valid create still plans and runs. +func TestAnUpdateIsNotHeldToTheRequiredRuleAndAValidCreateStillRuns(t *testing.T) { + up := m4aPlan(t, bindingKind, `{"displayName":"new"}`, `{"displayName":"old"}`, bindingWith("ocid1.vcn.oc1..a")) + if up.Operation != "update" { + t.Fatalf("an update with only displayName must still plan, got %q", up.Operation) + } + req := m4aForOp(t, bindingKind, providerOperation{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}, + `{"compartmentId":"ocid1.compartment..c","displayName":"x"}`, bindingWith("")) + res, mock := runExecute(t, req) + if res.Status != "ok" || mock.actuateCount() != 1 { + t.Fatalf("status %q with %d requests (%+v), want ok with 1", res.Status, mock.actuateCount(), res.Error) + } +} + +// Several problems at once: the refusal is the same on every run (the first by field +// path), never whichever the map walk happens to meet first. +func TestTheRefusalOfACreateWithSeveralProblemsIsDeterministic(t *testing.T) { + desired := `{"zzz":1,"aaa":1,"mmm":1,"displayName":3}` // compartmentId missing as well + for i := 0; i < 40; i++ { + raw, _ := json.Marshal(planRequest{Kind: bindingKind, + Desired: m4aPayload("d", desired), + Observed: m4aPayload("o", `{}`), + Target: bindingWith("")}) + out, _ := Plan(nil, raw) + res := decodeResult(t, out) + if res.Error == nil || res.Error.FieldPath != "desired.data.aaa" { + t.Fatalf("run %d: refusal %+v, want field_path desired.data.aaa (the first by path)", i, res.Error) + } + } +} diff --git a/module/provider.go b/module/provider.go index 6de1d29..d8bca44 100644 --- a/module/provider.go +++ b/module/provider.go @@ -497,6 +497,19 @@ func planFromRequest(req planRequest, targetField string) (executionPlan, *provi // changed" and could escalate to a replace that deleted a resource never created). switch { case len(observed) == 0 && req.Target.ResourceID == "": + // A create is planned from nothing but the desired config, so the plan + // must not exist for a config the kind's contract does not accept: a + // missing required field, a field the caller cannot set, a gross type + // error. (An update diffs against an existing resource and is judged + // by what changed; Validate is where a partial intent is reviewed.) + if errs := conformIntent(c, req.Desired); len(errs) > 0 { + // conformIntent walks a map: the refusal names the first error by field + // path, so the same request always gets the same answer. + sort.Slice(errs, func(i, j int) bool { return errs[i].FieldPath < errs[j].FieldPath }) + pe := errs[0] + pe.FieldPath = "desired.data" + strings.TrimPrefix(pe.FieldPath, "intent.data") + return executionPlan{}, &pe + } name, h, ok := c.opByRole(roleCreate) if !ok { return executionPlan{}, &providerError{Class: classInternal, Message: "no create operation for kind " + req.Kind}