diff --git a/.github/buildomat/jobs/deploy.sh b/.github/buildomat/jobs/deploy.sh index ff9b44fc40..3c4b3d88c8 100755 --- a/.github/buildomat/jobs/deploy.sh +++ b/.github/buildomat/jobs/deploy.sh @@ -2,7 +2,7 @@ #: #: name = "helios / deploy" #: variety = "basic" -#: target = "lab-2.0-opte-0.25" +#: target = "lab-2.0-opte-0.27" #: output_rules = [ #: "%/var/svc/log/oxide-sled-agent:default.log*", #: "%/pool/ext/*/crypt/zone/oxz_*/root/var/svc/log/oxide-*.log*", diff --git a/Cargo.lock b/Cargo.lock index 981dd99082..a126f82300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,7 +33,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cipher", "cpufeatures", ] @@ -58,7 +58,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "once_cell", "version_check", ] @@ -381,7 +381,7 @@ checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" dependencies = [ "addr2line", "cc", - "cfg-if 1.0.0", + "cfg-if", "libc", "miniz_oxide", "object 0.32.1", @@ -847,12 +847,6 @@ dependencies = [ "nom", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.0" @@ -865,7 +859,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cipher", "cpufeatures", ] @@ -1003,6 +997,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" +[[package]] +name = "cobs" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" + [[package]] name = "colorchoice" version = "1.0.0" @@ -1125,7 +1125,7 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -1190,7 +1190,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-channel", "crossbeam-deque", "crossbeam-epoch", @@ -1204,7 +1204,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", ] @@ -1214,7 +1214,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-epoch", "crossbeam-utils", ] @@ -1226,7 +1226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" dependencies = [ "autocfg", - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", "memoffset 0.9.0", "scopeguard", @@ -1238,7 +1238,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", ] @@ -1248,7 +1248,7 @@ version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -1364,7 +1364,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bd9c8e659a473bce955ae5c35b116af38af11a7acb0b480e01f3ed348aeb40" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "memchr", ] @@ -1383,7 +1383,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622178105f911d937a42cdb140730ba4a3ed2becd8ae6ce39c7d28b5d75d4588" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", @@ -1482,7 +1482,7 @@ version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd72493923899c6f10c641bdbdeddc7183d6396641d99c1a0d1597f37f92e28" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "hashbrown 0.14.2", "lock_api", "once_cell", @@ -1550,6 +1550,38 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe7ed1d93f4553003e20b629abe9085e1e81b1429520f897f8f8860bc6dfc21" +[[package]] +name = "defmt" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2d011b2fee29fb7d659b83c43fce9a2cb4df453e16d441a51448e448f3f98" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0216f6c5acb5ae1a47050a6645024e6edafc2ee32d421955eccfef12ef92e" +dependencies = [ + "defmt-parser", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "defmt-parser" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269924c02afd7f94bc4cecbfa5c379f6ffcf9766b3408fe63d22c728654eccd0" +dependencies = [ + "thiserror", +] + [[package]] name = "der" version = "0.7.8" @@ -1729,7 +1761,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "dirs-sys-next", ] @@ -2007,6 +2039,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + [[package]] name = "ena" version = "0.14.2" @@ -2028,7 +2066,7 @@ version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -2150,7 +2188,7 @@ version = "3.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "rustix 0.38.25", "windows-sys 0.48.0", ] @@ -2177,7 +2215,7 @@ version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "redox_syscall 0.4.1", "windows-sys 0.52.0", @@ -2554,7 +2592,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] @@ -2565,7 +2603,7 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", @@ -3061,7 +3099,7 @@ dependencies = [ [[package]] name = "illumos-sys-hdrs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" [[package]] name = "illumos-utils" @@ -3073,7 +3111,7 @@ dependencies = [ "byteorder", "camino", "camino-tempfile", - "cfg-if 1.0.0", + "cfg-if", "crucible-smf", "futures", "ipnetwork", @@ -3276,7 +3314,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -3464,10 +3502,10 @@ dependencies = [ [[package]] name = "kstat-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.32", ] [[package]] @@ -3557,7 +3595,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "winapi", ] @@ -3573,7 +3611,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/netadm-sys#f114bd0d543d886cd453932e9f0967de57289bc2" dependencies = [ "anyhow", - "cfg-if 1.0.0", + "cfg-if", "colored", "dlpi", "libc", @@ -3852,7 +3890,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "downcast", "fragile", "lazy_static", @@ -3867,7 +3905,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "proc-macro2", "quote", "syn 1.0.109", @@ -4200,7 +4238,7 @@ version = "0.26.2" source = "git+https://github.com/jgallagher/nix?branch=r0.26-illumos#c1a3636db0524f194b714cfd117cd9b637b8b10e" dependencies = [ "bitflags 1.3.2", - "cfg-if 1.0.0", + "cfg-if", "libc", "memoffset 0.7.1", "pin-utils", @@ -4808,7 +4846,7 @@ dependencies = [ "camino", "camino-tempfile", "cancel-safe-futures", - "cfg-if 1.0.0", + "cfg-if", "chrono", "clap 4.4.3", "crucible-agent-client", @@ -5093,7 +5131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79a4c6c3a2b158f7f8f2a2fc5a969fa3a068df6fc9dbb4a43845436e3af7c800" dependencies = [ "bitflags 2.4.0", - "cfg-if 1.0.0", + "cfg-if", "foreign-types 0.3.2", "libc", "once_cell", @@ -5133,37 +5171,35 @@ dependencies = [ [[package]] name = "opte" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "dyn-clone", "illumos-sys-hdrs", "kstat-macro", "opte-api", "postcard", "serde", - "smoltcp 0.8.2", + "smoltcp 0.10.0", "version_check", - "zerocopy 0.6.4", ] [[package]] name = "opte-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" dependencies = [ - "cfg-if 0.1.10", "illumos-sys-hdrs", "ipnetwork", "postcard", "serde", - "smoltcp 0.8.2", + "smoltcp 0.10.0", ] [[package]] name = "opte-ioctl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" dependencies = [ "libc", "libnet", @@ -5237,14 +5273,13 @@ dependencies = [ [[package]] name = "oxide-vpc" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=258a8b59902dd36fc7ee5425e6b1fb5fc80d4649#258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" +source = "git+https://github.com/oxidecomputer/opte?rev=24ceba1969269e4d81bda83d8968d7d7f713c46b#24ceba1969269e4d81bda83d8968d7d7f713c46b" dependencies = [ - "cfg-if 0.1.10", "illumos-sys-hdrs", "opte", "serde", - "smoltcp 0.8.2", - "zerocopy 0.6.4", + "smoltcp 0.10.0", + "zerocopy 0.7.26", ] [[package]] @@ -5363,7 +5398,7 @@ dependencies = [ name = "oximeter-instruments" version = "0.1.0" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "chrono", "dropshot", "futures", @@ -5471,7 +5506,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "instant", "libc", "redox_syscall 0.2.16", @@ -5485,7 +5520,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "redox_syscall 0.3.5", "smallvec 1.11.0", @@ -5834,7 +5869,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "opaque-debug", "universal-hash", @@ -5848,20 +5883,15 @@ checksum = "31114a898e107c51bb1609ffaf55a0e011cf6a4d7f1170d0015a165082c0338b" [[package]] name = "postcard" -version = "0.7.3" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25c0b0ae06fcffe600ad392aabfa535696c8973f2253d9ac83171924c58a858" +checksum = "a55c51ee6c0db07e68448e336cf8ea4131a620edefebf9893e759b2d793420f8" dependencies = [ - "postcard-cobs", + "cobs", + "embedded-io", "serde", ] -[[package]] -name = "postcard-cobs" -version = "0.1.5-pre" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c68cb38ed13fd7bc9dd5db8f165b7c8d9c1a315104083a2b10f11354c2af97f" - [[package]] name = "postgres-protocol" version = "0.6.6" @@ -6702,7 +6732,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "glob", "proc-macro2", "quote", @@ -7369,7 +7399,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest", ] @@ -7380,7 +7410,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest", ] @@ -7541,7 +7571,7 @@ version = "0.1.0" dependencies = [ "anyhow", "camino", - "cfg-if 1.0.0", + "cfg-if", "futures", "illumos-devinfo", "illumos-utils", @@ -7569,7 +7599,7 @@ dependencies = [ "async-trait", "camino", "camino-tempfile", - "cfg-if 1.0.0", + "cfg-if", "derive_more", "glob", "illumos-utils", @@ -7726,24 +7756,27 @@ dependencies = [ [[package]] name = "smoltcp" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee34c1e1bfc7e9206cc0fb8030a90129b4e319ab53856249bb27642cab914fb3" +checksum = "7e9786ac45091b96f946693e05bfa4d8ca93e2d3341237d97a380107a6b38dea" dependencies = [ "bitflags 1.3.2", "byteorder", + "cfg-if", + "heapless", "managed", ] [[package]] name = "smoltcp" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e9786ac45091b96f946693e05bfa4d8ca93e2d3341237d97a380107a6b38dea" +checksum = "8d2e3a36ac8fea7b94e666dfa3871063d6e0a5c9d5d4fec9a1a6b7b6760f0229" dependencies = [ "bitflags 1.3.2", "byteorder", - "cfg-if 1.0.0", + "cfg-if", + "defmt", "heapless", "managed", ] @@ -8178,7 +8211,7 @@ version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "fastrand", "redox_syscall 0.4.1", "rustix 0.38.25", @@ -8319,7 +8352,7 @@ version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "once_cell", ] @@ -8685,7 +8718,7 @@ version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "log", "pin-project-lite", "tracing-attributes", @@ -8718,7 +8751,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c408c32e6a9dbb38037cece35740f2cf23c875d8ca134d33631cec83f74d3fe" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "data-encoding", "futures-channel", "futures-util", @@ -8739,7 +8772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" dependencies = [ "async-trait", - "cfg-if 1.0.0", + "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", @@ -8763,7 +8796,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aff21aa4dcefb0a1afbfac26deb0adc93888c7d295fb63ab273ef276ba2b7cfe" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "futures-util", "ipconfig", "lazy_static", @@ -8785,7 +8818,7 @@ checksum = "99022f9befa6daec2a860be68ac28b1f0d9d7ccf441d8c5a695e35a58d88840d" dependencies = [ "async-trait", "bytes", - "cfg-if 1.0.0", + "cfg-if", "enum-as-inner", "futures-executor", "futures-util", @@ -9300,7 +9333,7 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "wasm-bindgen-macro", ] @@ -9325,7 +9358,7 @@ version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "js-sys", "wasm-bindgen", "web-sys", @@ -9847,7 +9880,7 @@ version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "windows-sys 0.48.0", ] @@ -9928,6 +9961,16 @@ dependencies = [ "zerocopy-derive 0.6.4", ] +[[package]] +name = "zerocopy" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.26", +] + [[package]] name = "zerocopy-derive" version = "0.2.0" @@ -9950,6 +9993,17 @@ dependencies = [ "syn 2.0.32", ] +[[package]] +name = "zerocopy-derive" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 3a80367806..c0935aec6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -260,7 +260,7 @@ omicron-sled-agent = { path = "sled-agent" } omicron-test-utils = { path = "test-utils" } omicron-zone-package = "0.9.1" oxide-client = { path = "clients/oxide-client" } -oxide-vpc = { git = "https://github.com/oxidecomputer/opte", rev = "258a8b59902dd36fc7ee5425e6b1fb5fc80d4649", features = [ "api", "std" ] } +oxide-vpc = { git = "https://github.com/oxidecomputer/opte", rev = "24ceba1969269e4d81bda83d8968d7d7f713c46b", features = [ "api", "std" ] } once_cell = "1.18.0" openapi-lint = { git = "https://github.com/oxidecomputer/openapi-lint", branch = "main" } openapiv3 = "2.0.0-rc.1" @@ -268,7 +268,7 @@ openapiv3 = "2.0.0-rc.1" openssl = "0.10" openssl-sys = "0.9" openssl-probe = "0.1.5" -opte-ioctl = { git = "https://github.com/oxidecomputer/opte", rev = "258a8b59902dd36fc7ee5425e6b1fb5fc80d4649" } +opte-ioctl = { git = "https://github.com/oxidecomputer/opte", rev = "24ceba1969269e4d81bda83d8968d7d7f713c46b" } oso = "0.27" owo-colors = "3.5.0" oximeter = { path = "oximeter/oximeter" } diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index db5272cd6e..50516a5da4 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -752,6 +752,7 @@ pub enum ResourceType { Zpool, Vmm, Ipv4NatEntry, + FloatingIp, } // IDENTITY METADATA diff --git a/illumos-utils/src/opte/port_manager.rs b/illumos-utils/src/opte/port_manager.rs index f0a8d8d839..3558ef1c78 100644 --- a/illumos-utils/src/opte/port_manager.rs +++ b/illumos-utils/src/opte/port_manager.rs @@ -20,6 +20,7 @@ use omicron_common::api::internal::shared::NetworkInterfaceKind; use omicron_common::api::internal::shared::SourceNatConfig; use oxide_vpc::api::AddRouterEntryReq; use oxide_vpc::api::DhcpCfg; +use oxide_vpc::api::ExternalIpCfg; use oxide_vpc::api::IpCfg; use oxide_vpc::api::IpCidr; use oxide_vpc::api::Ipv4Cfg; @@ -99,7 +100,8 @@ impl PortManager { &self, nic: &NetworkInterface, source_nat: Option, - external_ips: &[IpAddr], + ephemeral_ip: Option, + floating_ips: &[IpAddr], firewall_rules: &[VpcFirewallRule], dhcp_config: DhcpCfg, ) -> Result<(Port, PortTicket), Error> { @@ -111,13 +113,6 @@ impl PortManager { let boundary_services = default_boundary_services(); // Describe the external IP addresses for this port. - // - // Note that we're currently only taking the first address, which is all - // that OPTE supports. The array is guaranteed to be limited by Nexus. - // See https://github.com/oxidecomputer/omicron/issues/1467 - // See https://github.com/oxidecomputer/opte/issues/196 - let external_ip = external_ips.get(0); - macro_rules! ip_cfg { ($ip:expr, $log_prefix:literal, $ip_t:path, $cidr_t:path, $ipcfg_e:path, $ipcfg_t:ident, $snat_t:ident) => {{ @@ -152,25 +147,43 @@ impl PortManager { } None => None, }; - let external_ip = match external_ip { - Some($ip_t(ip)) => Some((*ip).into()), + let ephemeral_ip = match ephemeral_ip { + Some($ip_t(ip)) => Some(ip.into()), Some(_) => { error!( self.inner.log, - concat!($log_prefix, " external IP"); - "external_ip" => ?external_ip, + concat!($log_prefix, " ephemeral IP"); + "ephemeral_ip" => ?ephemeral_ip, ); return Err(Error::InvalidPortIpConfig); } None => None, }; + let floating_ips: Vec<_> = floating_ips + .iter() + .copied() + .map(|ip| match ip { + $ip_t(ip) => Ok(ip.into()), + _ => { + error!( + self.inner.log, + concat!($log_prefix, " ephemeral IP"); + "ephemeral_ip" => ?ephemeral_ip, + ); + Err(Error::InvalidPortIpConfig) + } + }) + .collect::, _>>()?; $ipcfg_e($ipcfg_t { vpc_subnet, private_ip: $ip.into(), gateway_ip: gateway_ip.into(), - snat, - external_ips: external_ip, + external_ips: ExternalIpCfg { + ephemeral_ip, + snat, + floating_ips, + }, }) }} } diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index 1152e0109c..1a755f0396 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -7,10 +7,12 @@ use crate::impl_enum_type; use crate::schema::external_ip; +use crate::schema::floating_ip; use crate::Name; use crate::SqlU16; use chrono::DateTime; use chrono::Utc; +use db_macros::Resource; use diesel::Queryable; use diesel::Selectable; use ipnetwork::IpNetwork; @@ -18,6 +20,9 @@ use nexus_types::external_api::shared; use nexus_types::external_api::views; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_common::api::external::Error; +use omicron_common::api::external::IdentityMetadata; +use serde::Deserialize; +use serde::Serialize; use std::convert::TryFrom; use std::net::IpAddr; use uuid::Uuid; @@ -69,6 +74,30 @@ pub struct ExternalIp { pub ip: IpNetwork, pub first_port: SqlU16, pub last_port: SqlU16, + // Only Some(_) for instance Floating IPs + pub project_id: Option, +} + +/// A view type constructed from `ExternalIp` used to represent Floating IP +/// objects in user-facing APIs. +/// +/// This View type fills a similar niche to `ProjectImage` etc.: we need to +/// represent identity as non-nullable (ditto for parent project) so as to +/// play nicely with authz and resource APIs. +#[derive( + Queryable, Selectable, Clone, Debug, Resource, Serialize, Deserialize, +)] +#[diesel(table_name = floating_ip)] +pub struct FloatingIp { + #[diesel(embed)] + pub identity: FloatingIpIdentity, + + pub ip_pool_id: Uuid, + pub ip_pool_range_id: Uuid, + pub is_service: bool, + pub parent_id: Option, + pub ip: IpNetwork, + pub project_id: Uuid, } impl From for sled_agent_client::types::SourceNatConfig { @@ -93,6 +122,7 @@ pub struct IncompleteExternalIp { is_service: bool, parent_id: Option, pool_id: Uuid, + project_id: Option, // Optional address requesting that a specific IP address be allocated. explicit_ip: Option, // Optional range when requesting a specific SNAT range be allocated. @@ -114,6 +144,7 @@ impl IncompleteExternalIp { is_service: false, parent_id: Some(instance_id), pool_id, + project_id: None, explicit_ip: None, explicit_port_range: None, } @@ -129,6 +160,7 @@ impl IncompleteExternalIp { is_service: false, parent_id: Some(instance_id), pool_id, + project_id: None, explicit_ip: None, explicit_port_range: None, } @@ -138,6 +170,7 @@ impl IncompleteExternalIp { id: Uuid, name: &Name, description: &str, + project_id: Uuid, pool_id: Uuid, ) -> Self { Self { @@ -149,11 +182,35 @@ impl IncompleteExternalIp { is_service: false, parent_id: None, pool_id, + project_id: Some(project_id), explicit_ip: None, explicit_port_range: None, } } + pub fn for_floating_explicit( + id: Uuid, + name: &Name, + description: &str, + project_id: Uuid, + explicit_ip: IpAddr, + pool_id: Uuid, + ) -> Self { + Self { + id, + name: Some(name.clone()), + description: Some(description.to_string()), + time_created: Utc::now(), + kind: IpKind::Floating, + is_service: false, + parent_id: None, + pool_id, + project_id: Some(project_id), + explicit_ip: Some(explicit_ip.into()), + explicit_port_range: None, + } + } + pub fn for_service_explicit( id: Uuid, name: &Name, @@ -171,6 +228,7 @@ impl IncompleteExternalIp { is_service: true, parent_id: Some(service_id), pool_id, + project_id: None, explicit_ip: Some(IpNetwork::from(address)), explicit_port_range: None, } @@ -199,6 +257,7 @@ impl IncompleteExternalIp { is_service: true, parent_id: Some(service_id), pool_id, + project_id: None, explicit_ip: Some(IpNetwork::from(address)), explicit_port_range, } @@ -220,6 +279,7 @@ impl IncompleteExternalIp { is_service: true, parent_id: Some(service_id), pool_id, + project_id: None, explicit_ip: None, explicit_port_range: None, } @@ -235,6 +295,7 @@ impl IncompleteExternalIp { is_service: true, parent_id: Some(service_id), pool_id, + project_id: None, explicit_ip: None, explicit_port_range: None, } @@ -272,6 +333,10 @@ impl IncompleteExternalIp { &self.pool_id } + pub fn project_id(&self) -> &Option { + &self.project_id + } + pub fn explicit_ip(&self) -> &Option { &self.explicit_ip } @@ -308,3 +373,78 @@ impl TryFrom for views::ExternalIp { Ok(views::ExternalIp { kind, ip: ip.ip.ip() }) } } + +impl TryFrom for FloatingIp { + type Error = Error; + + fn try_from(ip: ExternalIp) -> Result { + if ip.kind != IpKind::Floating { + return Err(Error::internal_error( + "attempted to convert non-floating external IP to floating", + )); + } + if ip.is_service { + return Err(Error::internal_error( + "Service IPs should not be exposed in the API", + )); + } + + let project_id = ip.project_id.ok_or(Error::internal_error( + "database schema guarantees parent project for non-service FIP", + ))?; + + let name = ip.name.ok_or(Error::internal_error( + "database schema guarantees ID metadata for non-service FIP", + ))?; + + let description = ip.description.ok_or(Error::internal_error( + "database schema guarantees ID metadata for non-service FIP", + ))?; + + let identity = FloatingIpIdentity { + id: ip.id, + name, + description, + time_created: ip.time_created, + time_modified: ip.time_modified, + time_deleted: ip.time_deleted, + }; + + Ok(FloatingIp { + ip: ip.ip, + identity, + project_id, + ip_pool_id: ip.ip_pool_id, + ip_pool_range_id: ip.ip_pool_range_id, + is_service: ip.is_service, + parent_id: ip.parent_id, + }) + } +} + +impl TryFrom for views::FloatingIp { + type Error = Error; + + fn try_from(ip: ExternalIp) -> Result { + FloatingIp::try_from(ip).map(Into::into) + } +} + +impl From for views::FloatingIp { + fn from(ip: FloatingIp) -> Self { + let identity = IdentityMetadata { + id: ip.identity.id, + name: ip.identity.name.into(), + description: ip.identity.description, + time_created: ip.identity.time_created, + time_modified: ip.identity.time_modified, + }; + + views::FloatingIp { + ip: ip.ip.ip(), + identity, + project_id: ip.project_id, + instance_id: ip.parent_id, + } + } +} diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 373785799e..51501b4894 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -525,6 +525,7 @@ table! { time_created -> Timestamptz, time_modified -> Timestamptz, time_deleted -> Nullable, + ip_pool_id -> Uuid, ip_pool_range_id -> Uuid, is_service -> Bool, @@ -533,6 +534,26 @@ table! { ip -> Inet, first_port -> Int4, last_port -> Int4, + + project_id -> Nullable, + } +} + +table! { + floating_ip (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + + ip_pool_id -> Uuid, + ip_pool_range_id -> Uuid, + is_service -> Bool, + parent_id -> Nullable, + ip -> Inet, + project_id -> Uuid, } } @@ -1301,7 +1322,7 @@ table! { /// /// This should be updated whenever the schema is changed. For more details, /// refer to: schema/crdb/README.adoc -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(18, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(19, 0, 0); allow_tables_to_appear_in_same_query!( system_update, diff --git a/nexus/db-queries/src/authz/api_resources.rs b/nexus/db-queries/src/authz/api_resources.rs index b22fe1ac25..2dfe2f7174 100644 --- a/nexus/db-queries/src/authz/api_resources.rs +++ b/nexus/db-queries/src/authz/api_resources.rs @@ -791,6 +791,14 @@ authz_resource! { polar_snippet = InProject, } +authz_resource! { + name = "FloatingIp", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProject, +} + // Customer network integration resources nested below "Fleet" authz_resource! { diff --git a/nexus/db-queries/src/authz/oso_generic.rs b/nexus/db-queries/src/authz/oso_generic.rs index e642062ead..6098379287 100644 --- a/nexus/db-queries/src/authz/oso_generic.rs +++ b/nexus/db-queries/src/authz/oso_generic.rs @@ -131,6 +131,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { VpcRouter::init(), RouterRoute::init(), VpcSubnet::init(), + FloatingIp::init(), // Silo-level resources Image::init(), SiloImage::init(), diff --git a/nexus/db-queries/src/authz/policy_test/resources.rs b/nexus/db-queries/src/authz/policy_test/resources.rs index 3049f3b9bf..8bdd97923b 100644 --- a/nexus/db-queries/src/authz/policy_test/resources.rs +++ b/nexus/db-queries/src/authz/policy_test/resources.rs @@ -319,6 +319,13 @@ async fn make_project( Uuid::new_v4(), LookupType::ByName(image_name), )); + + let floating_ip_name = format!("{project_name}-fip1"); + builder.new_resource(authz::FloatingIp::new( + project.clone(), + Uuid::new_v4(), + LookupType::ByName(floating_ip_name), + )); } /// Returns the set of authz classes exempted from the coverage test diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 4e34bfc15c..e821082501 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -15,9 +15,11 @@ use crate::db::error::ErrorHandler; use crate::db::error::TransactionError; use crate::db::lookup::LookupPath; use crate::db::model::ExternalIp; +use crate::db::model::FloatingIp; use crate::db::model::IncompleteExternalIp; use crate::db::model::IpKind; use crate::db::model::Name; +use crate::db::pagination::paginated; use crate::db::pool::DbConnection; use crate::db::queries::external_ip::NextExternalIp; use crate::db::update_and_check::UpdateAndCheck; @@ -25,10 +27,18 @@ use crate::db::update_and_check::UpdateStatus; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use nexus_types::external_api::params; use nexus_types::identity::Resource; +use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; +use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NameOrId; +use omicron_common::api::external::ResourceType; +use omicron_common::api::external::UpdateResult; +use ref_cast::RefCast; use std::net::IpAddr; use uuid::Uuid; @@ -128,6 +138,56 @@ impl DataStore { self.allocate_external_ip(opctx, data).await } + /// Allocates a floating IP address for instance usage. + pub async fn allocate_floating_ip( + &self, + opctx: &OpContext, + project_id: Uuid, + params: params::FloatingIpCreate, + ) -> CreateResult { + let ip_id = Uuid::new_v4(); + + let pool_id = match params.pool { + Some(NameOrId::Name(name)) => { + LookupPath::new(opctx, self) + .ip_pool_name(&Name(name)) + .fetch_for(authz::Action::Read) + .await? + .1 + } + Some(NameOrId::Id(id)) => { + LookupPath::new(opctx, self) + .ip_pool_id(id) + .fetch_for(authz::Action::Read) + .await? + .1 + } + None => self.ip_pools_fetch_default(opctx).await?, + } + .id(); + + let data = if let Some(ip) = params.address { + IncompleteExternalIp::for_floating_explicit( + ip_id, + &Name(params.identity.name), + ¶ms.identity.description, + project_id, + ip, + pool_id, + ) + } else { + IncompleteExternalIp::for_floating( + ip_id, + &Name(params.identity.name), + ¶ms.identity.description, + project_id, + pool_id, + ) + }; + + self.allocate_external_ip(opctx, data).await + } + async fn allocate_external_ip( &self, opctx: &OpContext, @@ -144,8 +204,13 @@ impl DataStore { conn: &async_bb8_diesel::Connection, data: IncompleteExternalIp, ) -> Result> { + use diesel::result::DatabaseErrorKind::UniqueViolation; + // Name needs to be cloned out here (if present) to give users a + // sensible error message on name collision. + let name = data.name().clone(); let explicit_ip = data.explicit_ip().is_some(); NextExternalIp::new(data).get_result_async(conn).await.map_err(|e| { + use diesel::result::Error::DatabaseError; use diesel::result::Error::NotFound; match e { NotFound => { @@ -159,6 +224,17 @@ impl DataStore { )) } } + DatabaseError(UniqueViolation, ..) if name.is_some() => { + TransactionError::CustomError(public_error_from_diesel( + e, + ErrorHandler::Conflict( + ResourceType::FloatingIp, + name.as_ref() + .map(|m| m.as_str()) + .unwrap_or_default(), + ), + )) + } _ => { if retryable(&e) { return TransactionError::Database(e); @@ -255,8 +331,6 @@ impl DataStore { /// This method returns the number of records deleted, rather than the usual /// `DeleteResult`. That's mostly useful for tests, but could be important /// if callers have some invariants they'd like to check. - // TODO-correctness: This can't be used for Floating IPs, we'll need a - // _detatch_ method for that. pub async fn deallocate_external_ip_by_instance_id( &self, opctx: &OpContext, @@ -275,6 +349,27 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Detach an individual Floating IP address from its parent instance. + /// + /// As in `deallocate_external_ip_by_instance_id`, this method returns the + /// number of records altered, rather than an `UpdateResult`. + pub async fn detach_floating_ips_by_instance_id( + &self, + opctx: &OpContext, + instance_id: Uuid, + ) -> Result { + use db::schema::external_ip::dsl; + diesel::update(dsl::external_ip) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::is_service.eq(false)) + .filter(dsl::parent_id.eq(instance_id)) + .filter(dsl::kind.eq(IpKind::Floating)) + .set(dsl::parent_id.eq(Option::::None)) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// Fetch all external IP addresses of any kind for the provided instance pub async fn instance_lookup_external_ips( &self, @@ -291,4 +386,167 @@ impl DataStore { .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + + /// Fetch all Floating IP addresses for the provided project. + pub async fn floating_ips_list( + &self, + opctx: &OpContext, + authz_project: &authz::Project, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + use db::schema::floating_ip::dsl; + + opctx.authorize(authz::Action::ListChildren, authz_project).await?; + + match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(dsl::floating_ip, dsl::id, &pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + dsl::floating_ip, + dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + .filter(dsl::project_id.eq(authz_project.id())) + .filter(dsl::time_deleted.is_null()) + .select(FloatingIp::as_select()) + .get_results_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + + /// Delete a Floating IP, verifying first that it is not in use. + pub async fn floating_ip_delete( + &self, + opctx: &OpContext, + authz_fip: &authz::FloatingIp, + db_fip: &FloatingIp, + ) -> DeleteResult { + use db::schema::external_ip::dsl; + + // Verify this FIP is not attached to any instances/services. + if db_fip.parent_id.is_some() { + return Err(Error::invalid_request( + "Floating IP cannot be deleted while attached to an instance", + )); + } + + opctx.authorize(authz::Action::Delete, authz_fip).await?; + + let now = Utc::now(); + let updated_rows = diesel::update(dsl::external_ip) + .filter(dsl::id.eq(db_fip.id())) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.is_null()) + .set(dsl::time_deleted.eq(now)) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByResource(authz_fip), + ) + })?; + + if updated_rows == 0 { + return Err(Error::InvalidRequest { + message: "deletion failed due to concurrent modification" + .to_string(), + }); + } + Ok(()) + } + + /// Attaches a Floating IP address to an instance. + pub async fn floating_ip_attach( + &self, + opctx: &OpContext, + authz_fip: &authz::FloatingIp, + db_fip: &FloatingIp, + instance_id: Uuid, + ) -> UpdateResult { + use db::schema::external_ip::dsl; + + // Verify this FIP is not attached to any instances/services. + if db_fip.parent_id.is_some() { + return Err(Error::invalid_request( + "Floating IP cannot be attached to one instance while still attached to another", + )); + } + + let (.., authz_instance, _db_instance) = LookupPath::new(&opctx, self) + .instance_id(instance_id) + .fetch_for(authz::Action::Modify) + .await?; + + opctx.authorize(authz::Action::Modify, authz_fip).await?; + opctx.authorize(authz::Action::Modify, &authz_instance).await?; + + diesel::update(dsl::external_ip) + .filter(dsl::id.eq(db_fip.id())) + .filter(dsl::kind.eq(IpKind::Floating)) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.is_null()) + .set(( + dsl::parent_id.eq(Some(instance_id)), + dsl::time_modified.eq(Utc::now()), + )) + .returning(ExternalIp::as_returning()) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByResource(authz_fip), + ) + }) + .and_then(|r| FloatingIp::try_from(r)) + .map_err(|e| Error::internal_error(&format!("{e}"))) + } + + /// Detaches a Floating IP address from an instance. + pub async fn floating_ip_detach( + &self, + opctx: &OpContext, + authz_fip: &authz::FloatingIp, + db_fip: &FloatingIp, + ) -> UpdateResult { + use db::schema::external_ip::dsl; + + let Some(instance_id) = db_fip.parent_id else { + return Err(Error::invalid_request( + "Floating IP is not attached to an instance", + )); + }; + + let (.., authz_instance, _db_instance) = LookupPath::new(&opctx, self) + .instance_id(instance_id) + .fetch_for(authz::Action::Modify) + .await?; + + opctx.authorize(authz::Action::Modify, authz_fip).await?; + opctx.authorize(authz::Action::Modify, &authz_instance).await?; + + diesel::update(dsl::external_ip) + .filter(dsl::id.eq(db_fip.id())) + .filter(dsl::kind.eq(IpKind::Floating)) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.eq(instance_id)) + .set(( + dsl::parent_id.eq(Option::::None), + dsl::time_modified.eq(Utc::now()), + )) + .returning(ExternalIp::as_returning()) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByResource(authz_fip), + ) + }) + .and_then(|r| FloatingIp::try_from(r)) + .map_err(|e| Error::internal_error(&format!("{e}"))) + } } diff --git a/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs b/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs index 274937b299..1caf5617bb 100644 --- a/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs +++ b/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs @@ -36,6 +36,7 @@ impl DataStore { .filter(dsl::sled_address.eq(nat_entry.sled_address)) .filter(dsl::vni.eq(nat_entry.vni)) .filter(dsl::mac.eq(nat_entry.mac)) + .filter(dsl::version_removed.is_null()) .select(( dsl::external_address, dsl::first_port, @@ -275,7 +276,7 @@ mod test { use crate::db::datastore::datastore_test; use chrono::Utc; - use nexus_db_model::{Ipv4NatValues, MacAddr, Vni}; + use nexus_db_model::{Ipv4NatEntry, Ipv4NatValues, MacAddr, Vni}; use nexus_test_utils::db::test_setup_database; use omicron_common::api::external; use omicron_test_utils::dev; @@ -427,7 +428,6 @@ mod test { datastore.ipv4_nat_list_since_version(&opctx, 0, 10).await.unwrap(); assert_eq!(nat_entries.len(), 1); - // version should be unchanged assert_eq!( datastore.ipv4_nat_current_version(&opctx).await.unwrap(), @@ -437,4 +437,150 @@ mod test { db.cleanup().await.unwrap(); logctx.cleanup_successful(); } + + #[tokio::test] + /// Table design and queries should only insert one active NAT entry for a given + /// set of properties, but allow multiple deleted nat entries for the same set + /// of properties. + async fn table_allows_unique_active_multiple_deleted() { + let logctx = dev::test_setup_log("test_nat_version_tracking"); + let mut db = test_setup_database(&logctx.log).await; + let (opctx, datastore) = datastore_test(&logctx, &db).await; + + // We should not have any NAT entries at this moment + let initial_state = + datastore.ipv4_nat_list_since_version(&opctx, 0, 10).await.unwrap(); + + assert!(initial_state.is_empty()); + assert_eq!( + datastore.ipv4_nat_current_version(&opctx).await.unwrap(), + 0 + ); + + // Each change (creation / deletion) to the NAT table should increment the + // version number of the row in the NAT table + let external_address = external::Ipv4Net( + ipnetwork::Ipv4Network::try_from("10.0.0.100").unwrap(), + ); + + let sled_address = external::Ipv6Net( + ipnetwork::Ipv6Network::try_from("fd00:1122:3344:104::1").unwrap(), + ); + + // Add a nat entry. + let nat1 = Ipv4NatValues { + external_address: external_address.into(), + first_port: 0.into(), + last_port: 999.into(), + sled_address: sled_address.into(), + vni: Vni(external::Vni::random()), + mac: MacAddr( + external::MacAddr::from_str("A8:40:25:F5:EB:2A").unwrap(), + ), + }; + + datastore.ensure_ipv4_nat_entry(&opctx, nat1.clone()).await.unwrap(); + + // Try to add it again. It should still only result in a single entry. + datastore.ensure_ipv4_nat_entry(&opctx, nat1.clone()).await.unwrap(); + let first_entry = datastore + .ipv4_nat_find_by_values(&opctx, nat1.clone()) + .await + .unwrap(); + + let nat_entries = + datastore.ipv4_nat_list_since_version(&opctx, 0, 10).await.unwrap(); + + // The NAT table has undergone one change. One entry has been added, + // none deleted, so we should be at version 1. + assert_eq!(nat_entries.len(), 1); + assert_eq!(nat_entries.last().unwrap().version_added, 1); + assert_eq!( + datastore.ipv4_nat_current_version(&opctx).await.unwrap(), + 1 + ); + + datastore.ipv4_nat_delete(&opctx, &first_entry).await.unwrap(); + + // The NAT table has undergone two changes. One entry has been added, + // then deleted, so we should be at version 2. + let nat_entries = datastore + .ipv4_nat_list_since_version(&opctx, 0, 10) + .await + .unwrap() + .into_iter(); + + let active: Vec = nat_entries + .clone() + .filter(|entry| entry.version_removed.is_none()) + .collect(); + + let inactive: Vec = nat_entries + .filter(|entry| entry.version_removed.is_some()) + .collect(); + + assert!(active.is_empty()); + assert_eq!(inactive.len(), 1); + assert_eq!( + datastore.ipv4_nat_current_version(&opctx).await.unwrap(), + 2 + ); + + // Add the same entry back. This simulates the behavior we will see + // when stopping and then restarting an instance. + datastore.ensure_ipv4_nat_entry(&opctx, nat1.clone()).await.unwrap(); + + // The NAT table has undergone three changes. + let nat_entries = datastore + .ipv4_nat_list_since_version(&opctx, 0, 10) + .await + .unwrap() + .into_iter(); + + let active: Vec = nat_entries + .clone() + .filter(|entry| entry.version_removed.is_none()) + .collect(); + + let inactive: Vec = nat_entries + .filter(|entry| entry.version_removed.is_some()) + .collect(); + + assert_eq!(active.len(), 1); + assert_eq!(inactive.len(), 1); + assert_eq!( + datastore.ipv4_nat_current_version(&opctx).await.unwrap(), + 3 + ); + + let second_entry = + datastore.ipv4_nat_find_by_values(&opctx, nat1).await.unwrap(); + datastore.ipv4_nat_delete(&opctx, &second_entry).await.unwrap(); + + // The NAT table has undergone four changes + let nat_entries = datastore + .ipv4_nat_list_since_version(&opctx, 0, 10) + .await + .unwrap() + .into_iter(); + + let active: Vec = nat_entries + .clone() + .filter(|entry| entry.version_removed.is_none()) + .collect(); + + let inactive: Vec = nat_entries + .filter(|entry| entry.version_removed.is_some()) + .collect(); + + assert_eq!(active.len(), 0); + assert_eq!(inactive.len(), 2); + assert_eq!( + datastore.ipv4_nat_current_version(&opctx).await.unwrap(), + 4 + ); + + db.cleanup().await.unwrap(); + logctx.cleanup_successful(); + } } diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 2e7f9da5b7..2844285f40 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -1661,6 +1661,7 @@ mod test { time_deleted: None, ip_pool_id: Uuid::new_v4(), ip_pool_range_id: Uuid::new_v4(), + project_id: None, is_service: false, parent_id: Some(instance_id), kind: IpKind::Ephemeral, @@ -1721,6 +1722,7 @@ mod test { time_deleted: None, ip_pool_id: Uuid::new_v4(), ip_pool_range_id: Uuid::new_v4(), + project_id: None, is_service: false, parent_id: Some(Uuid::new_v4()), kind: IpKind::SNat, @@ -1767,6 +1769,7 @@ mod test { use crate::db::model::IpKind; use crate::db::schema::external_ip::dsl; use diesel::result::DatabaseErrorKind::CheckViolation; + use diesel::result::DatabaseErrorKind::UniqueViolation; use diesel::result::Error::DatabaseError; let logctx = dev::test_setup_log("test_external_ip_check_constraints"); @@ -1791,6 +1794,7 @@ mod test { time_deleted: None, ip_pool_id: Uuid::new_v4(), ip_pool_range_id: Uuid::new_v4(), + project_id: None, is_service: false, parent_id: Some(Uuid::new_v4()), kind: IpKind::Floating, @@ -1803,151 +1807,190 @@ mod test { // - name // - description // - parent (instance / service) UUID - let names = [ - None, - Some(db::model::Name(Name::try_from("foo".to_string()).unwrap())), - ]; + // - project UUID + let names = [None, Some("foo")]; let descriptions = [None, Some("foo".to_string())]; let parent_ids = [None, Some(Uuid::new_v4())]; + let project_ids = [None, Some(Uuid::new_v4())]; + + let mut seen_pairs = HashSet::new(); // For Floating IPs, both name and description must be non-NULL - for name in names.iter() { - for description in descriptions.iter() { - for parent_id in parent_ids.iter() { - for is_service in [false, true] { - let new_ip = ExternalIp { - id: Uuid::new_v4(), - name: name.clone(), - description: description.clone(), - ip: addresses.next().unwrap().into(), - is_service, - parent_id: *parent_id, - ..ip - }; - let res = diesel::insert_into(dsl::external_ip) - .values(new_ip) - .execute_async(&*conn) - .await; - if name.is_some() && description.is_some() { - // Name/description must be non-NULL, instance ID can be - // either - res.unwrap_or_else(|_| { - panic!( - "Failed to insert Floating IP with valid \ - name, description, and {} ID", - if is_service { - "Service" - } else { - "Instance" - } - ) - }); - } else { - // At least one is not valid, we expect a check violation - let err = res.expect_err( - "Expected a CHECK violation when inserting a \ - Floating IP record with NULL name and/or description", - ); - assert!( - matches!( - err, - DatabaseError( - CheckViolation, - _ - ) - ), - "Expected a CHECK violation when inserting a \ - Floating IP record with NULL name and/or description", - ); - } - } - } + // If they are instance FIPs, they *must* have a project id. + for ( + name, + description, + parent_id, + is_service, + project_id, + modify_name, + ) in itertools::iproduct!( + &names, + &descriptions, + &parent_ids, + [false, true], + &project_ids, + [false, true] + ) { + // Both choices of parent_id are valid, so we need a unique name for each. + let name_local = name.map(|v| { + let name = if modify_name { + v.to_string() + } else { + format!("{v}-with-parent") + }; + db::model::Name(Name::try_from(name).unwrap()) + }); + + // We do name duplicate checking on the `Some` branch, don't steal the + // name intended for another floating IP. + if parent_id.is_none() && modify_name { + continue; + } + + let new_ip = ExternalIp { + id: Uuid::new_v4(), + name: name_local.clone(), + description: description.clone(), + ip: addresses.next().unwrap().into(), + is_service, + parent_id: *parent_id, + project_id: *project_id, + ..ip + }; + + let key = (*project_id, name_local); + + let res = diesel::insert_into(dsl::external_ip) + .values(new_ip) + .execute_async(&*conn) + .await; + + let project_as_expected = (is_service && project_id.is_none()) + || (!is_service && project_id.is_some()); + + let valid_expression = + name.is_some() && description.is_some() && project_as_expected; + let name_exists = seen_pairs.contains(&key); + + if valid_expression && !name_exists { + // Name/description must be non-NULL, instance ID can be + // either + // Names must be unique at fleet level and at project level. + // Project must be NULL if service, non-NULL if instance. + res.unwrap_or_else(|e| { + panic!( + "Failed to insert Floating IP with valid \ + name, description, project ID, and {} ID:\ + {name:?} {description:?} {project_id:?} {:?}\n{e}", + if is_service { "Service" } else { "Instance" }, + &ip.parent_id + ) + }); + + seen_pairs.insert(key); + } else if !valid_expression { + // Several permutations are invalid and we want to detect them all. + // NOTE: CHECK violation will supersede UNIQUE violation below. + let err = res.expect_err( + "Expected a CHECK violation when inserting a \ + Floating IP record with NULL name and/or description, \ + and incorrect project parent relation", + ); + assert!( + matches!(err, DatabaseError(CheckViolation, _)), + "Expected a CHECK violation when inserting a \ + Floating IP record with NULL name and/or description, \ + and incorrect project parent relation", + ); + } else { + let err = res.expect_err( + "Expected a UNIQUE violation when inserting a \ + Floating IP record with existing (name, project_id)", + ); + assert!( + matches!(err, DatabaseError(UniqueViolation, _)), + "Expected a UNIQUE violation when inserting a \ + Floating IP record with existing (name, project_id)", + ); } } - // For other IP types, both name and description must be NULL - for kind in [IpKind::SNat, IpKind::Ephemeral].into_iter() { - for name in names.iter() { - for description in descriptions.iter() { - for parent_id in parent_ids.iter() { - for is_service in [false, true] { - let new_ip = ExternalIp { - id: Uuid::new_v4(), - name: name.clone(), - description: description.clone(), - kind, - ip: addresses.next().unwrap().into(), - is_service, - parent_id: *parent_id, - ..ip - }; - let res = diesel::insert_into(dsl::external_ip) - .values(new_ip.clone()) - .execute_async(&*conn) - .await; - let ip_type = - if is_service { "Service" } else { "Instance" }; - if name.is_none() - && description.is_none() - && parent_id.is_some() - { - // Name/description must be NULL, instance ID cannot - // be NULL. - - if kind == IpKind::Ephemeral && is_service { - // Ephemeral Service IPs aren't supported. - let err = res.unwrap_err(); - assert!( - matches!( - err, - DatabaseError( - CheckViolation, - _ - ) - ), - "Expected a CHECK violation when inserting an \ - Ephemeral Service IP", - ); - } else { - assert!( - res.is_ok(), - "Failed to insert {:?} IP with valid \ - name, description, and {} ID", - kind, - ip_type, - ); - } - } else { - // One is not valid, we expect a check violation - assert!( - res.is_err(), - "Expected a CHECK violation when inserting a \ - {:?} IP record with non-NULL name, description, \ - and/or {} ID", - kind, - ip_type, - ); - let err = res.unwrap_err(); - assert!( - matches!( - err, - DatabaseError( - CheckViolation, - _ - ) - ), - "Expected a CHECK violation when inserting a \ - {:?} IP record with non-NULL name, description, \ - and/or {} ID", - kind, - ip_type, - ); - } - } - } + // For other IP types: name, description and project must be NULL + for (kind, name, description, parent_id, is_service, project_id) in itertools::iproduct!( + [IpKind::SNat, IpKind::Ephemeral], + &names, + &descriptions, + &parent_ids, + [false, true], + &project_ids + ) { + let name_local = name.map(|v| { + db::model::Name(Name::try_from(v.to_string()).unwrap()) + }); + let new_ip = ExternalIp { + id: Uuid::new_v4(), + name: name_local, + description: description.clone(), + kind, + ip: addresses.next().unwrap().into(), + is_service, + parent_id: *parent_id, + project_id: *project_id, + ..ip + }; + let res = diesel::insert_into(dsl::external_ip) + .values(new_ip.clone()) + .execute_async(&*conn) + .await; + let ip_type = if is_service { "Service" } else { "Instance" }; + if name.is_none() + && description.is_none() + && parent_id.is_some() + && project_id.is_none() + { + // Name/description must be NULL, instance ID cannot + // be NULL. + + if kind == IpKind::Ephemeral && is_service { + // Ephemeral Service IPs aren't supported. + let err = res.unwrap_err(); + assert!( + matches!(err, DatabaseError(CheckViolation, _)), + "Expected a CHECK violation when inserting an \ + Ephemeral Service IP", + ); + } else { + assert!( + res.is_ok(), + "Failed to insert {:?} IP with valid \ + name, description, and {} ID", + kind, + ip_type, + ); } + } else { + // One is not valid, we expect a check violation + assert!( + res.is_err(), + "Expected a CHECK violation when inserting a \ + {:?} IP record with non-NULL name, description, \ + and/or {} ID", + kind, + ip_type, + ); + let err = res.unwrap_err(); + assert!( + matches!(err, DatabaseError(CheckViolation, _)), + "Expected a CHECK violation when inserting a \ + {:?} IP record with non-NULL name, description, \ + and/or {} ID", + kind, + ip_type, + ); } } + db.cleanup().await.unwrap(); logctx.cleanup_successful(); } diff --git a/nexus/db-queries/src/db/lookup.rs b/nexus/db-queries/src/db/lookup.rs index 72a32f562c..028694dc4b 100644 --- a/nexus/db-queries/src/db/lookup.rs +++ b/nexus/db-queries/src/db/lookup.rs @@ -231,6 +231,11 @@ impl<'a> LookupPath<'a> { RouterRoute::PrimaryKey(Root { lookup_root: self }, id) } + /// Select a resource of type FloatingIp, identified by its id + pub fn floating_ip_id(self, id: Uuid) -> FloatingIp<'a> { + FloatingIp::PrimaryKey(Root { lookup_root: self }, id) + } + // Fleet-level resources /// Select a resource of type ConsoleSession, identified by its `token` @@ -632,7 +637,7 @@ lookup_resource! { lookup_resource! { name = "Project", ancestors = [ "Silo" ], - children = [ "Disk", "Instance", "Vpc", "Snapshot", "ProjectImage" ], + children = [ "Disk", "Instance", "Vpc", "Snapshot", "ProjectImage", "FloatingIp" ], lookup_by_name = true, soft_deletes = true, primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] @@ -728,6 +733,15 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } +lookup_resource! { + name = "FloatingIp", + ancestors = [ "Silo", "Project" ], + children = [], + lookup_by_name = true, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] +} + // Miscellaneous resources nested directly below "Fleet" lookup_resource! { diff --git a/nexus/db-queries/src/db/queries/external_ip.rs b/nexus/db-queries/src/db/queries/external_ip.rs index cf182e080d..4e5f59e79c 100644 --- a/nexus/db-queries/src/db/queries/external_ip.rs +++ b/nexus/db-queries/src/db/queries/external_ip.rs @@ -98,7 +98,8 @@ const MAX_PORT: u16 = u16::MAX; /// AS kind, /// candidate_ip AS ip, /// CAST(candidate_first_port AS INT4) AS first_port, -/// CAST(candidate_last_port AS INT4) AS last_port +/// CAST(candidate_last_port AS INT4) AS last_port, +/// AS project_id /// FROM /// SELECT * FROM ( /// -- Select all IP addresses by pool and range. @@ -371,6 +372,13 @@ impl NextExternalIp { out.push_identifier(dsl::first_port::NAME)?; out.push_sql(", CAST(candidate_last_port AS INT4) AS "); out.push_identifier(dsl::last_port::NAME)?; + out.push_sql(", "); + + // Project ID, possibly null + out.push_bind_param::, Option>(self.ip.project_id())?; + out.push_sql(" AS "); + out.push_identifier(dsl::project_id::NAME)?; + out.push_sql(" FROM ("); self.push_address_sequence_subquery(out.reborrow())?; out.push_sql(") CROSS JOIN ("); diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 963f00f7e8..54fb6481a9 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -376,6 +376,20 @@ resource: ProjectImage "silo1-proj1-image1" silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: FloatingIp "silo1-proj1-fip1" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-proj1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-proj1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Project "silo1-proj2" USER Q R LC RP M MP CC D @@ -488,6 +502,20 @@ resource: ProjectImage "silo1-proj2-image1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: FloatingIp "silo1-proj2-fip1" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-collaborator ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-viewer ✘ ✔ ✔ ✔ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Silo "silo2" USER Q R LC RP M MP CC D @@ -768,6 +796,20 @@ resource: ProjectImage "silo2-proj1-image1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: FloatingIp "silo2-proj1-fip1" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Rack id "c037e882-8b6d-c8b5-bef4-97e848eb0a50" USER Q R LC RP M MP CC D diff --git a/nexus/src/app/external_ip.rs b/nexus/src/app/external_ip.rs index 2354e97085..404f597288 100644 --- a/nexus/src/app/external_ip.rs +++ b/nexus/src/app/external_ip.rs @@ -5,11 +5,20 @@ //! External IP addresses for instances use crate::external_api::views::ExternalIp; +use crate::external_api::views::FloatingIp; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::lookup; +use nexus_db_queries::db::lookup::LookupPath; use nexus_db_queries::db::model::IpKind; +use nexus_types::external_api::params; +use omicron_common::api::external::http_pagination::PaginatedBy; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NameOrId; impl super::Nexus { pub(crate) async fn instance_list_external_ips( @@ -33,4 +42,82 @@ impl super::Nexus { }) .collect::>()) } + + pub(crate) fn floating_ip_lookup<'a>( + &'a self, + opctx: &'a OpContext, + fip_selector: params::FloatingIpSelector, + ) -> LookupResult> { + match fip_selector { + params::FloatingIpSelector { floating_ip: NameOrId::Id(id), project: None } => { + let floating_ip = + LookupPath::new(opctx, &self.db_datastore).floating_ip_id(id); + Ok(floating_ip) + } + params::FloatingIpSelector { + floating_ip: NameOrId::Name(name), + project: Some(project), + } => { + let floating_ip = self + .project_lookup(opctx, params::ProjectSelector { project })? + .floating_ip_name_owned(name.into()); + Ok(floating_ip) + } + params::FloatingIpSelector { + floating_ip: NameOrId::Id(_), + .. + } => Err(Error::invalid_request( + "when providing Floating IP as an ID project should not be specified", + )), + _ => Err(Error::invalid_request( + "Floating IP should either be UUID or project should be specified", + )), + } + } + + pub(crate) async fn floating_ips_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::ListChildren).await?; + + Ok(self + .db_datastore + .floating_ips_list(opctx, &authz_project, pagparams) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + pub(crate) async fn floating_ip_create( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + params: params::FloatingIpCreate, + ) -> CreateResult { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::CreateChild).await?; + + Ok(self + .db_datastore + .allocate_floating_ip(opctx, authz_project.id(), params) + .await? + .try_into() + .unwrap()) + } + + pub(crate) async fn floating_ip_delete( + &self, + opctx: &OpContext, + ip_lookup: lookup::FloatingIp<'_>, + ) -> DeleteResult { + let (.., authz_fip, db_fip) = + ip_lookup.fetch_for(authz::Action::Delete).await?; + + self.db_datastore.floating_ip_delete(opctx, &authz_fip, &db_fip).await + } } diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index 923bb1777e..0edb2c5ea7 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -5,6 +5,7 @@ //! Virtual Machine Instances use super::MAX_DISKS_PER_INSTANCE; +use super::MAX_EPHEMERAL_IPS_PER_INSTANCE; use super::MAX_EXTERNAL_IPS_PER_INSTANCE; use super::MAX_MEMORY_BYTES_PER_INSTANCE; use super::MAX_NICS_PER_INSTANCE; @@ -52,6 +53,7 @@ use sled_agent_client::types::InstanceProperties; use sled_agent_client::types::InstancePutMigrationIdsBody; use sled_agent_client::types::InstancePutStateBody; use sled_agent_client::types::SourceNatConfig; +use std::matches; use std::net::SocketAddr; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite}; @@ -168,6 +170,18 @@ impl super::Nexus { MAX_EXTERNAL_IPS_PER_INSTANCE, ))); } + if params + .external_ips + .iter() + .filter(|v| matches!(v, params::ExternalIpCreate::Ephemeral { .. })) + .count() + > MAX_EPHEMERAL_IPS_PER_INSTANCE + { + return Err(Error::invalid_request(&format!( + "An instance may not have more than {} ephemeral IP address", + MAX_EPHEMERAL_IPS_PER_INSTANCE, + ))); + } if let params::InstanceNetworkInterfaceAttachment::Create(ref ifaces) = params.network_interfaces { @@ -885,8 +899,6 @@ impl super::Nexus { .await?; // Collect the external IPs for the instance. - // TODO-correctness: Handle Floating IPs, see - // https://github.com/oxidecomputer/omicron/issues/1334 let (snat_ip, external_ips): (Vec<_>, Vec<_>) = self .db_datastore .instance_lookup_external_ips(&opctx, authz_instance.id()) @@ -895,8 +907,6 @@ impl super::Nexus { .partition(|ip| ip.kind == IpKind::SNat); // Sanity checks on the number and kind of each IP address. - // TODO-correctness: Handle multiple IP addresses, see - // https://github.com/oxidecomputer/omicron/issues/1467 if external_ips.len() > MAX_EXTERNAL_IPS_PER_INSTANCE { return Err(Error::internal_error( format!( @@ -908,8 +918,28 @@ impl super::Nexus { .as_str(), )); } - let external_ips = - external_ips.into_iter().map(|model| model.ip.ip()).collect(); + + // Partition remaining external IPs by class: we can have at most + // one ephemeral ip. + let (ephemeral_ips, floating_ips): (Vec<_>, Vec<_>) = external_ips + .into_iter() + .partition(|ip| ip.kind == IpKind::Ephemeral); + + if ephemeral_ips.len() > MAX_EPHEMERAL_IPS_PER_INSTANCE { + return Err(Error::internal_error( + format!( + "Expected at most {} ephemeral IP for an instance, found {}", + MAX_EPHEMERAL_IPS_PER_INSTANCE, + ephemeral_ips.len() + ) + .as_str(), + )); + } + + let ephemeral_ip = ephemeral_ips.get(0).map(|model| model.ip.ip()); + + let floating_ips = + floating_ips.into_iter().map(|model| model.ip.ip()).collect(); if snat_ip.len() != 1 { return Err(Error::internal_error( "Expected exactly one SNAT IP address for an instance", @@ -985,7 +1015,8 @@ impl super::Nexus { }, nics, source_nat, - external_ips, + ephemeral_ip, + floating_ips, firewall_rules, dhcp_config: sled_agent_client::types::DhcpConfig { dns_servers: self.external_dns_servers.clone(), diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 18c9dae841..d4c2d596f8 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -79,8 +79,13 @@ pub(crate) use nexus_db_queries::db::queries::disk::MAX_DISKS_PER_INSTANCE; pub(crate) const MAX_NICS_PER_INSTANCE: usize = 8; -// TODO-completeness: Support multiple external IPs -pub(crate) const MAX_EXTERNAL_IPS_PER_INSTANCE: usize = 1; +// XXX: Might want to recast as max *floating* IPs, we have at most one +// ephemeral (so bounded in saga by design). +// The value here is arbitrary, but we need *a* limit for the instance +// create saga to have a bounded DAG. We might want to only enforce +// this during instance create (rather than live attach) in future. +pub(crate) const MAX_EXTERNAL_IPS_PER_INSTANCE: usize = 32; +pub(crate) const MAX_EPHEMERAL_IPS_PER_INSTANCE: usize = 1; pub const MAX_VCPU_PER_INSTANCE: u16 = 64; diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 95283faa1c..3804841feb 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -869,7 +869,17 @@ impl super::Nexus { ) -> ListResultVec { debug!(self.log, "Getting latest collection"); // Grab the SPs from the last collection - let limit = NonZeroU32::new(50).unwrap(); + // + // We set a limit of 200 here to give us some breathing room when + // querying for cabooses and RoT pages, each of which is "4 per SP/RoT", + // which in a single fully populated rack works out to (32 sleds + 2 + // switches + 1 psc) * 4 = 140. + // + // This feels bad and probably needs more thought; see + // https://github.com/oxidecomputer/omicron/issues/4621 where this limit + // being too low bit us, and it will link to a more general followup + // issue. + let limit = NonZeroU32::new(200).unwrap(); let collection = self .db_datastore .inventory_get_latest_collection(opctx, limit) diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index 8c2f96c36c..5149825842 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -598,35 +598,55 @@ async fn sic_allocate_instance_snat_ip_undo( async fn sic_allocate_instance_external_ip( sagactx: NexusActionContext, ) -> Result<(), ActionError> { + // XXX: may wish to restructure partially: we have at most one ephemeral + // and then at most $n$ floating. let osagactx = sagactx.user_data(); let datastore = osagactx.datastore(); let repeat_saga_params = sagactx.saga_params::()?; let saga_params = repeat_saga_params.saga_params; let ip_index = repeat_saga_params.which; - let ip_params = saga_params.create_params.external_ips.get(ip_index); - let ip_params = match ip_params { - None => { - return Ok(()); - } - Some(ref prs) => prs, + let Some(ip_params) = saga_params.create_params.external_ips.get(ip_index) + else { + return Ok(()); }; let opctx = crate::context::op_context_for_saga_action( &sagactx, &saga_params.serialized_authn, ); let instance_id = repeat_saga_params.instance_id; - let ip_id = repeat_saga_params.new_id; - // Collect the possible pool name for this IP address - let pool_name = match ip_params { + match ip_params { + // Allocate a new IP address from the target, possibly default, pool params::ExternalIpCreate::Ephemeral { ref pool_name } => { - pool_name.as_ref().map(|name| db::model::Name(name.clone())) + let pool_name = + pool_name.as_ref().map(|name| db::model::Name(name.clone())); + let ip_id = repeat_saga_params.new_id; + datastore + .allocate_instance_ephemeral_ip( + &opctx, + ip_id, + instance_id, + pool_name, + ) + .await + .map_err(ActionError::action_failed)?; } - }; - datastore - .allocate_instance_ephemeral_ip(&opctx, ip_id, instance_id, pool_name) - .await - .map_err(ActionError::action_failed)?; + // Set the parent of an existing floating IP to the new instance's ID. + params::ExternalIpCreate::Floating { ref floating_ip_name } => { + let floating_ip_name = db::model::Name(floating_ip_name.clone()); + let (.., authz_fip, db_fip) = LookupPath::new(&opctx, &datastore) + .project_id(saga_params.project_id) + .floating_ip_name(&floating_ip_name) + .fetch_for(authz::Action::Modify) + .await + .map_err(ActionError::action_failed)?; + + datastore + .floating_ip_attach(&opctx, &authz_fip, &db_fip, instance_id) + .await + .map_err(ActionError::action_failed)?; + } + } Ok(()) } @@ -638,16 +658,31 @@ async fn sic_allocate_instance_external_ip_undo( let repeat_saga_params = sagactx.saga_params::()?; let saga_params = repeat_saga_params.saga_params; let ip_index = repeat_saga_params.which; - if ip_index >= saga_params.create_params.external_ips.len() { - return Ok(()); - } - let opctx = crate::context::op_context_for_saga_action( &sagactx, &saga_params.serialized_authn, ); - let ip_id = repeat_saga_params.new_id; - datastore.deallocate_external_ip(&opctx, ip_id).await?; + let Some(ip_params) = saga_params.create_params.external_ips.get(ip_index) + else { + return Ok(()); + }; + + match ip_params { + params::ExternalIpCreate::Ephemeral { .. } => { + let ip_id = repeat_saga_params.new_id; + datastore.deallocate_external_ip(&opctx, ip_id).await?; + } + params::ExternalIpCreate::Floating { floating_ip_name } => { + let floating_ip_name = db::model::Name(floating_ip_name.clone()); + let (.., authz_fip, db_fip) = LookupPath::new(&opctx, &datastore) + .project_id(saga_params.project_id) + .floating_ip_name(&floating_ip_name) + .fetch_for(authz::Action::Modify) + .await?; + + datastore.floating_ip_detach(&opctx, &authz_fip, &db_fip).await?; + } + } Ok(()) } diff --git a/nexus/src/app/sagas/instance_delete.rs b/nexus/src/app/sagas/instance_delete.rs index 1605465c74..7802312b10 100644 --- a/nexus/src/app/sagas/instance_delete.rs +++ b/nexus/src/app/sagas/instance_delete.rs @@ -8,6 +8,7 @@ use super::ActionRegistry; use super::NexusActionContext; use super::NexusSaga; use crate::app::sagas::declare_saga_actions; +use nexus_db_queries::db::lookup::LookupPath; use nexus_db_queries::{authn, authz, db}; use omicron_common::api::external::{Error, ResourceType}; use omicron_common::api::internal::shared::SwitchLocation; @@ -39,6 +40,9 @@ declare_saga_actions! { DEALLOCATE_EXTERNAL_IP -> "no_result3" { + sid_deallocate_external_ip } + INSTANCE_DELETE_NAT -> "no_result4" { + + sid_delete_nat + } } // instance delete saga: definition @@ -57,6 +61,7 @@ impl NexusSaga for SagaInstanceDelete { _params: &Self::Params, mut builder: steno::DagBuilder, ) -> Result { + builder.append(instance_delete_nat_action()); builder.append(instance_delete_record_action()); builder.append(delete_network_interfaces_action()); builder.append(deallocate_external_ip_action()); @@ -110,6 +115,32 @@ async fn sid_delete_network_interfaces( Ok(()) } +async fn sid_delete_nat( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let params = sagactx.saga_params::()?; + let instance_id = params.authz_instance.id(); + let osagactx = sagactx.user_data(); + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + let (.., authz_instance) = LookupPath::new(&opctx, &osagactx.datastore()) + .instance_id(instance_id) + .lookup_for(authz::Action::Modify) + .await + .map_err(ActionError::action_failed)?; + + osagactx + .nexus() + .instance_delete_dpd_config(&opctx, &authz_instance) + .await + .map_err(ActionError::action_failed)?; + + Ok(()) +} + async fn sid_deallocate_external_ip( sagactx: NexusActionContext, ) -> Result<(), ActionError> { @@ -127,6 +158,11 @@ async fn sid_deallocate_external_ip( ) .await .map_err(ActionError::action_failed)?; + osagactx + .datastore() + .detach_floating_ips_by_instance_id(&opctx, params.authz_instance.id()) + .await + .map_err(ActionError::action_failed)?; Ok(()) } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index ef8d73afab..a113451fc7 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -140,6 +140,11 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(ip_pool_service_range_add)?; api.register(ip_pool_service_range_remove)?; + api.register(floating_ip_list)?; + api.register(floating_ip_create)?; + api.register(floating_ip_view)?; + api.register(floating_ip_delete)?; + api.register(disk_list)?; api.register(disk_create)?; api.register(disk_view)?; @@ -1521,6 +1526,126 @@ async fn ip_pool_service_range_remove( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// Floating IP Addresses + +/// List all Floating IPs +#[endpoint { + method = GET, + path = "/v1/floating-ips", + tags = ["floating-ips"], +}] +async fn floating_ip_list( + rqctx: RequestContext>, + query_params: Query>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanByNameOrId::from_query(&query)?; + let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; + let project_lookup = + nexus.project_lookup(&opctx, scan_params.selector.clone())?; + let ips = nexus + .floating_ips_list(&opctx, &project_lookup, &paginated_by) + .await?; + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + ips, + &marker_for_name_or_id, + )?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a Floating IP +#[endpoint { + method = POST, + path = "/v1/floating-ips", + tags = ["floating-ips"], +}] +async fn floating_ip_create( + rqctx: RequestContext>, + query_params: Query, + floating_params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let floating_params = floating_params.into_inner(); + let handler = async { + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let project_lookup = + nexus.project_lookup(&opctx, query_params.into_inner())?; + let ip = nexus + .floating_ip_create(&opctx, &project_lookup, floating_params) + .await?; + Ok(HttpResponseCreated(ip)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete a Floating IP +#[endpoint { + method = DELETE, + path = "/v1/floating-ips/{floating_ip}", + tags = ["floating-ips"], +}] +async fn floating_ip_delete( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, +) -> Result { + let apictx = rqctx.context(); + let handler = async { + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let floating_ip_selector = params::FloatingIpSelector { + floating_ip: path.floating_ip, + project: query.project, + }; + let fip_lookup = + nexus.floating_ip_lookup(&opctx, floating_ip_selector)?; + + nexus.floating_ip_delete(&opctx, fip_lookup).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Fetch a floating IP +#[endpoint { + method = GET, + path = "/v1/floating-ips/{floating_ip}", + tags = ["floating-ips"] +}] +async fn floating_ip_view( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let floating_ip_selector = params::FloatingIpSelector { + floating_ip: path.floating_ip, + project: query.project, + }; + let (.., fip) = nexus + .floating_ip_lookup(&opctx, floating_ip_selector)? + .fetch() + .await?; + Ok(HttpResponseOk(fip.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Disks /// List disks diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index 07eb198016..3bc8006cee 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -8,6 +8,12 @@ "url": "http://docs.oxide.computer/api/disks" } }, + "floating-ips": { + "description": "Floating IPs allow a project to allocate well-known IPs to instances.", + "external_docs": { + "url": "http://docs.oxide.computer/api/floating-ips" + } + }, "hidden": { "description": "TODO operations that will not ship to customers", "external_docs": { diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 2368c3f568..1848989bf9 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -21,6 +21,7 @@ use nexus_types::external_api::shared::IdentityType; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::views; use nexus_types::external_api::views::Certificate; +use nexus_types::external_api::views::FloatingIp; use nexus_types::external_api::views::IpPool; use nexus_types::external_api::views::IpPoolRange; use nexus_types::external_api::views::User; @@ -32,7 +33,9 @@ use omicron_common::api::external::Disk; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::Instance; use omicron_common::api::external::InstanceCpuCount; +use omicron_common::api::external::NameOrId; use omicron_sled_agent::sim::SledAgent; +use std::net::IpAddr; use std::sync::Arc; use uuid::Uuid; @@ -149,6 +152,28 @@ pub async fn create_ip_pool( (pool, range) } +pub async fn create_floating_ip( + client: &ClientTestContext, + fip_name: &str, + project: &str, + address: Option, + parent_pool_name: Option<&str>, +) -> FloatingIp { + object_create( + client, + &format!("/v1/floating-ips?project={project}"), + ¶ms::FloatingIpCreate { + identity: IdentityMetadataCreateParams { + name: fip_name.parse().unwrap(), + description: String::from("a floating ip"), + }, + address, + pool: parent_pool_name.map(|v| NameOrId::Name(v.parse().unwrap())), + }, + ) + .await +} + pub async fn create_certificate( client: &ClientTestContext, cert_name: &str, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 536b96f7ae..db803bfde0 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -134,6 +134,7 @@ lazy_static! { pub static ref DEMO_PROJECT_URL_INSTANCES: String = format!("/v1/instances?project={}", *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_URL_SNAPSHOTS: String = format!("/v1/snapshots?project={}", *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_URL_VPCS: String = format!("/v1/vpcs?project={}", *DEMO_PROJECT_NAME); + pub static ref DEMO_PROJECT_URL_FIPS: String = format!("/v1/floating-ips?project={}", *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_CREATE: params::ProjectCreate = params::ProjectCreate { identity: IdentityMetadataCreateParams { @@ -573,6 +574,22 @@ lazy_static! { }; } +lazy_static! { + // Project Floating IPs + pub static ref DEMO_FLOAT_IP_NAME: Name = "float-ip".parse().unwrap(); + pub static ref DEMO_FLOAT_IP_URL: String = + format!("/v1/floating-ips/{}?project={}", *DEMO_FLOAT_IP_NAME, *DEMO_PROJECT_NAME); + pub static ref DEMO_FLOAT_IP_CREATE: params::FloatingIpCreate = + params::FloatingIpCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_FLOAT_IP_NAME.clone(), + description: String::from("a new IP pool"), + }, + address: Some(std::net::Ipv4Addr::new(10, 0, 0, 141).into()), + pool: None, + }; +} + lazy_static! { // Identity providers pub static ref IDENTITY_PROVIDERS_URL: String = format!("/v1/system/identity-providers?silo=demo-silo"); @@ -1991,6 +2008,29 @@ lazy_static! { allowed_methods: vec![ AllowedMethod::GetNonexistent, ], + }, + + // Floating IPs + VerifyEndpoint { + url: &DEMO_PROJECT_URL_FIPS, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_FLOAT_IP_CREATE).unwrap(), + ), + AllowedMethod::Get, + ], + }, + + VerifyEndpoint { + url: &DEMO_FLOAT_IP_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], } ]; } diff --git a/nexus/tests/integration_tests/external_ips.rs b/nexus/tests/integration_tests/external_ips.rs new file mode 100644 index 0000000000..f3161dea72 --- /dev/null +++ b/nexus/tests/integration_tests/external_ips.rs @@ -0,0 +1,432 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Tests Floating IP support in the API + +use std::net::IpAddr; +use std::net::Ipv4Addr; + +use crate::integration_tests::instances::instance_simulate; +use dropshot::test_util::ClientTestContext; +use dropshot::HttpErrorResponseBody; +use http::Method; +use http::StatusCode; +use nexus_test_utils::http_testing::AuthnMode; +use nexus_test_utils::http_testing::NexusRequest; +use nexus_test_utils::http_testing::RequestBuilder; +use nexus_test_utils::resource_helpers::create_floating_ip; +use nexus_test_utils::resource_helpers::create_instance_with; +use nexus_test_utils::resource_helpers::create_ip_pool; +use nexus_test_utils::resource_helpers::create_project; +use nexus_test_utils::resource_helpers::populate_ip_pool; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::params; +use nexus_types::external_api::views::FloatingIp; +use omicron_common::address::IpRange; +use omicron_common::address::Ipv4Range; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::Instance; +use uuid::Uuid; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +const PROJECT_NAME: &str = "rootbeer-float"; + +const FIP_NAMES: &[&str] = &["vanilla", "chocolate", "strawberry", "pistachio"]; + +pub fn get_floating_ips_url(project_name: &str) -> String { + format!("/v1/floating-ips?project={project_name}") +} + +pub fn get_floating_ip_by_name_url( + fip_name: &str, + project_name: &str, +) -> String { + format!("/v1/floating-ips/{fip_name}?project={project_name}") +} + +pub fn get_floating_ip_by_id_url(fip_id: &Uuid) -> String { + format!("/v1/floating-ips/{fip_id}") +} + +#[nexus_test] +async fn test_floating_ip_access(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + populate_ip_pool(&client, "default", None).await; + let project = create_project(client, PROJECT_NAME).await; + + // Create a floating IP from the default pool. + let fip_name = FIP_NAMES[0]; + let fip = create_floating_ip( + client, + fip_name, + &project.identity.id.to_string(), + None, + None, + ) + .await; + + // Fetch floating IP by ID + let fetched_fip = + floating_ip_get(&client, &get_floating_ip_by_id_url(&fip.identity.id)) + .await; + assert_eq!(fetched_fip.identity.id, fip.identity.id); + + // Fetch floating IP by name and project_id + let fetched_fip = floating_ip_get( + &client, + &get_floating_ip_by_name_url( + fip.identity.name.as_str(), + &project.identity.id.to_string(), + ), + ) + .await; + assert_eq!(fetched_fip.identity.id, fip.identity.id); + + // Fetch floating IP by name and project_name + let fetched_fip = floating_ip_get( + &client, + &get_floating_ip_by_name_url( + fip.identity.name.as_str(), + project.identity.name.as_str(), + ), + ) + .await; + assert_eq!(fetched_fip.identity.id, fip.identity.id); +} + +#[nexus_test] +async fn test_floating_ip_create(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + populate_ip_pool(&client, "default", None).await; + let other_pool_range = IpRange::V4( + Ipv4Range::new(Ipv4Addr::new(10, 1, 0, 1), Ipv4Addr::new(10, 1, 0, 5)) + .unwrap(), + ); + create_ip_pool(&client, "other-pool", Some(other_pool_range)).await; + + let project = create_project(client, PROJECT_NAME).await; + + // Create with no chosen IP and fallback to default pool. + let fip_name = FIP_NAMES[0]; + let fip = create_floating_ip( + client, + fip_name, + project.identity.name.as_str(), + None, + None, + ) + .await; + assert_eq!(fip.identity.name.as_str(), fip_name); + assert_eq!(fip.project_id, project.identity.id); + assert_eq!(fip.instance_id, None); + assert_eq!(fip.ip, IpAddr::from(Ipv4Addr::new(10, 0, 0, 0))); + + // Create with chosen IP and fallback to default pool. + let fip_name = FIP_NAMES[1]; + let ip_addr = "10.0.12.34".parse().unwrap(); + let fip = create_floating_ip( + client, + fip_name, + project.identity.name.as_str(), + Some(ip_addr), + None, + ) + .await; + assert_eq!(fip.identity.name.as_str(), fip_name); + assert_eq!(fip.project_id, project.identity.id); + assert_eq!(fip.instance_id, None); + assert_eq!(fip.ip, ip_addr); + + // Create with no chosen IP from named pool. + let fip_name = FIP_NAMES[2]; + let fip = create_floating_ip( + client, + fip_name, + project.identity.name.as_str(), + None, + Some("other-pool"), + ) + .await; + assert_eq!(fip.identity.name.as_str(), fip_name); + assert_eq!(fip.project_id, project.identity.id); + assert_eq!(fip.instance_id, None); + assert_eq!(fip.ip, IpAddr::from(Ipv4Addr::new(10, 1, 0, 1))); + + // Create with chosen IP from named pool. + let fip_name = FIP_NAMES[3]; + let ip_addr = "10.1.0.5".parse().unwrap(); + let fip = create_floating_ip( + client, + fip_name, + project.identity.name.as_str(), + Some(ip_addr), + Some("other-pool"), + ) + .await; + assert_eq!(fip.identity.name.as_str(), fip_name); + assert_eq!(fip.project_id, project.identity.id); + assert_eq!(fip.instance_id, None); + assert_eq!(fip.ip, ip_addr); +} + +#[nexus_test] +async fn test_floating_ip_create_ip_in_use( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + populate_ip_pool(&client, "default", None).await; + + let project = create_project(client, PROJECT_NAME).await; + let contested_ip = "10.0.0.0".parse().unwrap(); + + // First create will succeed. + create_floating_ip( + client, + FIP_NAMES[0], + project.identity.name.as_str(), + Some(contested_ip), + None, + ) + .await; + + // Second will fail as the requested IP is in use in the selected + // (default) pool. + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &get_floating_ips_url(PROJECT_NAME), + ) + .body(Some(¶ms::FloatingIpCreate { + identity: IdentityMetadataCreateParams { + name: FIP_NAMES[1].parse().unwrap(), + description: "another fip".into(), + }, + address: Some(contested_ip), + pool: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "Requested external IP address not available"); +} + +#[nexus_test] +async fn test_floating_ip_create_name_in_use( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + populate_ip_pool(&client, "default", None).await; + + let project = create_project(client, PROJECT_NAME).await; + let contested_name = FIP_NAMES[0]; + + // First create will succeed. + create_floating_ip( + client, + contested_name, + project.identity.name.as_str(), + None, + None, + ) + .await; + + // Second will fail as the requested name is in use within this + // project. + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &get_floating_ips_url(PROJECT_NAME), + ) + .body(Some(¶ms::FloatingIpCreate { + identity: IdentityMetadataCreateParams { + name: contested_name.parse().unwrap(), + description: "another fip".into(), + }, + address: None, + pool: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + format!("already exists: floating-ip \"{contested_name}\""), + ); +} + +#[nexus_test] +async fn test_floating_ip_delete(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + populate_ip_pool(&client, "default", None).await; + let project = create_project(client, PROJECT_NAME).await; + + let fip = create_floating_ip( + client, + FIP_NAMES[0], + project.identity.name.as_str(), + None, + None, + ) + .await; + + // Delete the floating IP. + NexusRequest::object_delete( + client, + &get_floating_ip_by_id_url(&fip.identity.id), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); +} + +#[nexus_test] +async fn test_floating_ip_attachment(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + populate_ip_pool(&client, "default", None).await; + let project = create_project(client, PROJECT_NAME).await; + + let fip = create_floating_ip( + client, + FIP_NAMES[0], + project.identity.name.as_str(), + None, + None, + ) + .await; + + // Bind the floating IP to an instance at create time. + let instance_name = "anonymous-diner"; + let instance = create_instance_with( + &client, + PROJECT_NAME, + instance_name, + ¶ms::InstanceNetworkInterfaceAttachment::Default, + vec![], + vec![params::ExternalIpCreate::Floating { + floating_ip_name: FIP_NAMES[0].parse().unwrap(), + }], + ) + .await; + + // Reacquire FIP: parent ID must have updated to match instance. + let fetched_fip = + floating_ip_get(&client, &get_floating_ip_by_id_url(&fip.identity.id)) + .await; + assert_eq!(fetched_fip.instance_id, Some(instance.identity.id)); + + // Try to delete the floating IP, which should fail. + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new( + client, + Method::DELETE, + &get_floating_ip_by_id_url(&fip.identity.id), + ) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + format!("Floating IP cannot be deleted while attached to an instance"), + ); + + // Stop and delete the instance. + instance_simulate(nexus, &instance.identity.id).await; + instance_simulate(nexus, &instance.identity.id).await; + + let _: Instance = NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/v1/instances/{}/stop", instance.identity.id), + ) + .body(None as Option<&serde_json::Value>) + .expect_status(Some(StatusCode::ACCEPTED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + + instance_simulate(nexus, &instance.identity.id).await; + + NexusRequest::object_delete( + &client, + &format!("/v1/instances/{instance_name}?project={PROJECT_NAME}"), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + // Reacquire FIP again: parent ID must now be unset. + let fetched_fip = + floating_ip_get(&client, &get_floating_ip_by_id_url(&fip.identity.id)) + .await; + assert_eq!(fetched_fip.instance_id, None); + + // Delete the floating IP. + NexusRequest::object_delete( + client, + &get_floating_ip_by_id_url(&fip.identity.id), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); +} + +pub async fn floating_ip_get( + client: &ClientTestContext, + fip_url: &str, +) -> FloatingIp { + floating_ip_get_as(client, fip_url, AuthnMode::PrivilegedUser).await +} + +async fn floating_ip_get_as( + client: &ClientTestContext, + fip_url: &str, + authn_as: AuthnMode, +) -> FloatingIp { + NexusRequest::object_get(client, fip_url) + .authn_as(authn_as) + .execute() + .await + .unwrap_or_else(|e| { + panic!("failed to make \"get\" request to {fip_url}: {e}") + }) + .parsed_body() + .unwrap_or_else(|e| { + panic!("failed to make \"get\" request to {fip_url}: {e}") + }) +} diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index ea633be9dc..f54370c32f 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -4,11 +4,14 @@ //! Tests basic instance support in the API +use super::external_ips::floating_ip_get; +use super::external_ips::get_floating_ip_by_id_url; use super::metrics::{get_latest_silo_metric, get_latest_system_metric}; use camino::Utf8Path; use http::method::Method; use http::StatusCode; +use itertools::Itertools; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; use nexus_db_queries::db::fixed_data::silo::SILO_ID; @@ -18,6 +21,7 @@ use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils::http_testing::RequestBuilder; use nexus_test_utils::resource_helpers::create_disk; +use nexus_test_utils::resource_helpers::create_floating_ip; use nexus_test_utils::resource_helpers::create_ip_pool; use nexus_test_utils::resource_helpers::create_local_user; use nexus_test_utils::resource_helpers::create_silo; @@ -54,6 +58,7 @@ use omicron_nexus::TestInterfaces as _; use omicron_sled_agent::sim::SledAgent; use sled_agent_client::TestInterfaces as _; use std::convert::TryFrom; +use std::net::Ipv4Addr; use std::sync::Arc; use uuid::Uuid; @@ -3645,6 +3650,139 @@ async fn test_instance_ephemeral_ip_from_correct_pool( ); } +#[nexus_test] +async fn test_instance_attach_several_external_ips( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let _ = create_project(&client, PROJECT_NAME).await; + + // Create a single (large) IP pool + let default_pool_range = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 10), + ) + .unwrap(), + ); + populate_ip_pool(&client, "default", Some(default_pool_range)).await; + + // Create several floating IPs for the instance, totalling 8 IPs. + let mut external_ip_create = + vec![params::ExternalIpCreate::Ephemeral { pool_name: None }]; + let mut fips = vec![]; + for i in 1..8 { + let name = format!("fip-{i}"); + fips.push( + create_floating_ip(&client, &name, PROJECT_NAME, None, None).await, + ); + external_ip_create.push(params::ExternalIpCreate::Floating { + floating_ip_name: name.parse().unwrap(), + }); + } + + // Create an instance with pool name blank, expect IP from default pool + let instance_name = "many-fips"; + let instance = create_instance_with( + &client, + PROJECT_NAME, + instance_name, + ¶ms::InstanceNetworkInterfaceAttachment::Default, + vec![], + external_ip_create, + ) + .await; + + // Verify that all external IPs are visible on the instance and have + // been allocated in order. + let external_ips = + fetch_instance_external_ips(&client, instance_name).await; + assert_eq!(external_ips.len(), 8); + eprintln!("{external_ips:?}"); + for (i, eip) in external_ips + .iter() + .sorted_unstable_by(|a, b| a.ip.cmp(&b.ip)) + .enumerate() + { + let last_octet = i + if i != external_ips.len() - 1 { + assert_eq!(eip.kind, IpKind::Floating); + 1 + } else { + // SNAT will occupy 1.0.0.8 here, since it it alloc'd before + // the ephemeral. + assert_eq!(eip.kind, IpKind::Ephemeral); + 2 + }; + assert_eq!(eip.ip, Ipv4Addr::new(10, 0, 0, last_octet as u8)); + } + + // Verify that all floating IPs are bound to their parent instance. + for fip in fips { + let fetched_fip = floating_ip_get( + &client, + &get_floating_ip_by_id_url(&fip.identity.id), + ) + .await; + assert_eq!(fetched_fip.instance_id, Some(instance.identity.id)); + } +} + +#[nexus_test] +async fn test_instance_allow_only_one_ephemeral_ip( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let _ = create_project(&client, PROJECT_NAME).await; + + // Create one IP pool with space for two ephemerals. + let default_pool_range = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 2), + ) + .unwrap(), + ); + populate_ip_pool(&client, "default", Some(default_pool_range)).await; + + let ephemeral_create = params::ExternalIpCreate::Ephemeral { + pool_name: Some("default".parse().unwrap()), + }; + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &get_instances_url()) + .body(Some(¶ms::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: "default-pool-inst".parse().unwrap(), + description: "instance default-pool-inst".into(), + }, + ncpus: InstanceCpuCount(4), + memory: ByteCount::from_gibibytes_u32(1), + hostname: String::from("the_host"), + user_data: + b"#cloud-config\nsystem_info:\n default_user:\n name: oxide" + .to_vec(), + network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, + external_ips: vec![ + ephemeral_create.clone(), ephemeral_create + ], + disks: vec![], + start: true, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + "An instance may not have more than 1 ephemeral IP address" + ); +} + async fn create_instance_with_pool( client: &ClientTestContext, instance_name: &str, @@ -3663,10 +3801,10 @@ async fn create_instance_with_pool( .await } -async fn fetch_instance_ephemeral_ip( +async fn fetch_instance_external_ips( client: &ClientTestContext, instance_name: &str, -) -> views::ExternalIp { +) -> Vec { let ips_url = format!( "/v1/instances/{}/external-ips?project={}", instance_name, PROJECT_NAME @@ -3678,9 +3816,18 @@ async fn fetch_instance_ephemeral_ip( .expect("Failed to fetch external IPs") .parsed_body::>() .expect("Failed to parse external IPs"); - assert_eq!(ips.items.len(), 1); - assert_eq!(ips.items[0].kind, IpKind::Ephemeral); - ips.items[0].clone() + ips.items +} + +async fn fetch_instance_ephemeral_ip( + client: &ClientTestContext, + instance_name: &str, +) -> views::ExternalIp { + fetch_instance_external_ips(client, instance_name) + .await + .into_iter() + .find(|v| v.kind == IpKind::Ephemeral) + .unwrap() } #[nexus_test] diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 4d7b41cfa8..53de24c518 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -12,6 +12,7 @@ mod commands; mod console_api; mod device_auth; mod disks; +mod external_ips; mod host_phase1_updater; mod images; mod initialization; diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 9936af20bf..1cb2eaca3a 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -278,6 +278,12 @@ lazy_static! { body: serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap(), id_routes: vec!["/v1/images/{id}"], }, + // Create a Floating IP in the project + SetupReq::Post { + url: &DEMO_PROJECT_URL_FIPS, + body: serde_json::to_value(&*DEMO_FLOAT_IP_CREATE).unwrap(), + id_routes: vec!["/v1/floating-ips/{id}"], + }, // Create a SAML identity provider SetupReq::Post { url: &SAML_IDENTITY_PROVIDERS_URL, diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 7e57d00df2..b236d73551 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -11,6 +11,13 @@ disk_list GET /v1/disks disk_metrics_list GET /v1/disks/{disk}/metrics/{metric} disk_view GET /v1/disks/{disk} +API operations found with tag "floating-ips" +OPERATION ID METHOD URL PATH +floating_ip_create POST /v1/floating-ips +floating_ip_delete DELETE /v1/floating-ips/{floating_ip} +floating_ip_list GET /v1/floating-ips +floating_ip_view GET /v1/floating-ips/{floating_ip} + API operations found with tag "hidden" OPERATION ID METHOD URL PATH device_access_token POST /device/token diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 3303d38367..e582590aa0 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -54,6 +54,7 @@ path_param!(VpcPath, vpc, "VPC"); path_param!(SubnetPath, subnet, "subnet"); path_param!(RouterPath, router, "router"); path_param!(RoutePath, route, "route"); +path_param!(FloatingIpPath, floating_ip, "Floating IP"); path_param!(DiskPath, disk, "disk"); path_param!(SnapshotPath, snapshot, "snapshot"); path_param!(ImagePath, image, "image"); @@ -146,6 +147,14 @@ pub struct OptionalProjectSelector { pub project: Option, } +#[derive(Deserialize, JsonSchema)] +pub struct FloatingIpSelector { + /// Name or ID of the project, only required if `floating_ip` is provided as a `Name` + pub project: Option, + /// Name or ID of the Floating IP + pub floating_ip: NameOrId, +} + #[derive(Deserialize, JsonSchema)] pub struct DiskSelector { /// Name or ID of the project, only required if `disk` is provided as a `Name` @@ -768,6 +777,23 @@ pub struct IpPoolUpdate { pub identity: IdentityMetadataUpdateParams, } +// Floating IPs +/// Parameters for creating a new floating IP address for instances. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct FloatingIpCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// An IP address to reserve for use as a floating IP. This field is + /// optional: when not set, an address will be automatically chosen from + /// `pool`. If set, then the IP must be available in the resolved `pool`. + pub address: Option, + + /// The parent IP pool that a floating IP is pulled from. If unset, the + /// default pool is selected. + pub pool: Option, +} + // INSTANCES /// Describes an attachment of an `InstanceNetworkInterface` to an `Instance`, @@ -835,7 +861,11 @@ pub enum ExternalIpCreate { /// automatically-assigned from the provided IP Pool, or all available pools /// if not specified. Ephemeral { pool_name: Option }, - // TODO: Add floating IPs: https://github.com/oxidecomputer/omicron/issues/1334 + /// An IP address providing both inbound and outbound access. The address is + /// an existing Floating IP object assigned to the current project. + /// + /// The floating IP must not be in use by another instance or service. + Floating { floating_ip_name: Name }, } /// Create-time parameters for an `Instance` diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 4006b18bcc..047bd71814 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -265,6 +265,22 @@ pub struct ExternalIp { pub kind: IpKind, } +/// A Floating IP is a well-known IP address which can be attached +/// and detached from instances. +#[derive(ObjectIdentity, Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct FloatingIp { + #[serde(flatten)] + pub identity: IdentityMetadata, + /// The IP address held by this resource. + pub ip: IpAddr, + /// The project this resource exists within. + pub project_id: Uuid, + /// The ID of the instance that this Floating IP is attached to, + /// if it is presently in use. + pub instance_id: Option, +} + // RACKS /// View of an Rack diff --git a/openapi/nexus.json b/openapi/nexus.json index 1c7e25d004..6076663a2d 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -853,6 +853,204 @@ } } }, + "/v1/floating-ips": { + "get": { + "tags": [ + "floating-ips" + ], + "summary": "List all Floating IPs", + "operationId": "floating_ip_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIpResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [ + "project" + ] + } + }, + "post": { + "tags": [ + "floating-ips" + ], + "summary": "Create a Floating IP", + "operationId": "floating_ip_create", + "parameters": [ + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIpCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/floating-ips/{floating_ip}": { + "get": { + "tags": [ + "floating-ips" + ], + "summary": "Fetch a floating IP", + "operationId": "floating_ip_view", + "parameters": [ + { + "in": "path", + "name": "floating_ip", + "description": "Name or ID of the Floating IP", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "floating-ips" + ], + "summary": "Delete a Floating IP", + "operationId": "floating_ip_delete", + "parameters": [ + { + "in": "path", + "name": "floating_ip", + "description": "Name or ID of the Floating IP", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/groups": { "get": { "tags": [ @@ -10386,6 +10584,25 @@ "required": [ "type" ] + }, + { + "description": "An IP address providing both inbound and outbound access. The address is an existing Floating IP object assigned to the current project.\n\nThe floating IP must not be in use by another instance or service.", + "type": "object", + "properties": { + "floating_ip_name": { + "$ref": "#/components/schemas/Name" + }, + "type": { + "type": "string", + "enum": [ + "floating" + ] + } + }, + "required": [ + "floating_ip_name", + "type" + ] } ] }, @@ -10470,6 +10687,116 @@ "role_name" ] }, + "FloatingIp": { + "description": "A Floating IP is a well-known IP address which can be attached and detached from instances.", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "instance_id": { + "nullable": true, + "description": "The ID of the instance that this Floating IP is attached to, if it is presently in use.", + "type": "string", + "format": "uuid" + }, + "ip": { + "description": "The IP address held by this resource.", + "type": "string", + "format": "ip" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "project_id": { + "description": "The project this resource exists within.", + "type": "string", + "format": "uuid" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "ip", + "name", + "project_id", + "time_created", + "time_modified" + ] + }, + "FloatingIpCreate": { + "description": "Parameters for creating a new floating IP address for instances.", + "type": "object", + "properties": { + "address": { + "nullable": true, + "description": "An IP address to reserve for use as a floating IP. This field is optional: when not set, an address will be automatically chosen from `pool`. If set, then the IP must be available in the resolved `pool`.", + "type": "string", + "format": "ip" + }, + "description": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "pool": { + "nullable": true, + "description": "The parent IP pool that a floating IP is pulled from. If unset, the default pool is selected.", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + } + }, + "required": [ + "description", + "name" + ] + }, + "FloatingIpResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/FloatingIp" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Group": { "description": "View of a Group", "type": "object", @@ -15266,6 +15593,13 @@ "url": "http://docs.oxide.computer/api/disks" } }, + { + "name": "floating-ips", + "description": "Floating IPs allow a project to allocate well-known IPs to instances.", + "externalDocs": { + "url": "http://docs.oxide.computer/api/floating-ips" + } + }, { "name": "hidden", "description": "TODO operations that will not ship to customers", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 5e217b27a4..3a88b6cc9c 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -4252,18 +4252,23 @@ "$ref": "#/components/schemas/DiskRequest" } }, - "external_ips": { + "ephemeral_ip": { + "nullable": true, "description": "Zero or more external IP addresses (either floating or ephemeral), provided to an instance to allow inbound connectivity.", + "type": "string", + "format": "ip" + }, + "firewall_rules": { "type": "array", "items": { - "type": "string", - "format": "ip" + "$ref": "#/components/schemas/VpcFirewallRule" } }, - "firewall_rules": { + "floating_ips": { "type": "array", "items": { - "$ref": "#/components/schemas/VpcFirewallRule" + "type": "string", + "format": "ip" } }, "nics": { @@ -4282,8 +4287,8 @@ "required": [ "dhcp_config", "disks", - "external_ips", "firewall_rules", + "floating_ips", "nics", "properties", "source_nat" diff --git a/package-manifest.toml b/package-manifest.toml index 71788f06d7..27b4e7821c 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -476,8 +476,8 @@ only_for_targets.image = "standard" # 2. Copy dendrite.tar.gz from dendrite/out to omicron/out source.type = "prebuilt" source.repo = "dendrite" -source.commit = "2af6adea85c62ac37e451148b84e5eb0ef005f36" -source.sha256 = "dc93b671cce54e83ed55faaa267f81ba9e65abcd6714aa559d68a8783d73b1c1" +source.commit = "1b15e62b04044ef2b15c82d8dcef03f6fc24b3d8" +source.sha256 = "06b5eeedaebf30e96a5c5e932e08034c90947af7a54e9bc04d57d6807013ade9" output.type = "zone" output.intermediate_only = true @@ -501,8 +501,8 @@ only_for_targets.image = "standard" # 2. Copy the output zone image from dendrite/out to omicron/out source.type = "prebuilt" source.repo = "dendrite" -source.commit = "2af6adea85c62ac37e451148b84e5eb0ef005f36" -source.sha256 = "c34b10d47fa3eb9f9f6b3655ea4ed8a726f93399ea177efea79f5c89f2ab5a1e" +source.commit = "1b15e62b04044ef2b15c82d8dcef03f6fc24b3d8" +source.sha256 = "51be0b0342bc7cdf927797af45af3bc82861bb8efb174d50958cb16b5620c51d" output.type = "zone" output.intermediate_only = true @@ -519,8 +519,8 @@ only_for_targets.image = "standard" # 2. Copy dendrite.tar.gz from dendrite/out to omicron/out/dendrite-softnpu.tar.gz source.type = "prebuilt" source.repo = "dendrite" -source.commit = "2af6adea85c62ac37e451148b84e5eb0ef005f36" -source.sha256 = "ce7065227c092ee82704f39a966b7441e3ae82d75eedb6eb281bd8b3e5873e32" +source.commit = "1b15e62b04044ef2b15c82d8dcef03f6fc24b3d8" +source.sha256 = "9afb24cdae27755eaf86a856268686bb641048b5d450dae858cf47b9daaa46ed" output.type = "zone" output.intermediate_only = true diff --git a/schema/crdb/19.0.0/up01.sql b/schema/crdb/19.0.0/up01.sql new file mode 100644 index 0000000000..6cfa92f4c2 --- /dev/null +++ b/schema/crdb/19.0.0/up01.sql @@ -0,0 +1 @@ +ALTER TABLE omicron.public.external_ip ADD COLUMN IF NOT EXISTS project_id UUID; diff --git a/schema/crdb/19.0.0/up02.sql b/schema/crdb/19.0.0/up02.sql new file mode 100644 index 0000000000..733c46b0dc --- /dev/null +++ b/schema/crdb/19.0.0/up02.sql @@ -0,0 +1,4 @@ +ALTER TABLE omicron.public.external_ip ADD CONSTRAINT IF NOT EXISTS null_project_id CHECK ( + (kind = 'floating' AND is_service = FALSE AND project_id IS NOT NULL) OR + ((kind != 'floating' OR is_service = TRUE) AND project_id IS NULL) +); diff --git a/schema/crdb/19.0.0/up03.sql b/schema/crdb/19.0.0/up03.sql new file mode 100644 index 0000000000..d3577edc12 --- /dev/null +++ b/schema/crdb/19.0.0/up03.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS lookup_floating_ip_by_name on omicron.public.external_ip ( + name +) WHERE + kind = 'floating' AND + time_deleted is NULL AND + project_id is NULL; diff --git a/schema/crdb/19.0.0/up04.sql b/schema/crdb/19.0.0/up04.sql new file mode 100644 index 0000000000..9a40dc99c5 --- /dev/null +++ b/schema/crdb/19.0.0/up04.sql @@ -0,0 +1,7 @@ +CREATE UNIQUE INDEX IF NOT EXISTS lookup_floating_ip_by_name_and_project on omicron.public.external_ip ( + project_id, + name +) WHERE + kind = 'floating' AND + time_deleted is NULL AND + project_id is NOT NULL; diff --git a/schema/crdb/19.0.0/up05.sql b/schema/crdb/19.0.0/up05.sql new file mode 100644 index 0000000000..3e172e3e70 --- /dev/null +++ b/schema/crdb/19.0.0/up05.sql @@ -0,0 +1,19 @@ +CREATE VIEW IF NOT EXISTS omicron.public.floating_ip AS +SELECT + id, + name, + description, + time_created, + time_modified, + time_deleted, + ip_pool_id, + ip_pool_range_id, + is_service, + parent_id, + ip, + project_id +FROM + omicron.public.external_ip +WHERE + omicron.public.external_ip.kind = 'floating' AND + project_id IS NOT NULL; diff --git a/schema/crdb/19.0.0/up06.sql b/schema/crdb/19.0.0/up06.sql new file mode 100644 index 0000000000..30c0b3773a --- /dev/null +++ b/schema/crdb/19.0.0/up06.sql @@ -0,0 +1,3 @@ +ALTER TABLE omicron.public.external_ip ADD CONSTRAINT IF NOT EXISTS null_non_fip_parent_id CHECK ( + (kind != 'floating' AND parent_id is NOT NULL) OR (kind = 'floating') +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index f82829a2d9..0bf365a2f1 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -1662,6 +1662,9 @@ CREATE TABLE IF NOT EXISTS omicron.public.external_ip ( /* The last port in the allowed range, also inclusive. */ last_port INT4 NOT NULL, + /* FK to the `project` table. */ + project_id UUID, + /* The name must be non-NULL iff this is a floating IP. */ CONSTRAINT null_fip_name CHECK ( (kind != 'floating' AND name IS NULL) OR @@ -1674,6 +1677,14 @@ CREATE TABLE IF NOT EXISTS omicron.public.external_ip ( (kind = 'floating' AND description IS NOT NULL) ), + /* Only floating IPs can be attached to a project, and + * they must have a parent project if they are instance FIPs. + */ + CONSTRAINT null_project_id CHECK ( + (kind = 'floating' AND is_service = FALSE AND project_id is NOT NULL) OR + ((kind != 'floating' OR is_service = TRUE) AND project_id IS NULL) + ), + /* * Only nullable if this is a floating IP, which may exist not * attached to any instance or service yet. @@ -1717,6 +1728,43 @@ CREATE UNIQUE INDEX IF NOT EXISTS lookup_external_ip_by_parent ON omicron.public ) WHERE parent_id IS NOT NULL AND time_deleted IS NULL; +/* Enforce name-uniqueness of floating (service) IPs at fleet level. */ +CREATE UNIQUE INDEX IF NOT EXISTS lookup_floating_ip_by_name on omicron.public.external_ip ( + name +) WHERE + kind = 'floating' AND + time_deleted is NULL AND + project_id is NULL; + +/* Enforce name-uniqueness of floating IPs at project level. */ +CREATE UNIQUE INDEX IF NOT EXISTS lookup_floating_ip_by_name_and_project on omicron.public.external_ip ( + project_id, + name +) WHERE + kind = 'floating' AND + time_deleted is NULL AND + project_id is NOT NULL; + +CREATE VIEW IF NOT EXISTS omicron.public.floating_ip AS +SELECT + id, + name, + description, + time_created, + time_modified, + time_deleted, + ip_pool_id, + ip_pool_range_id, + is_service, + parent_id, + ip, + project_id +FROM + omicron.public.external_ip +WHERE + omicron.public.external_ip.kind = 'floating' AND + project_id IS NOT NULL; + /*******************************************************************/ /* @@ -3014,7 +3062,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - ( TRUE, NOW(), NOW(), '18.0.0', NULL) + ( TRUE, NOW(), NOW(), '19.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/sled-agent/src/instance.rs b/sled-agent/src/instance.rs index c37f0ffde6..a811678a48 100644 --- a/sled-agent/src/instance.rs +++ b/sled-agent/src/instance.rs @@ -208,7 +208,8 @@ struct InstanceInner { // Guest NIC and OPTE port information requested_nics: Vec, source_nat: SourceNatConfig, - external_ips: Vec, + ephemeral_ip: Option, + floating_ips: Vec, firewall_rules: Vec, dhcp_config: DhcpCfg, @@ -669,7 +670,8 @@ impl Instance { port_manager, requested_nics: hardware.nics, source_nat: hardware.source_nat, - external_ips: hardware.external_ips, + ephemeral_ip: hardware.ephemeral_ip, + floating_ips: hardware.floating_ips, firewall_rules: hardware.firewall_rules, dhcp_config, requested_disks: hardware.disks, @@ -882,15 +884,20 @@ impl Instance { // Create OPTE ports for the instance let mut opte_ports = Vec::with_capacity(inner.requested_nics.len()); for nic in inner.requested_nics.iter() { - let (snat, external_ips) = if nic.primary { - (Some(inner.source_nat), &inner.external_ips[..]) + let (snat, ephemeral_ip, floating_ips) = if nic.primary { + ( + Some(inner.source_nat), + inner.ephemeral_ip, + &inner.floating_ips[..], + ) } else { - (None, &[][..]) + (None, None, &[][..]) }; let port = inner.port_manager.create_port( nic, snat, - external_ips, + ephemeral_ip, + floating_ips, &inner.firewall_rules, inner.dhcp_config.clone(), )?; diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index 6be2ceabbd..a7d91e2b93 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -70,7 +70,8 @@ pub struct InstanceHardware { pub source_nat: SourceNatConfig, /// Zero or more external IP addresses (either floating or ephemeral), /// provided to an instance to allow inbound connectivity. - pub external_ips: Vec, + pub ephemeral_ip: Option, + pub floating_ips: Vec, pub firewall_rules: Vec, pub dhcp_config: DhcpConfig, // TODO: replace `propolis_client::*` with locally-modeled request type diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index dc309e8423..fb6de8d38a 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -1167,7 +1167,7 @@ impl ServiceManager { .collect(); let external_ip; - let (zone_type_str, nic, snat, external_ips) = match &zone_args + let (zone_type_str, nic, snat, floating_ips) = match &zone_args .omicron_type() { Some( @@ -1207,16 +1207,18 @@ impl ServiceManager { // config allows outbound access which is enough for // Boundary NTP which needs to come up before Nexus. let port = port_manager - .create_port(nic, snat, external_ips, &[], DhcpCfg::default()) + .create_port(nic, snat, None, floating_ips, &[], DhcpCfg::default()) .map_err(|err| Error::ServicePortCreation { service: zone_type_str.clone(), err: Box::new(err), })?; // We also need to update the switch with the NAT mappings + // XXX: need to revisit iff. any services get more than one + // address. let (target_ip, first_port, last_port) = match snat { Some(s) => (s.ip, s.first_port, s.last_port), - None => (external_ips[0], 0, u16::MAX), + None => (floating_ips[0], 0, u16::MAX), }; for dpd_client in &dpd_clients { diff --git a/tools/dendrite_openapi_version b/tools/dendrite_openapi_version index c2dda4dbd0..b6dc45a8d0 100644 --- a/tools/dendrite_openapi_version +++ b/tools/dendrite_openapi_version @@ -1,2 +1,2 @@ -COMMIT="2af6adea85c62ac37e451148b84e5eb0ef005f36" +COMMIT="1b15e62b04044ef2b15c82d8dcef03f6fc24b3d8" SHA2="07d115bfa8498a8015ca2a8447efeeac32e24aeb25baf3d5e2313216e11293c0" diff --git a/tools/dendrite_stub_checksums b/tools/dendrite_stub_checksums index 77ee198fc5..95f04db9e8 100644 --- a/tools/dendrite_stub_checksums +++ b/tools/dendrite_stub_checksums @@ -1,3 +1,3 @@ -CIDL_SHA256_ILLUMOS="dc93b671cce54e83ed55faaa267f81ba9e65abcd6714aa559d68a8783d73b1c1" -CIDL_SHA256_LINUX_DPD="b13b391a085ba6bf16fdd99774f64c9d53cd7220ad518d5839c8558fb925c40c" -CIDL_SHA256_LINUX_SWADM="6bfa4e367eb2b0be89f1588ac458026a186314597a4feb9fee6cea60101c7ebe" +CIDL_SHA256_ILLUMOS="06b5eeedaebf30e96a5c5e932e08034c90947af7a54e9bc04d57d6807013ade9" +CIDL_SHA256_LINUX_DPD="99a800cbd5739245154831004892d47be5a871e37c536ec3009911ddb02fdb16" +CIDL_SHA256_LINUX_SWADM="e92bfc071f3944523a2e69b13ee877a4fd87cb8a9a78011b4aa8f40218347e25" diff --git a/tools/opte_version b/tools/opte_version index 0a79a6aba9..fa0ef8d768 100644 --- a/tools/opte_version +++ b/tools/opte_version @@ -1 +1 @@ -0.25.183 +0.27.199