diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 784c207..795aaa9 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,9 +30,12 @@ 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 +2ce99d2a744e7d79e64bd7e1c9446543e7ac134d03dfbfb9f5c8fa8e10fda718 module/plan_create_conformance_test.go 2d0d9bf8861afacaec5796ebfa105b5fa8566981d732b9c146850515672f089e module/contracts.go 2d28d83b74f3b137a2e2d02d21178ad751b9b59ea81133ec42c9704ca535c986 tools/init_from_template.sh 2d71ac3c49cf88cc1d95b43948afe78d3f531e4ced96ee0675bb6fea05144dc6 docs/design/specs/host-functions.md @@ -47,11 +49,13 @@ 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 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,23 +64,25 @@ 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 +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 -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 635384b1db18a7654d1e30b660ba825645d88a89e7bc0e808500a213b584f278 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.registry.json 66181e4c6a0b2735b5a8b3088b37e6a844e2c692399a5b72b036059da0188e0a tools/releaselib/__init__.py 688dffd2264877f3a64fca1d3526c57bb34cb1c18f5a01aa906667311831a052 docs/en/workflow.md @@ -90,7 +96,6 @@ 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 74d93981f1935d21001ed0475ebb84132ab67c71840ca82ca0cbfac5aca29ea2 docs/contracts/en/wasm-abi.md @@ -99,6 +104,7 @@ 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 +112,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 +119,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 +127,14 @@ 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 +916a79713953c5dec593df2a1a0fbbd4c59a811f865f79e530c5c34a7ba53ec5 module/provider.go 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 @@ -238,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/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..db154e0 --- /dev/null +++ b/docs/design/specs/plan-digest.md @@ -0,0 +1,116 @@ +# 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` | + +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. + +## 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..c627ebc 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,20 @@ 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`. + 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. + 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..c165228 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","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","availabilityDomain":"AD-1"}`, `{}`}, + {"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_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/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..d8bca44 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,77 @@ 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 == "": + // 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} + } + 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 +551,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 +620,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 +1089,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 +1121,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 +1196,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 +1218,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 +1334,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" + } + ] +}