diff --git a/.github/buildomat/jobs/deploy.sh b/.github/buildomat/jobs/deploy.sh index f4f1e0a9997..e69cfb0078c 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.27" +#: target = "lab-2.0-opte-0.28" #: output_rules = [ #: "%/var/svc/log/oxide-sled-agent:default.log*", #: "%/pool/ext/*/crypt/zone/oxz_*/root/var/svc/log/oxide-*.log*", @@ -33,6 +33,9 @@ _exit_trap() { local status=$? [[ $status -eq 0 ]] && exit 0 + # XXX paranoia + pfexec cp /tmp/opteadm /opt/oxide/opte/bin/opteadm + set +o errexit set -o xtrace banner evidence @@ -50,6 +53,7 @@ _exit_trap() { standalone \ dump-state pfexec /opt/oxide/opte/bin/opteadm list-ports + pfexec /opt/oxide/opte/bin/opteadm dump-v2b z_swadm link ls z_swadm addr list z_swadm route list @@ -97,6 +101,19 @@ z_swadm () { pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm $@ } +# XXX remove. This is just to test against a development branch of OPTE in CI. +set +x +OPTE_COMMIT="73d4669ea213d0b7aca35c4babb6fd09ed51d29e" +curl -sSfOL https://buildomat.eng.oxide.computer/public/file/oxidecomputer/opte/module/$OPTE_COMMIT/xde +pfexec rem_drv xde || true +pfexec mv xde /kernel/drv/amd64/xde +pfexec add_drv xde || true +curl -sSfOL https://buildomat.eng.oxide.computer/wg/0/artefact/01HM09S4M15WNXB2B2MX8R1GBT/yLalJU5vT4S4IEpwSeY4hPuspxw3JcINokZmlfNU14npHkzG/01HM09SJ2RQSFGW7MVKC9JKZ8D/01HM0A58D888AJ7YP6N1Q6T6ZD/opteadm +chmod +x opteadm +cp opteadm /tmp/opteadm +pfexec mv opteadm /opt/oxide/opte/bin/opteadm +set -x + # # XXX work around 14537 (UFS should not allow directories to be unlinked) which # is probably not yet fixed in xde branch? Once the xde branch merges from @@ -236,7 +253,7 @@ infra_ip_last = \"$UPLINK_IP\" /^routes/c\\ routes = \\[{nexthop = \"$GATEWAY_IP\", destination = \"0.0.0.0/0\"}\\] /^addresses/c\\ -addresses = \\[\"$UPLINK_IP/32\"\\] +addresses = \\[\"$UPLINK_IP/24\"\\] } " pkg/config-rss.toml diff -u pkg/config-rss.toml{~,} || true diff --git a/.github/buildomat/jobs/package.sh b/.github/buildomat/jobs/package.sh index b4d10891b9d..79590a44dfa 100755 --- a/.github/buildomat/jobs/package.sh +++ b/.github/buildomat/jobs/package.sh @@ -117,7 +117,7 @@ zones=( out/internal-dns.tar.gz out/omicron-nexus.tar.gz out/omicron-nexus-single-sled.tar.gz - out/oximeter-collector.tar.gz + out/oximeter.tar.gz out/propolis-server.tar.gz out/switch-*.tar.gz out/ntp.tar.gz diff --git a/.github/workflows/hakari.yml b/.github/workflows/hakari.yml index 06da0395a1f..46d09c09407 100644 --- a/.github/workflows/hakari.yml +++ b/.github/workflows/hakari.yml @@ -24,7 +24,7 @@ jobs: with: toolchain: stable - name: Install cargo-hakari - uses: taiki-e/install-action@cf2d7f1118304815479579570ad3ec572fe94523 # v2 + uses: taiki-e/install-action@9f9bf5e8df111848fb25b8a97a361d8963025899 # v2 with: tool: cargo-hakari - name: Check workspace-hack Cargo.toml is up-to-date diff --git a/Cargo.lock b/Cargo.lock index 4d531366f61..7ea3d2b96d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -168,7 +174,7 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -269,7 +275,7 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -291,7 +297,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -302,7 +308,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -353,7 +359,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream 0.2.0", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -473,11 +479,11 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.65.1" +version = "0.69.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +checksum = "a4c69fae65a523209d34240b60abe0c42d33d1045d445c0839d8a4894a736e2d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.0", "cexpr", "clang-sys", "lazy_static", @@ -490,7 +496,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.46", + "syn 2.0.48", "which", ] @@ -986,7 +992,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -997,13 +1003,11 @@ checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" [[package]] name = "clipboard-win" -version = "4.5.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +checksum = "c57002a5d9be777c1ef967e33674dac9ebd310d8893e4e3437b14d5f0f6372cc" dependencies = [ "error-code", - "str-buf", - "winapi", ] [[package]] @@ -1412,7 +1416,7 @@ checksum = "83fdaf97f4804dcebfa5862639bc9ce4121e82140bec2a987ac5140294865b5b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1460,7 +1464,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.10.0", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1482,7 +1486,7 @@ checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core 0.20.3", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1514,7 +1518,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream 0.2.0", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1566,7 +1570,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1599,7 +1603,7 @@ checksum = "5fe87ce4529967e0ba1dcf8450bab64d97dfd5010a6256187ffe2e43e6f0e049" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1619,7 +1623,7 @@ checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1706,7 +1710,7 @@ dependencies = [ "diesel_table_macro_syntax", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1715,7 +1719,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" dependencies = [ - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1958,7 +1962,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream 0.2.0", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -2155,13 +2159,9 @@ dependencies = [ [[package]] name = "error-code" -version = "2.3.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" -dependencies = [ - "libc", - "str-buf", -] +checksum = "281e452d3bad4005426416cdba5ccfd4f5c1280e10099e21db27f7c1c28347fc" [[package]] name = "expectorate" @@ -2209,6 +2209,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "fd-lock" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" +dependencies = [ + "cfg-if", + "rustix 0.38.30", + "windows-sys 0.52.0", +] + [[package]] name = "ff" version = "0.13.0" @@ -2313,7 +2324,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -2430,7 +2441,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -2772,6 +2783,10 @@ name = "hashbrown" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "headers" @@ -3247,7 +3262,7 @@ dependencies = [ [[package]] name = "illumos-sys-hdrs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" [[package]] name = "illumos-utils" @@ -3595,15 +3610,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.12.0" @@ -3656,10 +3662,10 @@ dependencies = [ [[package]] name = "kstat-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" dependencies = [ "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -3885,6 +3891,15 @@ dependencies = [ "zerocopy 0.6.4", ] +[[package]] +name = "lru" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2994eeba8ed550fd9b47a0b38f0242bc3344e496483c6180b69139cc2fa5d1d7" +dependencies = [ + "hashbrown 0.14.2", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -4062,7 +4077,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -4263,6 +4278,7 @@ dependencies = [ "steno", "strum", "subprocess", + "swrite", "term", "thiserror", "tokio", @@ -4377,6 +4393,7 @@ dependencies = [ "serde_urlencoded", "slog", "tokio", + "tokio-util", "trust-dns-resolver", "uuid", ] @@ -4387,7 +4404,7 @@ version = "0.1.0" dependencies = [ "omicron-workspace-hack", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -4440,11 +4457,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.26.4" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.0", "cfg-if", "libc", ] @@ -4540,7 +4557,7 @@ checksum = "9e6a0fd4f737c707bd9086cc16c925f294943eb62eb71499e9fd4cf71f8b9f4e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -4837,6 +4854,7 @@ dependencies = [ "async-trait", "base64", "buf-list", + "bytes", "camino", "camino-tempfile", "cancel-safe-futures", @@ -4937,6 +4955,9 @@ dependencies = [ "tokio-postgres", "tough", "trust-dns-resolver", + "tufaceous", + "tufaceous-lib", + "update-common", "uuid", ] @@ -5172,6 +5193,7 @@ name = "omicron-workspace-hack" version = "0.1.0" dependencies = [ "ahash", + "aho-corasick", "anyhow", "base16ct", "bit-set", @@ -5213,6 +5235,7 @@ dependencies = [ "getrandom 0.2.10", "group", "hashbrown 0.13.2", + "hashbrown 0.14.2", "hex", "hmac", "hyper 0.14.27", @@ -5243,7 +5266,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "regex", - "regex-automata 0.4.3", + "regex-automata 0.4.4", "regex-syntax 0.8.2", "reqwest", "ring 0.17.7", @@ -5255,13 +5278,12 @@ dependencies = [ "sha2", "similar", "slog", - "snafu", "socket2 0.5.5", "spin 0.9.8", "string_cache", "subtle", "syn 1.0.109", - "syn 2.0.46", + "syn 2.0.48", "time", "time-macros", "tokio", @@ -5375,7 +5397,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -5399,7 +5421,7 @@ dependencies = [ [[package]] name = "opte" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" dependencies = [ "cfg-if", "dyn-clone", @@ -5415,7 +5437,7 @@ dependencies = [ [[package]] name = "opte-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" dependencies = [ "illumos-sys-hdrs", "ipnetwork", @@ -5427,7 +5449,7 @@ dependencies = [ [[package]] name = "opte-ioctl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" dependencies = [ "libc", "libnet", @@ -5501,10 +5523,12 @@ dependencies = [ [[package]] name = "oxide-vpc" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=dd2b7b0306d3f01fa09170b8884d402209e49244#dd2b7b0306d3f01fa09170b8884d402209e49244" +source = "git+https://github.com/oxidecomputer/opte?rev=1d29ef60a18179babfb44f0f7a3c2fe71034a2c1#1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" dependencies = [ + "cfg-if", "illumos-sys-hdrs", "opte", + "poptrie", "serde", "smoltcp 0.11.0", "zerocopy 0.7.31", @@ -5658,7 +5682,7 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -5810,7 +5834,7 @@ dependencies = [ "regex", "regex-syntax 0.7.5", "structmeta", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -5956,7 +5980,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -6026,7 +6050,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -6141,6 +6165,11 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "poptrie" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/poptrie?branch=multipath#ca52bef3f87ff1a67d81b3c6e601dcb5fdbcc165" + [[package]] name = "portable-atomic" version = "1.4.3" @@ -6270,7 +6299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -6318,17 +6347,17 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.74" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] name = "progenitor" -version = "0.4.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=main#9339b57628e1e76b1d7131ef93a6c0db2ab0a762" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/progenitor?branch=main#2d3b9d0eb50a1907974c0b0ba7ee7893425b3e79" dependencies = [ "progenitor-client", "progenitor-impl", @@ -6338,8 +6367,8 @@ dependencies = [ [[package]] name = "progenitor-client" -version = "0.4.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=main#9339b57628e1e76b1d7131ef93a6c0db2ab0a762" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/progenitor?branch=main#2d3b9d0eb50a1907974c0b0ba7ee7893425b3e79" dependencies = [ "bytes", "futures-core", @@ -6352,8 +6381,8 @@ dependencies = [ [[package]] name = "progenitor-impl" -version = "0.4.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=main#9339b57628e1e76b1d7131ef93a6c0db2ab0a762" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/progenitor?branch=main#2d3b9d0eb50a1907974c0b0ba7ee7893425b3e79" dependencies = [ "getopts", "heck 0.4.1", @@ -6366,7 +6395,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "syn 2.0.46", + "syn 2.0.48", "thiserror", "typify", "unicode-ident", @@ -6374,8 +6403,8 @@ dependencies = [ [[package]] name = "progenitor-macro" -version = "0.4.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=main#9339b57628e1e76b1d7131ef93a6c0db2ab0a762" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/progenitor?branch=main#2d3b9d0eb50a1907974c0b0ba7ee7893425b3e79" dependencies = [ "openapiv3", "proc-macro2", @@ -6386,7 +6415,7 @@ dependencies = [ "serde_json", "serde_tokenstream 0.2.0", "serde_yaml", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -6477,9 +6506,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.23.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" dependencies = [ "memchr", "serde", @@ -6631,16 +6660,18 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.23.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad" +checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" dependencies = [ "bitflags 2.4.0", "cassowary", "crossterm", "indoc 2.0.3", - "itertools 0.11.0", + "itertools 0.12.0", + "lru", "paste", + "stability", "strum", "unicode-segmentation", "unicode-width", @@ -6733,7 +6764,7 @@ checksum = "68f4e89a0f80909b3ca4bca9759ed37e4bfddb6f5d2ffb1b4ceb2b1638a3e1eb" dependencies = [ "chrono", "crossterm", - "fd-lock", + "fd-lock 3.0.13", "itertools 0.12.0", "nu-ansi-term", "serde", @@ -6762,18 +6793,18 @@ checksum = "7f7473c2cfcf90008193dd0e3e16599455cb601a9fce322b5bb55de799664925" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] name = "regex" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.3", + "regex-automata 0.4.4", "regex-syntax 0.8.2", ] @@ -6791,9 +6822,9 @@ checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a" dependencies = [ "aho-corasick", "memchr", @@ -7009,7 +7040,7 @@ dependencies = [ "regex", "relative-path", "rustc_version 0.4.0", - "syn 2.0.46", + "syn 2.0.48", "unicode-ident", ] @@ -7311,21 +7342,20 @@ dependencies = [ [[package]] name = "rustyline" -version = "12.0.0" +version = "13.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" +checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86" dependencies = [ "bitflags 2.4.0", "cfg-if", "clipboard-win", - "fd-lock", + "fd-lock 4.0.2", "home", "libc", "log", "memchr", - "nix 0.26.4", + "nix 0.27.1", "radix_trie", - "scopeguard", "unicode-segmentation", "unicode-width", "utf8parse", @@ -7351,8 +7381,9 @@ dependencies = [ [[package]] name = "samael" -version = "0.0.10" -source = "git+https://github.com/njaremko/samael?branch=master#52028e45d11ceb7114bf0c730a9971207e965602" +version = "0.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75583aad4a51c50fc0af69c230d18078c9d5a69a98d0f6013d01053acf744f4" dependencies = [ "base64", "bindgen", @@ -7370,7 +7401,7 @@ dependencies = [ "quick-xml", "rand 0.8.5", "serde", - "snafu", + "thiserror", "url", "uuid", ] @@ -7570,7 +7601,7 @@ checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -7631,7 +7662,7 @@ checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -7663,7 +7694,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -7680,9 +7711,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.4.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +checksum = "f5c9fdb6b00a489875b22efd4b78fe2b363b72265cc5f6eb2e2b9ee270e6140c" dependencies = [ "base64", "chrono", @@ -7697,14 +7728,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.4.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +checksum = "dbff351eb4b33600a2e138dfa0b10b65a238ea8ff8fb2387c422c5022a3e8298" dependencies = [ "darling 0.20.3", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8018,7 +8049,7 @@ source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8273,7 +8304,17 @@ checksum = "01b2e185515564f15375f593fb966b5718bc624ba77fe49fa4616ad619690554" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", +] + +[[package]] +name = "stability" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +dependencies = [ + "quote", + "syn 1.0.109", ] [[package]] @@ -8310,12 +8351,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "string_cache" version = "0.8.7" @@ -8370,7 +8405,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8381,7 +8416,7 @@ checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8440,7 +8475,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8488,9 +8523,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.46" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -8672,7 +8707,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8697,22 +8732,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -8899,7 +8934,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -9166,7 +9201,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -9351,9 +9386,9 @@ dependencies = [ [[package]] name = "tui-tree-widget" -version = "0.13.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f01f9172fb8f4f925fb1e259c2f411be14af031ab8b35d517fd05cb78c0784d5" +checksum = "136011b328c4f392499a02c4b5b78d509fb297bf9c10f2bda5d11d65cb946e4c" dependencies = [ "ratatui", "unicode-width", @@ -9392,8 +9427,8 @@ checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "typify" -version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#c9d6453fc3cf69726d539925b838b267f886cb53" +version = "0.0.15" +source = "git+https://github.com/oxidecomputer/typify#1f97f167923f001818d461b1286f8a5242abf8b1" dependencies = [ "typify-impl", "typify-macro", @@ -9401,8 +9436,8 @@ dependencies = [ [[package]] name = "typify-impl" -version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#c9d6453fc3cf69726d539925b838b267f886cb53" +version = "0.0.15" +source = "git+https://github.com/oxidecomputer/typify#1f97f167923f001818d461b1286f8a5242abf8b1" dependencies = [ "heck 0.4.1", "log", @@ -9411,15 +9446,15 @@ dependencies = [ "regress", "schemars", "serde_json", - "syn 2.0.46", + "syn 2.0.48", "thiserror", "unicode-ident", ] [[package]] name = "typify-macro" -version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#c9d6453fc3cf69726d539925b838b267f886cb53" +version = "0.0.15" +source = "git+https://github.com/oxidecomputer/typify#1f97f167923f001818d461b1286f8a5242abf8b1" dependencies = [ "proc-macro2", "quote", @@ -9427,7 +9462,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream 0.2.0", - "syn 2.0.46", + "syn 2.0.48", "typify-impl", ] @@ -9539,6 +9574,7 @@ dependencies = [ "bytes", "camino", "camino-tempfile", + "chrono", "clap 4.4.3", "debug-ignore", "display-error-chain", @@ -9682,9 +9718,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ "getrandom 0.2.10", "serde", @@ -9816,7 +9852,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", "wasm-bindgen-shared", ] @@ -9850,7 +9886,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -10393,7 +10429,7 @@ checksum = "56097d5b91d711293a42be9289403896b68654625021732067eac7a4ca388a1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -10404,7 +10440,7 @@ checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -10424,7 +10460,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5364f4b4e15..ba328fe6126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -270,7 +270,7 @@ omicron-sled-agent = { path = "sled-agent" } omicron-test-utils = { path = "test-utils" } omicron-zone-package = "0.10.1" oxide-client = { path = "clients/oxide-client" } -oxide-vpc = { git = "https://github.com/oxidecomputer/opte", rev = "dd2b7b0306d3f01fa09170b8884d402209e49244", features = [ "api", "std" ] } +oxide-vpc = { git = "https://github.com/oxidecomputer/opte", rev = "1d29ef60a18179babfb44f0f7a3c2fe71034a2c1", features = [ "api", "std" ] } once_cell = "1.19.0" openapi-lint = { git = "https://github.com/oxidecomputer/openapi-lint", branch = "main" } openapiv3 = "2.0.0" @@ -278,7 +278,7 @@ openapiv3 = "2.0.0" openssl = "0.10" openssl-sys = "0.9" openssl-probe = "0.1.5" -opte-ioctl = { git = "https://github.com/oxidecomputer/opte", rev = "dd2b7b0306d3f01fa09170b8884d402209e49244" } +opte-ioctl = { git = "https://github.com/oxidecomputer/opte", rev = "1d29ef60a18179babfb44f0f7a3c2fe71034a2c1" } oso = "0.27" owo-colors = "3.5.0" oximeter = { path = "oximeter/oximeter" } @@ -310,12 +310,12 @@ propolis-mock-server = { git = "https://github.com/oxidecomputer/propolis", rev proptest = "1.4.0" quote = "1.0" rand = "0.8.5" -ratatui = "0.23.0" +ratatui = "0.25.0" rayon = "1.8" rcgen = "0.12.0" reedline = "0.28.0" ref-cast = "1.0" -regex = "1.10.2" +regex = "1.10.3" regress = "0.7.1" reqwest = { version = "0.11", default-features = false } ring = "0.17.7" @@ -324,8 +324,8 @@ rstest = "0.18.2" rustfmt-wrapper = "0.2" rustls = "0.22.2" rustls-pemfile = "2.0.0" -rustyline = "12.0.0" -samael = { git = "https://github.com/njaremko/samael", features = ["xmlsec"], branch = "master" } +rustyline = "13.0.0" +samael = { version = "0.0.14", features = ["xmlsec"] } schemars = "0.8.16" secrecy = "0.8.0" semver = { version = "1.0.21", features = ["std", "serde"] } @@ -336,7 +336,7 @@ serde_json = "1.0.111" serde_path_to_error = "0.1.15" serde_tokenstream = "0.2" serde_urlencoded = "0.7.1" -serde_with = "3.4.0" +serde_with = "3.5.1" sha2 = "0.10.8" sha3 = "0.10.8" shell-words = "1.1.0" @@ -396,11 +396,12 @@ trust-dns-server = "0.22" trybuild = "1.0.89" tufaceous = { path = "tufaceous" } tufaceous-lib = { path = "tufaceous-lib" } +tui-tree-widget = "0.16.0" unicode-width = "0.1.11" update-common = { path = "update-common" } update-engine = { path = "update-engine" } usdt = "0.3" -uuid = { version = "1.6.1", features = ["serde", "v4"] } +uuid = { version = "1.7.0", features = ["serde", "v4"] } walkdir = "2.4" wicket = { path = "wicket" } wicket-common = { path = "wicket-common" } diff --git a/clients/ddm-admin-client/src/lib.rs b/clients/ddm-admin-client/src/lib.rs index 93248c73a15..c32345d1dce 100644 --- a/clients/ddm-admin-client/src/lib.rs +++ b/clients/ddm-admin-client/src/lib.rs @@ -20,7 +20,7 @@ pub use inner::types; pub use inner::Error; use either::Either; -use inner::types::Ipv6Prefix; +use inner::types::{Ipv6Prefix, TunnelOrigin}; use inner::Client as InnerClient; use omicron_common::address::Ipv6Subnet; use omicron_common::address::SLED_PREFIX; @@ -108,6 +108,22 @@ impl Client { }); } + pub fn advertise_tunnel_endpoint(&self, endpoint: TunnelOrigin) { + let me = self.clone(); + tokio::spawn(async move { + retry_notify(retry_policy_internal_service_aggressive(), || async { + me.inner.advertise_tunnel_endpoints(&vec![endpoint.clone()]).await?; + Ok(()) + }, |err, duration| { + info!( + me.log, + "Failed to notify ddmd of tunnel endpoint (retry in {duration:?}"; + "err" => %err, + ); + }).await.unwrap(); + }); + } + /// Returns the addresses of connected sleds. /// /// Note: These sleds have not yet been verified. diff --git a/clients/dns-service-client/src/lib.rs b/clients/dns-service-client/src/lib.rs index 931e68322f5..e437f1a7f63 100644 --- a/clients/dns-service-client/src/lib.rs +++ b/clients/dns-service-client/src/lib.rs @@ -29,8 +29,10 @@ pub fn is_retryable(error: &DnsConfigError) -> bool { let response_value = match error { DnsConfigError::CommunicationError(_) => return true, DnsConfigError::InvalidRequest(_) - | DnsConfigError::InvalidResponsePayload(_) - | DnsConfigError::UnexpectedResponse(_) => return false, + | DnsConfigError::InvalidResponsePayload(_, _) + | DnsConfigError::UnexpectedResponse(_) + | DnsConfigError::InvalidUpgrade(_) + | DnsConfigError::ResponseBodyError(_) => return false, DnsConfigError::ErrorResponse(response_value) => response_value, }; diff --git a/clients/nexus-client/src/lib.rs b/clients/nexus-client/src/lib.rs index 1e1cbc31e74..17fb5aa367c 100644 --- a/clients/nexus-client/src/lib.rs +++ b/clients/nexus-client/src/lib.rs @@ -236,32 +236,6 @@ impl From } } -impl From - for types::KnownArtifactKind -{ - fn from( - s: omicron_common::api::internal::nexus::KnownArtifactKind, - ) -> Self { - use omicron_common::api::internal::nexus::KnownArtifactKind; - - match s { - KnownArtifactKind::GimletSp => types::KnownArtifactKind::GimletSp, - KnownArtifactKind::GimletRot => types::KnownArtifactKind::GimletRot, - KnownArtifactKind::Host => types::KnownArtifactKind::Host, - KnownArtifactKind::Trampoline => { - types::KnownArtifactKind::Trampoline - } - KnownArtifactKind::ControlPlane => { - types::KnownArtifactKind::ControlPlane - } - KnownArtifactKind::PscSp => types::KnownArtifactKind::PscSp, - KnownArtifactKind::PscRot => types::KnownArtifactKind::PscRot, - KnownArtifactKind::SwitchSp => types::KnownArtifactKind::SwitchSp, - KnownArtifactKind::SwitchRot => types::KnownArtifactKind::SwitchRot, - } - } -} - impl From for types::Duration { fn from(s: std::time::Duration) -> Self { Self { secs: s.as_secs(), nanos: s.subsec_nanos() } diff --git a/common/src/api/external/error.rs b/common/src/api/external/error.rs index 2661db7bb68..a3876fcac35 100644 --- a/common/src/api/external/error.rs +++ b/common/src/api/external/error.rs @@ -487,20 +487,19 @@ pub trait ClientError: std::fmt::Debug { impl From> for Error { fn from(e: progenitor::progenitor_client::Error) -> Self { match e { - // This error indicates that the inputs were not valid for this API - // call. It's reflective of either a client-side programming error. - progenitor::progenitor_client::Error::InvalidRequest(msg) => { - Error::internal_error(&format!("InvalidRequest: {}", msg)) + // For most error variants, we delegate to the display impl for the + // Progenitor error type, but we pick apart an error response more + // carefully. + progenitor::progenitor_client::Error::InvalidRequest(_) + | progenitor::progenitor_client::Error::CommunicationError(_) + | progenitor::progenitor_client::Error::InvalidResponsePayload( + .., + ) + | progenitor::progenitor_client::Error::UnexpectedResponse(_) + | progenitor::progenitor_client::Error::InvalidUpgrade(_) + | progenitor::progenitor_client::Error::ResponseBodyError(_) => { + Error::internal_error(&e.to_string()) } - - // This error indicates a problem with the request to the remote - // service that did not result in an HTTP response code, but rather - // pertained to local (i.e. client-side) encoding or network - // communication. - progenitor::progenitor_client::Error::CommunicationError(ee) => { - Error::internal_error(&format!("CommunicationError: {}", ee)) - } - // This error represents an expected error from the remote service. progenitor::progenitor_client::Error::ErrorResponse(rv) => { let message = rv.message(); @@ -515,30 +514,6 @@ impl From> for Error { _ => Error::internal_error(&message), } } - - // This error indicates that the body returned by the client didn't - // match what was documented in the OpenAPI description for the - // service. This could only happen for us in the case of a severe - // logic/encoding bug in the remote service or due to a failure of - // our version constraints (i.e. that the call was to a newer - // service with an incompatible response). - progenitor::progenitor_client::Error::InvalidResponsePayload( - ee, - ) => Error::internal_error(&format!( - "InvalidResponsePayload: {}", - ee, - )), - - // This error indicates that the client generated a response code - // that was not described in the OpenAPI description for the - // service; this could be a success or failure response, but either - // way it indicates a logic or version error as above. - progenitor::progenitor_client::Error::UnexpectedResponse(r) => { - Error::internal_error(&format!( - "UnexpectedResponse: status code {}", - r.status(), - )) - } } } } diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 68fcb0f9fa3..dc3537fbb25 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -13,6 +13,8 @@ use dropshot::HttpError; pub use error::*; pub use crate::api::internal::shared::SwitchLocation; +use crate::update::ArtifactHash; +use crate::update::ArtifactId; use anyhow::anyhow; use anyhow::Context; use api_identity::ObjectIdentity; @@ -760,13 +762,9 @@ pub enum ResourceType { Oximeter, MetricProducer, RoleBuiltin, - UpdateArtifact, + TufRepo, + TufArtifact, SwitchPort, - SystemUpdate, - ComponentUpdate, - SystemUpdateComponentUpdate, - UpdateDeployment, - UpdateableComponent, UserBuiltin, Zpool, Vmm, @@ -2625,6 +2623,101 @@ pub struct BgpImportedRouteIpv4 { pub switch: SwitchLocation, } +/// A description of an uploaded TUF repository. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct TufRepoDescription { + // Information about the repository. + pub repo: TufRepoMeta, + + // Information about the artifacts present in the repository. + pub artifacts: Vec, +} + +impl TufRepoDescription { + /// Sorts the artifacts so that descriptions can be compared. + pub fn sort_artifacts(&mut self) { + self.artifacts.sort_by(|a, b| a.id.cmp(&b.id)); + } +} + +/// Metadata about a TUF repository. +/// +/// Found within a [`TufRepoDescription`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct TufRepoMeta { + /// The hash of the repository. + /// + /// This is a slight abuse of `ArtifactHash`, since that's the hash of + /// individual artifacts within the repository. However, we use it here for + /// convenience. + pub hash: ArtifactHash, + + /// The version of the targets role. + pub targets_role_version: u64, + + /// The time until which the repo is valid. + pub valid_until: DateTime, + + /// The system version in artifacts.json. + pub system_version: SemverVersion, + + /// The file name of the repository. + /// + /// This is purely used for debugging and may not always be correct (e.g. + /// with wicket, we read the file contents from stdin so we don't know the + /// correct file name). + pub file_name: String, +} + +/// Metadata about an individual TUF artifact. +/// +/// Found within a [`TufRepoDescription`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct TufArtifactMeta { + /// The artifact ID. + pub id: ArtifactId, + + /// The hash of the artifact. + pub hash: ArtifactHash, + + /// The size of the artifact in bytes. + pub size: u64, +} + +/// Data about a successful TUF repo import into Nexus. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct TufRepoInsertResponse { + /// The repository as present in the database. + pub recorded: TufRepoDescription, + + /// Whether this repository already existed or is new. + pub status: TufRepoInsertStatus, +} + +/// Status of a TUF repo import. +/// +/// Part of [`TufRepoInsertResponse`]. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum TufRepoInsertStatus { + /// The repository already existed in the database. + AlreadyExists, + + /// The repository did not exist, and was inserted into the database. + Inserted, +} + +/// Data about a successful TUF repo get from Nexus. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct TufRepoGetResponse { + /// The description of the repository. + pub description: TufRepoDescription, +} + #[cfg(test)] mod test { use serde::Deserialize; @@ -3253,7 +3346,7 @@ mod test { let net_des = serde_json::from_str::(&ser).unwrap(); assert_eq!(net, net_des); - let net_str = "fd00:99::1/64"; + let net_str = "fd00:47::1/64"; let net = IpNet::from_str(net_str).unwrap(); let ser = serde_json::to_string(&net).unwrap(); diff --git a/common/src/nexus_config.rs b/common/src/nexus_config.rs index 7f26bd84b0f..be4b05ffdfa 100644 --- a/common/src/nexus_config.rs +++ b/common/src/nexus_config.rs @@ -213,8 +213,6 @@ pub struct ConsoleConfig { pub struct UpdatesConfig { /// Trusted root.json role for the TUF updates repository. pub trusted_root: Utf8PathBuf, - /// Default base URL for the TUF repository. - pub default_base_url: String, } /// Options to tweak database schema changes. @@ -631,7 +629,6 @@ mod test { address = "[::1]:8123" [updates] trusted_root = "/path/to/root.json" - default_base_url = "http://example.invalid/" [tunables] max_vpc_ipv4_subnet_prefix = 27 [deployment] @@ -728,7 +725,6 @@ mod test { }, updates: Some(UpdatesConfig { trusted_root: Utf8PathBuf::from("/path/to/root.json"), - default_base_url: "http://example.invalid/".into(), }), schema: None, tunables: Tunables { max_vpc_ipv4_subnet_prefix: 27 }, diff --git a/common/src/update.rs b/common/src/update.rs index 28d5ae50a6b..9feff1f8688 100644 --- a/common/src/update.rs +++ b/common/src/update.rs @@ -95,6 +95,13 @@ pub struct ArtifactId { pub kind: ArtifactKind, } +/// Used for user-friendly messages. +impl fmt::Display for ArtifactId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} v{} ({})", self.name, self.version, self.kind) + } +} + /// A hash-based identifier for an artifact. /// /// Some places, e.g. the installinator, request artifacts by hash rather than diff --git a/dev-tools/omdb/src/bin/omdb/db.rs b/dev-tools/omdb/src/bin/omdb/db.rs index 23e92065061..a4651833516 100644 --- a/dev-tools/omdb/src/bin/omdb/db.rs +++ b/dev-tools/omdb/src/bin/omdb/db.rs @@ -44,6 +44,7 @@ use nexus_db_model::ExternalIp; use nexus_db_model::HwBaseboardId; use nexus_db_model::Instance; use nexus_db_model::InvCollection; +use nexus_db_model::IpAttachState; use nexus_db_model::Project; use nexus_db_model::Region; use nexus_db_model::RegionSnapshot; @@ -1705,6 +1706,7 @@ async fn cmd_db_eips( ip: ipnetwork::IpNetwork, ports: PortRange, kind: String, + state: IpAttachState, owner: Owner, } @@ -1789,6 +1791,7 @@ async fn cmd_db_eips( first: ip.first_port.into(), last: ip.last_port.into(), }, + state: ip.state, kind: format!("{:?}", ip.kind), owner, }; diff --git a/docs/boundary-services-a-to-z.adoc b/docs/boundary-services-a-to-z.adoc index 6f4f2fcea6d..e4c47ac7f91 100644 --- a/docs/boundary-services-a-to-z.adoc +++ b/docs/boundary-services-a-to-z.adoc @@ -1,115 +1,18 @@ = Boundary Services A-Z -NOTE: The instructions for _deploying_ SoftNPU with Omicron have been folded into xref:how-to-run.adoc[the main how-to-run docs]. +NOTE: The instructions for _deploying_ SoftNPU with Omicron have been folded +into xref:how-to-run.adoc[the main how-to-run docs]. -The virtual hardware making up SoftNPU is a bit different than what was previously used. What we now have looks like this. +The virtual hardware making up SoftNPU is depicted in the diagram below. image::plumbing.png[] -The `softnpu` zone will be configured and launched during the `create_virtual_hardware.sh` script. +The `softnpu` zone will be configured and launched during the +`create_virtual_hardware.sh` script. Once the control plane is running, `softnpu` can be configured via `dendrite` -using the `swadm` binary located in the `oxz_switch` zone. -An example script is provided in `tools/scrimlet/softnpu-init.sh`. -This script should work without modification for basic development setups, -but feel free to tweak it as needed. - ----- -$ ./tools/scrimlet/softnpu-init.sh -++ netstat -rn -f inet -++ grep default -++ awk -F ' ' '{print $2}' -+ GATEWAY_IP=10.85.0.1 -+ echo 'Using 10.85.0.1 as gateway ip' -Using 10.85.0.1 as gateway ip -++ arp 10.85.0.1 -++ awk -F ' ' '{print $4}' -+ GATEWAY_MAC=68:d7:9a:1f:77:a1 -+ echo 'Using 68:d7:9a:1f:77:a1 as gateway mac' -Using 68:d7:9a:1f:77:a1 as gateway mac -+ z_swadm link create rear0 --speed 100G --fec RS -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm link create rear0 --speed 100G --fec RS -+ z_swadm link create qsfp0 --speed 100G --fec RS -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm link create qsfp0 --speed 100G --fec RS -+ z_swadm addr add rear0/0 fe80::aae1:deff:fe01:701c -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm addr add rear0/0 fe80::aae1:deff:fe01:701c -+ z_swadm addr add qsfp0/0 fe80::aae1:deff:fe01:701d -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm addr add qsfp0/0 fe80::aae1:deff:fe01:701d -+ z_swadm addr add rear0/0 fd00:99::1 -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm addr add rear0/0 fd00:99::1 -+ z_swadm route add fd00:1122:3344:0101::/64 rear0/0 fe80::aae1:deff:fe00:1 -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm route add fd00:1122:3344:0101::/64 rear0/0 fe80::aae1:deff:fe00:1 -+ z_swadm arp add fe80::aae1:deff:fe00:1 a8:e1:de:00:00:01 -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm arp add fe80::aae1:deff:fe00:1 a8:e1:de:00:00:01 -+ z_swadm arp add 10.85.0.1 68:d7:9a:1f:77:a1 -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm arp add 10.85.0.1 68:d7:9a:1f:77:a1 -+ z_swadm route add 0.0.0.0/0 qsfp0/0 10.85.0.1 -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm route add 0.0.0.0/0 qsfp0/0 10.85.0.1 -+ z_swadm link ls -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm link ls -Port/Link Media Speed FEC Enabled Link MAC -rear0/0 Copper 100G RS true Up a8:40:25:46:55:e3 -qsfp0/0 Copper 100G RS true Up a8:40:25:46:55:e4 -+ z_swadm addr list -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm addr list -Link IPv4 IPv6 -rear0/0 fe80::aae1:deff:fe01:701c - fd00:99::1 -qsfp0/0 fe80::aae1:deff:fe01:701d -+ z_swadm route list -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm route list -Subnet Port Link Gateway -0.0.0.0/0 qsfp0 0 10.85.0.1 -fd00:1122:3344:101::/64 rear0 0 fe80::aae1:deff:fe00:1 -+ z_swadm arp list -+ pfexec zlogin oxz_switch /opt/oxide/dendrite/bin/swadm arp list -host mac age -10.85.0.1 68:d7:9a:1f:77:a1 0s -fe80::aae1:deff:fe00:1 a8:e1:de:00:00:01 0s ----- - -While following -https://github.com/oxidecomputer/omicron/blob/main/docs/how-to-run.adoc[how-to-run.adoc] -to set up IPs, images, disks, instances etc, pay particular attention to the -following. - -- The address range in the IP pool should be on a subnet in your local network that - can NAT out to the Internet. -- Be sure to set up an external IP for the instance you create. - -You will need to set up `proxy-arp` if your VM external IP addresses are on the -same L2 network as the router or other non-oxide hosts: ----- -pfexec /opt/oxide/softnpu/stuff/scadm \ - --server /opt/oxide/softnpu/stuff/server \ - --client /opt/oxide/softnpu/stuff/client \ - standalone \ - add-proxy-arp \ - $ip_pool_start \ - $ip_pool_end \ - $softnpu_mac ----- - -By the end, we have an instance up and running with external connectivity -configured via boundary services: ----- -ry@korgano:~/omicron$ ~/propolis/target/release/propolis-cli --server fd00:1122:3344:101::c serial - -debian login: root -Linux debian 5.10.0-9-amd64 #1 SMP Debian 5.10.70-1 (2021-09-30) x86_64 - -The programs included with the Debian GNU/Linux system are free software; -the exact distribution terms for each program are described in the -individual files in /usr/share/doc/*/copyright. - -Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent -permitted by applicable law. -root@debian:~# host oxide.computer -oxide.computer has address 76.76.21.61 -oxide.computer has address 76.76.21.22 -oxide.computer mail is handled by 5 alt2.aspmx.l.google.com. -oxide.computer mail is handled by 1 aspmx.l.google.com. -oxide.computer mail is handled by 10 aspmx3.googlemail.com. -oxide.computer mail is handled by 5 alt1.aspmx.l.google.com. -oxide.computer mail is handled by 10 aspmx2.googlemail.com. ----- +using the `swadm` binary located in the `oxz_switch` zone. This is not necessary +under normal operation, as the switch state will be managed automatically by the +control plane and networking daemons. An example script is provided in +`tools/scrimlet/softnpu-init.sh`. This script should work without modification +for basic development setups, but feel free to tweak it as needed. diff --git a/docs/networking.adoc b/docs/networking.adoc index 2ebad97842b..84c95832c0d 100644 --- a/docs/networking.adoc +++ b/docs/networking.adoc @@ -669,13 +669,13 @@ fdb0:a840:2504:352::/64 fe80::aa40:25ff:fe05:c UG 2 640 cxgbe0 fd00:1122:3344:1::/64 fe80::aa40:25ff:fe05:c UG 2 2401 cxgbe0 fd00:1122:3344:1::/64 fe80::aa40:25ff:fe05:40c UG 2 51 cxgbe1 fdb0:a840:2504:352::/64 fe80::aa40:25ff:fe05:40c UG 2 11090 cxgbe1 -fd00:99::/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 +fdb2:ceeb:3ab7:8c9d::1/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 fdb0:a840:2504:1d1::/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 fdb0:a840:2504:393::/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 fdb0:a840:2504:191::/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 fdb0:a840:2504:353::/64 fe80::aa40:25ff:fe05:c UG 1 0 cxgbe0 fd00:1122:3344:101::/64 fe80::aa40:25ff:fe05:c UG 2 634578 cxgbe0 -fd00:99::/64 fe80::aa40:25ff:fe05:40c UG 1 0 cxgbe1 +fd96:354:c1dc:606d::1/64 fe80::aa40:25ff:fe05:40c UG 1 0 cxgbe1 fd00:1122:3344:101::/64 fe80::aa40:25ff:fe05:40c UG 2 14094545 cxgbe1 fdb0:a840:2504:1d1::/64 fe80::aa40:25ff:fe05:40c UG 1 0 cxgbe1 fdb0:a840:2504:353::/64 fe80::aa40:25ff:fe05:40c UG 1 0 cxgbe1 @@ -733,7 +733,11 @@ fd00:1122:3344:3::/64 fe80::aa40:25ff:fe05:c UG 2 2437 cxgbe0 Recall that cxgbe0 and cxgbe1 are connected to separate switches in the rack. So we're seeing the prefixes for the other sleds in this deployment. We have two routes to reach each sled: one through each switch. The gateway is the link-local address _of each switch_ on the corresponding link. One notable exception: the route for this same sled (`fd00:1122:3344:104::/64`) points to `underlay0`, the GZ's VNIC on the sled's underlay network. In this way, traffic leaving the GZ (whether it originated in this GZ or arrived from one of the switches) is directed to the sled's underlay network etherstub and from there to the right zone VNIC. -(Questions: Why does 107 only have one route? What are the `fd00:99::` routes?) +(Questions: Why does 107 only have one route?) + +The `fdb2:ceeb:3ab7:8c9d::1/64` and `fd96:354:c1dc:606d::1/64` routes are +randomly generated boundary services tunnel endpoint addresses. See RFD 404 for +more details. There are similar routes for other sleds' prefixes on the bootstrap network. diff --git a/end-to-end-tests/src/instance_launch.rs b/end-to-end-tests/src/instance_launch.rs index 7ac5bfcae7b..019bd73b049 100644 --- a/end-to-end-tests/src/instance_launch.rs +++ b/end-to-end-tests/src/instance_launch.rs @@ -5,9 +5,9 @@ use anyhow::{ensure, Context as _, Result}; use async_trait::async_trait; use omicron_test_utils::dev::poll::{wait_for_condition, CondCheckError}; use oxide_client::types::{ - ByteCount, DiskCreate, DiskSource, ExternalIpCreate, InstanceCpuCount, - InstanceCreate, InstanceDiskAttachment, InstanceNetworkInterfaceAttachment, - SshKeyCreate, + ByteCount, DiskCreate, DiskSource, ExternalIp, ExternalIpCreate, + InstanceCpuCount, InstanceCreate, InstanceDiskAttachment, + InstanceNetworkInterfaceAttachment, SshKeyCreate, }; use oxide_client::{ClientDisksExt, ClientInstancesExt, ClientSessionExt}; use russh::{ChannelMsg, Disconnect}; @@ -71,7 +71,7 @@ async fn instance_launch() -> Result<()> { name: disk_name.clone(), }], network_interfaces: InstanceNetworkInterfaceAttachment::Default, - external_ips: vec![ExternalIpCreate::Ephemeral { pool_name: None }], + external_ips: vec![ExternalIpCreate::Ephemeral { pool: None }], user_data: String::new(), ssh_keys: Some(vec![oxide_client::types::NameOrId::Name( ssh_key_name.clone(), @@ -91,7 +91,10 @@ async fn instance_launch() -> Result<()> { .items .first() .context("no external IPs")? - .ip; + .clone(); + let ExternalIp::Ephemeral { ip: ip_addr } = ip_addr else { + anyhow::bail!("IP bound to instance was not ephemeral as required.") + }; eprintln!("instance external IP: {}", ip_addr); // poll serial for login prompt, waiting 5 min max diff --git a/illumos-utils/src/opte/illumos.rs b/illumos-utils/src/opte/illumos.rs index 88e8d343b14..527172b9760 100644 --- a/illumos-utils/src/opte/illumos.rs +++ b/illumos-utils/src/opte/illumos.rs @@ -11,6 +11,7 @@ use omicron_common::api::internal::shared::NetworkInterfaceKind; use opte_ioctl::OpteHdl; use slog::info; use slog::Logger; +use std::net::IpAddr; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -46,6 +47,15 @@ pub enum Error { #[error("Tried to release non-existent port ({0}, {1:?})")] ReleaseMissingPort(uuid::Uuid, NetworkInterfaceKind), + + #[error("Tried to update external IPs on non-existent port ({0}, {1:?})")] + ExternalIpUpdateMissingPort(uuid::Uuid, NetworkInterfaceKind), + + #[error("Could not find Primary NIC")] + NoPrimaryNic, + + #[error("Can't attach new ephemeral IP {0}, currently have {1}")] + ImplicitEphemeralIpDetach(IpAddr, IpAddr), } /// Delete all xde devices on the system. diff --git a/illumos-utils/src/opte/mod.rs b/illumos-utils/src/opte/mod.rs index 710e7831811..d06b6b26e53 100644 --- a/illumos-utils/src/opte/mod.rs +++ b/illumos-utils/src/opte/mod.rs @@ -29,26 +29,6 @@ pub use oxide_vpc::api::DhcpCfg; pub use oxide_vpc::api::Vni; use std::net::IpAddr; -fn default_boundary_services() -> BoundaryServices { - use oxide_vpc::api::Ipv6Addr; - use oxide_vpc::api::MacAddr; - // TODO-completeness: Don't hardcode any of these values. - // - // Boundary Services will be started on several Sidecars during rack - // setup, and those addresses and VNIs will need to be propagated here. - // See https://github.com/oxidecomputer/omicron/issues/1382 - let ip = Ipv6Addr::from([0xfd00, 0x99, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); - - // This MAC address is entirely irrelevant to the functionality of OPTE and - // the Oxide VPC. It's never used to actually forward packets. It only - // represents the "logical" destination of Boundary Services as a - // destination that OPTE as a virtual gateway forwards packets to as its - // next hop. - let mac = MacAddr::from_const([0xa8, 0x40, 0x25, 0xf9, 0x99, 0x99]); - let vni = Vni::new(99_u32).unwrap(); - BoundaryServices { ip, mac, vni } -} - /// Information about the gateway for an OPTE port #[derive(Debug, Clone, Copy)] #[allow(dead_code)] diff --git a/illumos-utils/src/opte/non_illumos.rs b/illumos-utils/src/opte/non_illumos.rs index ccd4990d5f7..bf61249fb17 100644 --- a/illumos-utils/src/opte/non_illumos.rs +++ b/illumos-utils/src/opte/non_illumos.rs @@ -8,6 +8,7 @@ use slog::Logger; use crate::addrobj::AddrObject; use omicron_common::api::internal::shared::NetworkInterfaceKind; +use std::net::IpAddr; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -16,6 +17,15 @@ pub enum Error { #[error("Tried to release non-existent port ({0}, {1:?})")] ReleaseMissingPort(uuid::Uuid, NetworkInterfaceKind), + + #[error("Tried to update external IPs on non-existent port ({0}, {1:?})")] + ExternalIpUpdateMissingPort(uuid::Uuid, NetworkInterfaceKind), + + #[error("Could not find Primary NIC")] + NoPrimaryNic, + + #[error("Can't attach new ephemeral IP {0}, currently have {1}")] + ImplicitEphemeralIpDetach(IpAddr, IpAddr), } pub fn initialize_xde_driver( diff --git a/illumos-utils/src/opte/port_manager.rs b/illumos-utils/src/opte/port_manager.rs index 3558ef1c781..2b2f6220704 100644 --- a/illumos-utils/src/opte/port_manager.rs +++ b/illumos-utils/src/opte/port_manager.rs @@ -4,7 +4,6 @@ //! Manager for all OPTE ports on a Helios system -use crate::opte::default_boundary_services; use crate::opte::opte_firewall_rules; use crate::opte::params::DeleteVirtualNetworkInterfaceHost; use crate::opte::params::SetVirtualNetworkInterfaceHost; @@ -29,6 +28,7 @@ use oxide_vpc::api::MacAddr; use oxide_vpc::api::RouterTarget; use oxide_vpc::api::SNat4Cfg; use oxide_vpc::api::SNat6Cfg; +use oxide_vpc::api::SetExternalIpsReq; use oxide_vpc::api::VpcCfg; use slog::debug; use slog::error; @@ -110,7 +110,6 @@ impl PortManager { let subnet = IpNetwork::from(nic.subnet); let vpc_subnet = IpCidr::from(subnet); let gateway = Gateway::from_subnet(&subnet); - let boundary_services = default_boundary_services(); // Describe the external IP addresses for this port. macro_rules! ip_cfg { @@ -219,7 +218,6 @@ impl PortManager { gateway_mac: MacAddr::from(gateway.mac.into_array()), vni, phys_ip: self.inner.underlay_ip.into(), - boundary_services, }; // Create the xde device. @@ -401,6 +399,121 @@ impl PortManager { Ok((port, ticket)) } + /// Ensure external IPs for an OPTE port are up to date. + #[cfg_attr(not(target_os = "illumos"), allow(unused_variables))] + pub fn external_ips_ensure( + &self, + nic_id: Uuid, + nic_kind: NetworkInterfaceKind, + source_nat: Option, + ephemeral_ip: Option, + floating_ips: &[IpAddr], + ) -> Result<(), Error> { + let ports = self.inner.ports.lock().unwrap(); + let port = ports.get(&(nic_id, nic_kind)).ok_or_else(|| { + Error::ExternalIpUpdateMissingPort(nic_id, nic_kind) + })?; + + // XXX: duplicates parts of macro logic in `create_port`. + macro_rules! ext_ip_cfg { + ($ip:expr, $log_prefix:literal, $ip_t:path, $cidr_t:path, + $ipcfg_e:path, $ipcfg_t:ident, $snat_t:ident) => {{ + let snat = match source_nat { + Some(snat) => { + let $ip_t(snat_ip) = snat.ip else { + error!( + self.inner.log, + concat!($log_prefix, " SNAT config"); + "snat_ip" => ?snat.ip, + ); + return Err(Error::InvalidPortIpConfig); + }; + let ports = snat.first_port..=snat.last_port; + Some($snat_t { external_ip: snat_ip.into(), ports }) + } + None => None, + }; + let ephemeral_ip = match ephemeral_ip { + Some($ip_t(ip)) => Some(ip.into()), + Some(_) => { + error!( + self.inner.log, + 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::, _>>()?; + + ExternalIpCfg { + ephemeral_ip, + snat, + floating_ips, + } + }} + } + + // TODO-completeness: support dual-stack. We'll need to explicitly store + // a v4 and a v6 ephemeral IP + SNat + gateway + ... in `InstanceInner` + // to have enough info to build both. + let mut v4_cfg = None; + let mut v6_cfg = None; + match port.gateway().ip { + IpAddr::V4(_) => { + v4_cfg = Some(ext_ip_cfg!( + ip, + "Expected IPv4", + IpAddr::V4, + IpCidr::Ip4, + IpCfg::Ipv4, + Ipv4Cfg, + SNat4Cfg + )) + } + IpAddr::V6(_) => { + v6_cfg = Some(ext_ip_cfg!( + ip, + "Expected IPv6", + IpAddr::V6, + IpCidr::Ip6, + IpCfg::Ipv6, + Ipv6Cfg, + SNat6Cfg + )) + } + } + + let req = SetExternalIpsReq { + port_name: port.name().into(), + external_ips_v4: v4_cfg, + external_ips_v6: v6_cfg, + }; + + #[cfg(target_os = "illumos")] + let hdl = opte_ioctl::OpteHdl::open(opte_ioctl::OpteHdl::XDE_CTL)?; + + #[cfg(target_os = "illumos")] + hdl.set_external_ips(&req)?; + + Ok(()) + } + #[cfg(target_os = "illumos")] pub fn firewall_rules_ensure( &self, diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 52ee7034dd0..87703cce771 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -13,8 +13,10 @@ assert_matches.workspace = true async-trait.workspace = true base64.workspace = true buf-list.workspace = true +bytes.workspace = true cancel-safe-futures.workspace = true camino.workspace = true +camino-tempfile.workspace = true clap.workspace = true chrono.workspace = true crucible-agent-client.workspace = true @@ -88,6 +90,7 @@ oximeter-instruments = { workspace = true, features = ["http-instruments"] } oximeter-producer.workspace = true rustls = { workspace = true } rustls-pemfile = { workspace = true } +update-common.workspace = true omicron-workspace-hack.workspace = true [dev-dependencies] @@ -120,6 +123,8 @@ rustls = { workspace = true } subprocess.workspace = true term.workspace = true trust-dns-resolver.workspace = true +tufaceous.workspace = true +tufaceous-lib.workspace = true httptest.workspace = true strum.workspace = true diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index e95185658fe..1e9def4182f 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -23,6 +23,7 @@ use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadata; use serde::Deserialize; use serde::Serialize; +use sled_agent_client::types::InstanceExternalIpBody; use std::convert::TryFrom; use std::net::IpAddr; use uuid::Uuid; @@ -32,7 +33,7 @@ impl_enum_type!( #[diesel(postgres_type(name = "ip_kind", schema = "public"))] pub struct IpKindEnum; - #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow, PartialEq)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow, PartialEq, Deserialize, Serialize)] #[diesel(sql_type = IpKindEnum)] pub enum IpKind; @@ -41,6 +42,42 @@ impl_enum_type!( Floating => b"floating" ); +impl_enum_type!( + #[derive(SqlType, Debug, Clone, Copy, QueryId)] + #[diesel(postgres_type(name = "ip_attach_state"))] + pub struct IpAttachStateEnum; + + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow, PartialEq, Deserialize, Serialize)] + #[diesel(sql_type = IpAttachStateEnum)] + pub enum IpAttachState; + + Detached => b"detached" + Attached => b"attached" + Detaching => b"detaching" + Attaching => b"attaching" +); + +impl std::fmt::Display for IpAttachState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + IpAttachState::Detached => "Detached", + IpAttachState::Attached => "Attached", + IpAttachState::Detaching => "Detaching", + IpAttachState::Attaching => "Attaching", + }) + } +} + +impl std::fmt::Display for IpKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + IpKind::Floating => "floating", + IpKind::Ephemeral => "ephemeral", + IpKind::SNat => "SNAT", + }) + } +} + /// The main model type for external IP addresses for instances /// and externally-facing services. /// @@ -51,7 +88,9 @@ impl_enum_type!( /// addresses and port ranges, while source NAT IPs are not discoverable in the /// API at all, and only provide outbound connectivity to instances, not /// inbound. -#[derive(Debug, Clone, Selectable, Queryable, Insertable)] +#[derive( + Debug, Clone, Selectable, Queryable, Insertable, Deserialize, Serialize, +)] #[diesel(table_name = external_ip)] pub struct ExternalIp { pub id: Uuid, @@ -76,6 +115,7 @@ pub struct ExternalIp { pub last_port: SqlU16, // Only Some(_) for instance Floating IPs pub project_id: Option, + pub state: IpAttachState, } /// A view type constructed from `ExternalIp` used to represent Floating IP @@ -125,6 +165,7 @@ pub struct IncompleteExternalIp { parent_id: Option, pool_id: Uuid, project_id: Option, + state: IpAttachState, // Optional address requesting that a specific IP address be allocated. explicit_ip: Option, // Optional range when requesting a specific SNAT range be allocated. @@ -137,34 +178,38 @@ impl IncompleteExternalIp { instance_id: Uuid, pool_id: Uuid, ) -> Self { + let kind = IpKind::SNat; Self { id, name: None, description: None, time_created: Utc::now(), - kind: IpKind::SNat, + kind, is_service: false, parent_id: Some(instance_id), pool_id, project_id: None, explicit_ip: None, explicit_port_range: None, + state: kind.initial_state(), } } - pub fn for_ephemeral(id: Uuid, instance_id: Uuid, pool_id: Uuid) -> Self { + pub fn for_ephemeral(id: Uuid, pool_id: Uuid) -> Self { + let kind = IpKind::Ephemeral; Self { id, name: None, description: None, time_created: Utc::now(), - kind: IpKind::Ephemeral, + kind, is_service: false, - parent_id: Some(instance_id), + parent_id: None, pool_id, project_id: None, explicit_ip: None, explicit_port_range: None, + state: kind.initial_state(), } } @@ -175,18 +220,20 @@ impl IncompleteExternalIp { project_id: Uuid, pool_id: Uuid, ) -> Self { + let kind = IpKind::Floating; Self { id, name: Some(name.clone()), description: Some(description.to_string()), time_created: Utc::now(), - kind: IpKind::Floating, + kind, is_service: false, parent_id: None, pool_id, project_id: Some(project_id), explicit_ip: None, explicit_port_range: None, + state: kind.initial_state(), } } @@ -198,18 +245,20 @@ impl IncompleteExternalIp { explicit_ip: IpAddr, pool_id: Uuid, ) -> Self { + let kind = IpKind::Floating; Self { id, name: Some(name.clone()), description: Some(description.to_string()), time_created: Utc::now(), - kind: IpKind::Floating, + kind, is_service: false, parent_id: None, pool_id, project_id: Some(project_id), explicit_ip: Some(explicit_ip.into()), explicit_port_range: None, + state: kind.initial_state(), } } @@ -233,6 +282,7 @@ impl IncompleteExternalIp { project_id: None, explicit_ip: Some(IpNetwork::from(address)), explicit_port_range: None, + state: IpAttachState::Attached, } } @@ -250,18 +300,20 @@ impl IncompleteExternalIp { NUM_SOURCE_NAT_PORTS, ); let explicit_port_range = Some((first_port.into(), last_port.into())); + let kind = IpKind::SNat; Self { id, name: None, description: None, time_created: Utc::now(), - kind: IpKind::SNat, + kind, is_service: true, parent_id: Some(service_id), pool_id, project_id: None, explicit_ip: Some(IpNetwork::from(address)), explicit_port_range, + state: kind.initial_state(), } } @@ -272,34 +324,38 @@ impl IncompleteExternalIp { service_id: Uuid, pool_id: Uuid, ) -> Self { + let kind = IpKind::Floating; Self { id, name: Some(name.clone()), description: Some(description.to_string()), time_created: Utc::now(), - kind: IpKind::Floating, + kind, is_service: true, parent_id: Some(service_id), pool_id, project_id: None, explicit_ip: None, explicit_port_range: None, + state: IpAttachState::Attached, } } pub fn for_service_snat(id: Uuid, service_id: Uuid, pool_id: Uuid) -> Self { + let kind = IpKind::SNat; Self { id, name: None, description: None, time_created: Utc::now(), - kind: IpKind::SNat, + kind, is_service: true, parent_id: Some(service_id), pool_id, project_id: None, explicit_ip: None, explicit_port_range: None, + state: kind.initial_state(), } } @@ -339,6 +395,10 @@ impl IncompleteExternalIp { &self.project_id } + pub fn state(&self) -> &IpAttachState { + &self.state + } + pub fn explicit_ip(&self) -> &Option { &self.explicit_ip } @@ -348,6 +408,18 @@ impl IncompleteExternalIp { } } +impl IpKind { + /// The initial state which a new non-service IP should + /// be allocated in. + pub fn initial_state(&self) -> IpAttachState { + match &self { + IpKind::SNat => IpAttachState::Attached, + IpKind::Ephemeral => IpAttachState::Detached, + IpKind::Floating => IpAttachState::Detached, + } + } +} + impl TryFrom for shared::IpKind { type Error = Error; @@ -371,8 +443,15 @@ impl TryFrom for views::ExternalIp { "Service IPs should not be exposed in the API", )); } - let kind = ip.kind.try_into()?; - Ok(views::ExternalIp { kind, ip: ip.ip.ip() }) + match ip.kind { + IpKind::Floating => Ok(views::ExternalIp::Floating(ip.try_into()?)), + IpKind::Ephemeral => { + Ok(views::ExternalIp::Ephemeral { ip: ip.ip.ip() }) + } + IpKind::SNat => Err(Error::internal_error( + "SNAT IP addresses should not be exposed in the API", + )), + } } } @@ -450,3 +529,18 @@ impl From for views::FloatingIp { } } } + +impl TryFrom for InstanceExternalIpBody { + type Error = Error; + + fn try_from(value: ExternalIp) -> Result { + let ip = value.ip.ip(); + match value.kind { + IpKind::Ephemeral => Ok(InstanceExternalIpBody::Ephemeral(ip)), + IpKind::Floating => Ok(InstanceExternalIpBody::Floating(ip)), + IpKind::SNat => Err(Error::invalid_request( + "cannot dynamically add/remove SNAT allocation", + )), + } + } +} diff --git a/nexus/db-model/src/instance.rs b/nexus/db-model/src/instance.rs index 9252926547e..e10f8c26037 100644 --- a/nexus/db-model/src/instance.rs +++ b/nexus/db-model/src/instance.rs @@ -2,9 +2,11 @@ // 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/. -use super::{ByteCount, Disk, Generation, InstanceCpuCount, InstanceState}; +use super::{ + ByteCount, Disk, ExternalIp, Generation, InstanceCpuCount, InstanceState, +}; use crate::collection::DatastoreAttachTargetConfig; -use crate::schema::{disk, instance}; +use crate::schema::{disk, external_ip, instance}; use chrono::{DateTime, Utc}; use db_macros::Resource; use nexus_types::external_api::params; @@ -101,6 +103,17 @@ impl DatastoreAttachTargetConfig for Instance { type ResourceTimeDeletedColumn = disk::dsl::time_deleted; } +impl DatastoreAttachTargetConfig for Instance { + type Id = Uuid; + + type CollectionIdColumn = instance::dsl::id; + type CollectionTimeDeletedColumn = instance::dsl::time_deleted; + + type ResourceIdColumn = external_ip::dsl::id; + type ResourceCollectionIdColumn = external_ip::dsl::parent_id; + type ResourceTimeDeletedColumn = external_ip::dsl::time_deleted; +} + /// Runtime state of the Instance, including the actual running state and minimal /// metadata /// diff --git a/nexus/db-model/src/instance_state.rs b/nexus/db-model/src/instance_state.rs index 7b98850b43c..dca809758fc 100644 --- a/nexus/db-model/src/instance_state.rs +++ b/nexus/db-model/src/instance_state.rs @@ -65,3 +65,9 @@ impl From for sled_agent_client::types::InstanceState { } } } + +impl From for InstanceState { + fn from(state: external::InstanceState) -> Self { + Self::new(state) + } +} diff --git a/nexus/db-model/src/ipv4_nat_entry.rs b/nexus/db-model/src/ipv4_nat_entry.rs index 570a46b5e97..b0fa2b8eb91 100644 --- a/nexus/db-model/src/ipv4_nat_entry.rs +++ b/nexus/db-model/src/ipv4_nat_entry.rs @@ -5,6 +5,7 @@ use crate::{schema::ipv4_nat_entry, Ipv4Net, Ipv6Net, SqlU16, Vni}; use chrono::{DateTime, Utc}; use omicron_common::api::external; use schemars::JsonSchema; +use serde::Deserialize; use serde::Serialize; use uuid::Uuid; @@ -21,7 +22,7 @@ pub struct Ipv4NatValues { } /// Database representation of an Ipv4 NAT Entry. -#[derive(Queryable, Debug, Clone, Selectable)] +#[derive(Queryable, Debug, Clone, Selectable, Serialize, Deserialize)] #[diesel(table_name = ipv4_nat_entry)] pub struct Ipv4NatEntry { pub id: Uuid, diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index 8fdf05e876e..5c0a68c253b 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -49,7 +49,6 @@ mod project; mod semver_version; mod switch_interface; mod switch_port; -mod system_update; // These actually represent subqueries, not real table. // However, they must be defined in the same crate as our tables // for join-based marker trait generation. @@ -78,8 +77,8 @@ mod sled_underlay_subnet_allocation; mod snapshot; mod ssh_key; mod switch; +mod tuf_repo; mod unsigned; -mod update_artifact; mod user_builtin; mod utilization; mod virtual_provisioning_collection; @@ -165,8 +164,7 @@ pub use ssh_key::*; pub use switch::*; pub use switch_interface::*; pub use switch_port::*; -pub use system_update::*; -pub use update_artifact::*; +pub use tuf_repo::*; pub use user_builtin::*; pub use utilization::*; pub use virtual_provisioning_collection::*; diff --git a/nexus/db-model/src/macaddr.rs b/nexus/db-model/src/macaddr.rs index dceb8acf482..b3329598bd2 100644 --- a/nexus/db-model/src/macaddr.rs +++ b/nexus/db-model/src/macaddr.rs @@ -8,8 +8,19 @@ use diesel::pg::Pg; use diesel::serialize::{self, ToSql}; use diesel::sql_types; use omicron_common::api::external; +use serde::Deserialize; +use serde::Serialize; -#[derive(Clone, Copy, Debug, PartialEq, AsExpression, FromSqlRow)] +#[derive( + Clone, + Copy, + Debug, + PartialEq, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, +)] #[diesel(sql_type = sql_types::BigInt)] pub struct MacAddr(pub external::MacAddr); diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 2692b3a3ece..aa488e4553f 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -13,7 +13,7 @@ use omicron_common::api::external::SemverVersion; /// /// 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(26, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(28, 0, 0); table! { disk (id) { @@ -574,6 +574,7 @@ table! { last_port -> Int4, project_id -> Nullable, + state -> crate::IpAttachStateEnum, } } @@ -1183,72 +1184,45 @@ table! { } table! { - update_artifact (name, version, kind) { - name -> Text, - version -> Text, - kind -> crate::KnownArtifactKindEnum, + tuf_repo (id) { + id -> Uuid, + time_created -> Timestamptz, + sha256 -> Text, targets_role_version -> Int8, valid_until -> Timestamptz, - target_name -> Text, - target_sha256 -> Text, - target_length -> Int8, + system_version -> Text, + file_name -> Text, } } table! { - system_update (id) { - id -> Uuid, - time_created -> Timestamptz, - time_modified -> Timestamptz, - + tuf_artifact (name, version, kind) { + name -> Text, version -> Text, - } -} - -table! { - update_deployment (id) { - id -> Uuid, + kind -> Text, time_created -> Timestamptz, - time_modified -> Timestamptz, - - version -> Text, - status -> crate::UpdateStatusEnum, - // TODO: status reason for updateable_component + sha256 -> Text, + artifact_size -> Int8, } } table! { - component_update (id) { - id -> Uuid, - time_created -> Timestamptz, - time_modified -> Timestamptz, - - version -> Text, - component_type -> crate::UpdateableComponentTypeEnum, - } -} - -table! { - updateable_component (id) { - id -> Uuid, - time_created -> Timestamptz, - time_modified -> Timestamptz, - - device_id -> Text, - version -> Text, - system_version -> Text, - component_type -> crate::UpdateableComponentTypeEnum, - status -> crate::UpdateStatusEnum, - // TODO: status reason for updateable_component + tuf_repo_artifact (tuf_repo_id, tuf_artifact_name, tuf_artifact_version, tuf_artifact_kind) { + tuf_repo_id -> Uuid, + tuf_artifact_name -> Text, + tuf_artifact_version -> Text, + tuf_artifact_kind -> Text, } } -table! { - system_update_component_update (system_update_id, component_update_id) { - system_update_id -> Uuid, - component_update_id -> Uuid, - } -} +allow_tables_to_appear_in_same_query!( + tuf_repo, + tuf_repo_artifact, + tuf_artifact +); +joinable!(tuf_repo_artifact -> tuf_repo (tuf_repo_id)); +// Can't specify joinable for a composite primary key (tuf_repo_artifact -> +// tuf_artifact). /* hardware inventory */ @@ -1438,13 +1412,6 @@ table! { } } -allow_tables_to_appear_in_same_query!( - system_update, - component_update, - system_update_component_update, -); -joinable!(system_update_component_update -> component_update (component_update_id)); - allow_tables_to_appear_in_same_query!(ip_pool_range, ip_pool, ip_pool_resource); joinable!(ip_pool_range -> ip_pool (ip_pool_id)); joinable!(ip_pool_resource -> ip_pool (ip_pool_id)); diff --git a/nexus/db-model/src/semver_version.rs b/nexus/db-model/src/semver_version.rs index 8e168e11a2f..f314e98ab30 100644 --- a/nexus/db-model/src/semver_version.rs +++ b/nexus/db-model/src/semver_version.rs @@ -24,6 +24,8 @@ use serde::{Deserialize, Serialize}; Serialize, Deserialize, PartialEq, + Eq, + Hash, Display, )] #[diesel(sql_type = sql_types::Text)] diff --git a/nexus/db-model/src/system_update.rs b/nexus/db-model/src/system_update.rs deleted file mode 100644 index 17421936b1b..00000000000 --- a/nexus/db-model/src/system_update.rs +++ /dev/null @@ -1,306 +0,0 @@ -// 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/. - -use crate::{ - impl_enum_type, - schema::{ - component_update, system_update, system_update_component_update, - update_deployment, updateable_component, - }, - SemverVersion, -}; -use db_macros::Asset; -use nexus_types::{ - external_api::{params, shared, views}, - identity::Asset, -}; -use omicron_common::api::external; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive( - Queryable, - Insertable, - Selectable, - Clone, - Debug, - Asset, - Serialize, - Deserialize, -)] -#[diesel(table_name = system_update)] -pub struct SystemUpdate { - #[diesel(embed)] - pub identity: SystemUpdateIdentity, - pub version: SemverVersion, -} - -impl SystemUpdate { - /// Can fail if version numbers are too high. - pub fn new( - version: external::SemverVersion, - ) -> Result { - Ok(Self { - identity: SystemUpdateIdentity::new(Uuid::new_v4()), - version: SemverVersion(version), - }) - } -} - -impl From for views::SystemUpdate { - fn from(system_update: SystemUpdate) -> Self { - Self { - identity: system_update.identity(), - version: system_update.version.into(), - } - } -} - -impl_enum_type!( - #[derive(SqlType, Debug, QueryId)] - #[diesel(postgres_type(name = "update_status", schema = "public"))] - pub struct UpdateStatusEnum; - - #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] - #[diesel(sql_type = UpdateStatusEnum)] - pub enum UpdateStatus; - - Updating => b"updating" - Steady => b"steady" -); - -impl From for views::UpdateStatus { - fn from(status: UpdateStatus) -> Self { - match status { - UpdateStatus::Updating => Self::Updating, - UpdateStatus::Steady => Self::Steady, - } - } -} - -impl_enum_type!( - #[derive(SqlType, Debug, QueryId)] - #[diesel(postgres_type(name = "updateable_component_type", schema = "public"))] - pub struct UpdateableComponentTypeEnum; - - #[derive(Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, PartialEq)] - #[diesel(sql_type = UpdateableComponentTypeEnum)] - pub enum UpdateableComponentType; - - BootloaderForRot => b"bootloader_for_rot" - BootloaderForSp => b"bootloader_for_sp" - BootloaderForHostProc => b"bootloader_for_host_proc" - HubrisForPscRot => b"hubris_for_psc_rot" - HubrisForPscSp => b"hubris_for_psc_sp" - HubrisForSidecarRot => b"hubris_for_sidecar_rot" - HubrisForSidecarSp => b"hubris_for_sidecar_sp" - HubrisForGimletRot => b"hubris_for_gimlet_rot" - HubrisForGimletSp => b"hubris_for_gimlet_sp" - HeliosHostPhase1 => b"helios_host_phase_1" - HeliosHostPhase2 => b"helios_host_phase_2" - HostOmicron => b"host_omicron" -); - -impl From for UpdateableComponentType { - fn from(component_type: shared::UpdateableComponentType) -> Self { - match component_type { - shared::UpdateableComponentType::BootloaderForRot => { - UpdateableComponentType::BootloaderForRot - } - shared::UpdateableComponentType::BootloaderForSp => { - UpdateableComponentType::BootloaderForSp - } - shared::UpdateableComponentType::BootloaderForHostProc => { - UpdateableComponentType::BootloaderForHostProc - } - shared::UpdateableComponentType::HubrisForPscRot => { - UpdateableComponentType::HubrisForPscRot - } - shared::UpdateableComponentType::HubrisForPscSp => { - UpdateableComponentType::HubrisForPscSp - } - shared::UpdateableComponentType::HubrisForSidecarRot => { - UpdateableComponentType::HubrisForSidecarRot - } - shared::UpdateableComponentType::HubrisForSidecarSp => { - UpdateableComponentType::HubrisForSidecarSp - } - shared::UpdateableComponentType::HubrisForGimletRot => { - UpdateableComponentType::HubrisForGimletRot - } - shared::UpdateableComponentType::HubrisForGimletSp => { - UpdateableComponentType::HubrisForGimletSp - } - shared::UpdateableComponentType::HeliosHostPhase1 => { - UpdateableComponentType::HeliosHostPhase1 - } - shared::UpdateableComponentType::HeliosHostPhase2 => { - UpdateableComponentType::HeliosHostPhase2 - } - shared::UpdateableComponentType::HostOmicron => { - UpdateableComponentType::HostOmicron - } - } - } -} - -impl Into for UpdateableComponentType { - fn into(self) -> shared::UpdateableComponentType { - match self { - UpdateableComponentType::BootloaderForRot => { - shared::UpdateableComponentType::BootloaderForRot - } - UpdateableComponentType::BootloaderForSp => { - shared::UpdateableComponentType::BootloaderForSp - } - UpdateableComponentType::BootloaderForHostProc => { - shared::UpdateableComponentType::BootloaderForHostProc - } - UpdateableComponentType::HubrisForPscRot => { - shared::UpdateableComponentType::HubrisForPscRot - } - UpdateableComponentType::HubrisForPscSp => { - shared::UpdateableComponentType::HubrisForPscSp - } - UpdateableComponentType::HubrisForSidecarRot => { - shared::UpdateableComponentType::HubrisForSidecarRot - } - UpdateableComponentType::HubrisForSidecarSp => { - shared::UpdateableComponentType::HubrisForSidecarSp - } - UpdateableComponentType::HubrisForGimletRot => { - shared::UpdateableComponentType::HubrisForGimletRot - } - UpdateableComponentType::HubrisForGimletSp => { - shared::UpdateableComponentType::HubrisForGimletSp - } - UpdateableComponentType::HeliosHostPhase1 => { - shared::UpdateableComponentType::HeliosHostPhase1 - } - UpdateableComponentType::HeliosHostPhase2 => { - shared::UpdateableComponentType::HeliosHostPhase2 - } - UpdateableComponentType::HostOmicron => { - shared::UpdateableComponentType::HostOmicron - } - } - } -} - -#[derive( - Queryable, - Insertable, - Selectable, - Clone, - Debug, - Asset, - Serialize, - Deserialize, -)] -#[diesel(table_name = component_update)] -pub struct ComponentUpdate { - #[diesel(embed)] - pub identity: ComponentUpdateIdentity, - pub version: SemverVersion, - pub component_type: UpdateableComponentType, -} - -#[derive( - Queryable, Insertable, Selectable, Clone, Debug, Serialize, Deserialize, -)] -#[diesel(table_name = system_update_component_update)] -pub struct SystemUpdateComponentUpdate { - pub component_update_id: Uuid, - pub system_update_id: Uuid, -} - -impl From for views::ComponentUpdate { - fn from(component_update: ComponentUpdate) -> Self { - Self { - identity: component_update.identity(), - version: component_update.version.into(), - component_type: component_update.component_type.into(), - } - } -} - -#[derive( - Queryable, - Insertable, - Selectable, - Clone, - Debug, - Asset, - Serialize, - Deserialize, -)] -#[diesel(table_name = updateable_component)] -pub struct UpdateableComponent { - #[diesel(embed)] - pub identity: UpdateableComponentIdentity, - pub device_id: String, - pub component_type: UpdateableComponentType, - pub version: SemverVersion, - pub system_version: SemverVersion, - pub status: UpdateStatus, - // TODO: point to the actual update artifact -} - -impl TryFrom for UpdateableComponent { - type Error = external::Error; - - fn try_from( - create: params::UpdateableComponentCreate, - ) -> Result { - Ok(Self { - identity: UpdateableComponentIdentity::new(Uuid::new_v4()), - version: SemverVersion(create.version), - system_version: SemverVersion(create.system_version), - component_type: create.component_type.into(), - device_id: create.device_id, - status: UpdateStatus::Steady, - }) - } -} - -impl From for views::UpdateableComponent { - fn from(component: UpdateableComponent) -> Self { - Self { - identity: component.identity(), - device_id: component.device_id, - component_type: component.component_type.into(), - version: component.version.into(), - system_version: component.system_version.into(), - status: component.status.into(), - } - } -} - -#[derive( - Queryable, - Insertable, - Selectable, - Clone, - Debug, - Asset, - Serialize, - Deserialize, -)] -#[diesel(table_name = update_deployment)] -pub struct UpdateDeployment { - #[diesel(embed)] - pub identity: UpdateDeploymentIdentity, - pub version: SemverVersion, - pub status: UpdateStatus, -} - -impl From for views::UpdateDeployment { - fn from(deployment: UpdateDeployment) -> Self { - Self { - identity: deployment.identity(), - version: deployment.version.into(), - status: deployment.status.into(), - } - } -} diff --git a/nexus/db-model/src/tuf_repo.rs b/nexus/db-model/src/tuf_repo.rs new file mode 100644 index 00000000000..5fa2a0aac74 --- /dev/null +++ b/nexus/db-model/src/tuf_repo.rs @@ -0,0 +1,312 @@ +// 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/. + +use std::str::FromStr; + +use crate::{ + schema::{tuf_artifact, tuf_repo, tuf_repo_artifact}, + SemverVersion, +}; +use chrono::{DateTime, Utc}; +use diesel::{deserialize::FromSql, serialize::ToSql, sql_types::Text}; +use omicron_common::{ + api::external, + update::{ + ArtifactHash as ExternalArtifactHash, ArtifactId as ExternalArtifactId, + ArtifactKind, + }, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use uuid::Uuid; + +/// A description of a TUF update: a repo, along with the artifacts it +/// contains. +/// +/// This is the internal variant of [`external::TufRepoDescription`]. +#[derive(Debug, Clone)] +pub struct TufRepoDescription { + /// The repository. + pub repo: TufRepo, + + /// The artifacts. + pub artifacts: Vec, +} + +impl TufRepoDescription { + /// Creates a new `TufRepoDescription` from an + /// [`external::TufRepoDescription`]. + /// + /// This is not implemented as a `From` impl because we insert new fields + /// as part of the process, which `From` doesn't necessarily communicate + /// and can be surprising. + pub fn from_external(description: external::TufRepoDescription) -> Self { + Self { + repo: TufRepo::from_external(description.repo), + artifacts: description + .artifacts + .into_iter() + .map(TufArtifact::from_external) + .collect(), + } + } + + /// Converts self into [`external::TufRepoDescription`]. + pub fn into_external(self) -> external::TufRepoDescription { + external::TufRepoDescription { + repo: self.repo.into_external(), + artifacts: self + .artifacts + .into_iter() + .map(TufArtifact::into_external) + .collect(), + } + } +} + +/// A record representing an uploaded TUF repository. +/// +/// This is the internal variant of [`external::TufRepoMeta`]. +#[derive( + Queryable, Identifiable, Insertable, Clone, Debug, Selectable, AsChangeset, +)] +#[diesel(table_name = tuf_repo)] +pub struct TufRepo { + pub id: Uuid, + pub time_created: DateTime, + // XXX: We're overloading ArtifactHash here to also mean the hash of the + // repository zip itself. + pub sha256: ArtifactHash, + pub targets_role_version: i64, + pub valid_until: DateTime, + pub system_version: SemverVersion, + pub file_name: String, +} + +impl TufRepo { + /// Creates a new `TufRepo` ready for insertion. + pub fn new( + sha256: ArtifactHash, + targets_role_version: u64, + valid_until: DateTime, + system_version: SemverVersion, + file_name: String, + ) -> Self { + Self { + id: Uuid::new_v4(), + time_created: Utc::now(), + sha256, + targets_role_version: targets_role_version as i64, + valid_until, + system_version, + file_name, + } + } + + /// Creates a new `TufRepo` ready for insertion from an external + /// `TufRepoMeta`. + /// + /// This is not implemented as a `From` impl because we insert new fields + /// as part of the process, which `From` doesn't necessarily communicate + /// and can be surprising. + pub fn from_external(repo: external::TufRepoMeta) -> Self { + Self::new( + repo.hash.into(), + repo.targets_role_version, + repo.valid_until, + repo.system_version.into(), + repo.file_name, + ) + } + + /// Converts self into [`external::TufRepoMeta`]. + pub fn into_external(self) -> external::TufRepoMeta { + external::TufRepoMeta { + hash: self.sha256.into(), + targets_role_version: self.targets_role_version as u64, + valid_until: self.valid_until, + system_version: self.system_version.into(), + file_name: self.file_name, + } + } + + /// Returns the repository's ID. + pub fn id(&self) -> Uuid { + self.id + } + + /// Returns the targets role version. + pub fn targets_role_version(&self) -> u64 { + self.targets_role_version as u64 + } +} + +#[derive(Queryable, Insertable, Clone, Debug, Selectable, AsChangeset)] +#[diesel(table_name = tuf_artifact)] +pub struct TufArtifact { + #[diesel(embed)] + pub id: ArtifactId, + pub time_created: DateTime, + pub sha256: ArtifactHash, + artifact_size: i64, +} + +impl TufArtifact { + /// Creates a new `TufArtifact` ready for insertion. + pub fn new( + id: ArtifactId, + sha256: ArtifactHash, + artifact_size: u64, + ) -> Self { + Self { + id, + time_created: Utc::now(), + sha256, + artifact_size: artifact_size as i64, + } + } + + /// Creates a new `TufArtifact` ready for insertion from an external + /// `TufArtifactMeta`. + /// + /// This is not implemented as a `From` impl because we insert new fields + /// as part of the process, which `From` doesn't necessarily communicate + /// and can be surprising. + pub fn from_external(artifact: external::TufArtifactMeta) -> Self { + Self::new(artifact.id.into(), artifact.hash.into(), artifact.size) + } + + /// Converts self into [`external::TufArtifactMeta`]. + pub fn into_external(self) -> external::TufArtifactMeta { + external::TufArtifactMeta { + id: self.id.into(), + hash: self.sha256.into(), + size: self.artifact_size as u64, + } + } + + /// Returns the artifact's ID. + pub fn id(&self) -> (String, SemverVersion, String) { + (self.id.name.clone(), self.id.version.clone(), self.id.kind.clone()) + } + + /// Returns the artifact length in bytes. + pub fn artifact_size(&self) -> u64 { + self.artifact_size as u64 + } +} + +/// The ID (primary key) of a [`TufArtifact`]. +/// +/// This is the internal variant of a [`ExternalArtifactId`]. +#[derive( + Queryable, + Insertable, + Clone, + Debug, + Selectable, + PartialEq, + Eq, + Hash, + Deserialize, + Serialize, +)] +#[diesel(table_name = tuf_artifact)] +pub struct ArtifactId { + pub name: String, + pub version: SemverVersion, + pub kind: String, +} + +impl From for ArtifactId { + fn from(id: ExternalArtifactId) -> Self { + Self { + name: id.name, + version: id.version.into(), + kind: id.kind.as_str().to_owned(), + } + } +} + +impl From for ExternalArtifactId { + fn from(id: ArtifactId) -> Self { + Self { + name: id.name, + version: id.version.into(), + kind: ArtifactKind::new(id.kind), + } + } +} + +impl fmt::Display for ArtifactId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // This is the same as ExternalArtifactId's Display impl. + write!(f, "{} v{} ({})", self.name, self.version, self.kind) + } +} + +/// Required by the authz_resource macro. +impl From for (String, SemverVersion, String) { + fn from(id: ArtifactId) -> Self { + (id.name, id.version, id.kind) + } +} + +/// A many-to-many relationship between [`TufRepo`] and [`TufArtifact`]. +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = tuf_repo_artifact)] +pub struct TufRepoArtifact { + pub tuf_repo_id: Uuid, + pub tuf_artifact_name: String, + pub tuf_artifact_version: SemverVersion, + pub tuf_artifact_kind: String, +} + +/// A wrapper around omicron-common's [`ArtifactHash`](ExternalArtifactHash), +/// supported by Diesel. +#[derive( + Copy, + Clone, + Debug, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + PartialEq, +)] +#[diesel(sql_type = Text)] +#[serde(transparent)] +pub struct ArtifactHash(pub ExternalArtifactHash); + +NewtypeFrom! { () pub struct ArtifactHash(ExternalArtifactHash); } +NewtypeDeref! { () pub struct ArtifactHash(ExternalArtifactHash); } + +impl fmt::Display for ArtifactHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ToSql for ArtifactHash { + fn to_sql<'a>( + &'a self, + out: &mut diesel::serialize::Output<'a, '_, diesel::pg::Pg>, + ) -> diesel::serialize::Result { + >::to_sql( + &self.0.to_string(), + &mut out.reborrow(), + ) + } +} + +impl FromSql for ArtifactHash { + fn from_sql( + bytes: diesel::pg::PgValue<'_>, + ) -> diesel::deserialize::Result { + let s = String::from_sql(bytes)?; + ExternalArtifactHash::from_str(&s) + .map(ArtifactHash) + .map_err(|e| e.into()) + } +} diff --git a/nexus/db-model/src/update_artifact.rs b/nexus/db-model/src/update_artifact.rs deleted file mode 100644 index 97c57b44cc0..00000000000 --- a/nexus/db-model/src/update_artifact.rs +++ /dev/null @@ -1,62 +0,0 @@ -// 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/. - -use super::impl_enum_wrapper; -use crate::schema::update_artifact; -use crate::SemverVersion; -use chrono::{DateTime, Utc}; -use omicron_common::api::internal; -use parse_display::Display; -use serde::Deserialize; -use serde::Serialize; -use std::io::Write; - -impl_enum_wrapper!( - #[derive(SqlType, Debug, QueryId)] - #[diesel(postgres_type(name = "update_artifact_kind", schema = "public"))] - pub struct KnownArtifactKindEnum; - - #[derive(Clone, Copy, Debug, Display, AsExpression, FromSqlRow, PartialEq, Eq, Serialize, Deserialize)] - #[display("{0}")] - #[diesel(sql_type = KnownArtifactKindEnum)] - pub struct KnownArtifactKind(pub internal::nexus::KnownArtifactKind); - - // Enum values - GimletSp => b"gimlet_sp" - GimletRot => b"gimlet_rot" - Host => b"host" - Trampoline => b"trampoline" - ControlPlane => b"control_plane" - PscSp => b"psc_sp" - PscRot => b"psc_rot" - SwitchSp => b"switch_sp" - SwitchRot => b"switch_rot" -); - -#[derive( - Queryable, Insertable, Clone, Debug, Display, Selectable, AsChangeset, -)] -#[diesel(table_name = update_artifact)] -#[display("{kind} \"{name}\" v{version}")] -pub struct UpdateArtifact { - pub name: String, - /// Version of the artifact itself - pub version: SemverVersion, - pub kind: KnownArtifactKind, - /// `version` field of targets.json from the repository - // FIXME this *should* be a NonZeroU64 - pub targets_role_version: i64, - pub valid_until: DateTime, - pub target_name: String, - // FIXME should this be [u8; 32]? - pub target_sha256: String, - // FIXME this *should* be a u64 - pub target_length: i64, -} - -impl UpdateArtifact { - pub fn id(&self) -> (String, SemverVersion, KnownArtifactKind) { - (self.name.clone(), self.version.clone(), self.kind) - } -} diff --git a/nexus/db-queries/Cargo.toml b/nexus/db-queries/Cargo.toml index cae42a0944d..3240c54f3fe 100644 --- a/nexus/db-queries/Cargo.toml +++ b/nexus/db-queries/Cargo.toml @@ -43,6 +43,7 @@ sled-agent-client.workspace = true slog.workspace = true static_assertions.workspace = true steno.workspace = true +swrite.workspace = true thiserror.workspace = true tokio = { workspace = true, features = [ "full" ] } uuid.workspace = true diff --git a/nexus/db-queries/src/authz/api_resources.rs b/nexus/db-queries/src/authz/api_resources.rs index 444a00d5ad9..b4fd4e18908 100644 --- a/nexus/db-queries/src/authz/api_resources.rs +++ b/nexus/db-queries/src/authz/api_resources.rs @@ -36,8 +36,7 @@ use crate::authn; use crate::context::OpContext; use crate::db; use crate::db::fixed_data::FLEET_ID; -use crate::db::model::KnownArtifactKind; -use crate::db::model::SemverVersion; +use crate::db::model::{ArtifactId, SemverVersion}; use crate::db::DataStore; use authz_macros::authz_resource; use futures::future::BoxFuture; @@ -1067,35 +1066,28 @@ authz_resource! { } authz_resource! { - name = "UpdateArtifact", + name = "TufRepo", parent = "Fleet", - primary_key = (String, SemverVersion, KnownArtifactKind), - roles_allowed = false, - polar_snippet = FleetChild, -} - -authz_resource! { - name = "Certificate", - parent = "Silo", primary_key = Uuid, roles_allowed = false, - polar_snippet = Custom, + polar_snippet = FleetChild, } authz_resource! { - name = "SystemUpdate", + name = "TufArtifact", parent = "Fleet", - primary_key = Uuid, + primary_key = (String, SemverVersion, String), + input_key = ArtifactId, roles_allowed = false, polar_snippet = FleetChild, } authz_resource! { - name = "UpdateDeployment", - parent = "Fleet", + name = "Certificate", + parent = "Silo", primary_key = Uuid, roles_allowed = false, - polar_snippet = FleetChild, + polar_snippet = Custom, } authz_resource! { diff --git a/nexus/db-queries/src/authz/oso_generic.rs b/nexus/db-queries/src/authz/oso_generic.rs index 9b842216b40..dd646a1c986 100644 --- a/nexus/db-queries/src/authz/oso_generic.rs +++ b/nexus/db-queries/src/authz/oso_generic.rs @@ -154,12 +154,11 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { IdentityProvider::init(), SamlIdentityProvider::init(), Sled::init(), + TufRepo::init(), + TufArtifact::init(), Zpool::init(), Service::init(), - UpdateArtifact::init(), UserBuiltin::init(), - SystemUpdate::init(), - UpdateDeployment::init(), ]; for init in generated_inits { diff --git a/nexus/db-queries/src/authz/policy_test/resources.rs b/nexus/db-queries/src/authz/policy_test/resources.rs index 9cc4e287901..3e87f6db518 100644 --- a/nexus/db-queries/src/authz/policy_test/resources.rs +++ b/nexus/db-queries/src/authz/policy_test/resources.rs @@ -7,6 +7,8 @@ use super::resource_builder::ResourceBuilder; use super::resource_builder::ResourceSet; use crate::authz; +use crate::db::model::ArtifactId; +use nexus_db_model::SemverVersion; use omicron_common::api::external::LookupType; use oso::PolarClass; use std::collections::BTreeSet; @@ -126,20 +128,23 @@ pub async fn make_resources( LookupType::ById(blueprint_id), )); - let system_update_id = - "9c86d713-1bc2-4927-9892-ada3eb6f5f62".parse().unwrap(); - builder.new_resource(authz::SystemUpdate::new( + let tuf_repo_id = "3c52d72f-cbf7-4951-a62f-a4154e74da87".parse().unwrap(); + builder.new_resource(authz::TufRepo::new( authz::FLEET, - system_update_id, - LookupType::ById(system_update_id), + tuf_repo_id, + LookupType::ById(tuf_repo_id), )); - let update_deployment_id = - "c617a035-7c42-49ff-a36a-5dfeee382832".parse().unwrap(); - builder.new_resource(authz::UpdateDeployment::new( + let artifact_id = ArtifactId { + name: "a".to_owned(), + version: SemverVersion("1.0.0".parse().unwrap()), + kind: "b".to_owned(), + }; + let artifact_id_desc = artifact_id.to_string(); + builder.new_resource(authz::TufArtifact::new( authz::FLEET, - update_deployment_id, - LookupType::ById(update_deployment_id), + artifact_id, + LookupType::ByCompositeId(artifact_id_desc), )); let address_lot_id = @@ -375,7 +380,6 @@ pub fn exempted_authz_classes() -> BTreeSet { authz::RouterRoute::get_polar_class(), authz::ConsoleSession::get_polar_class(), authz::RoleBuiltin::get_polar_class(), - authz::UpdateArtifact::get_polar_class(), authz::UserBuiltin::get_polar_class(), ] .into_iter() diff --git a/nexus/db-queries/src/db/datastore/disk.rs b/nexus/db-queries/src/db/datastore/disk.rs index 2055287e625..390376e6279 100644 --- a/nexus/db-queries/src/db/datastore/disk.rs +++ b/nexus/db-queries/src/db/datastore/disk.rs @@ -206,7 +206,7 @@ impl DataStore { let (instance, disk) = query.attach_and_get_result_async(&*self.pool_connection_authorized(opctx).await?) .await - .or_else(|e| { + .or_else(|e: AttachError| { match e { AttachError::CollectionNotFound => { Err(Error::not_found_by_id( @@ -348,7 +348,7 @@ impl DataStore { ) .detach_and_get_result_async(&*self.pool_connection_authorized(opctx).await?) .await - .or_else(|e| { + .or_else(|e: DetachError| { match e { DetachError::CollectionNotFound => { Err(Error::not_found_by_id( diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 02ce9501188..9d4d9474766 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -9,6 +9,10 @@ use crate::authz; use crate::authz::ApiResource; use crate::context::OpContext; use crate::db; +use crate::db::collection_attach::AttachError; +use crate::db::collection_attach::DatastoreAttachTarget; +use crate::db::collection_detach::DatastoreDetachTarget; +use crate::db::collection_detach::DetachError; use crate::db::error::public_error_from_diesel; use crate::db::error::retryable; use crate::db::error::ErrorHandler; @@ -22,11 +26,17 @@ 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::queries::external_ip::MAX_EXTERNAL_IPS_PER_INSTANCE; +use crate::db::queries::external_ip::SAFE_TO_ATTACH_INSTANCE_STATES; +use crate::db::queries::external_ip::SAFE_TO_ATTACH_INSTANCE_STATES_CREATING; +use crate::db::queries::external_ip::SAFE_TRANSIENT_INSTANCE_STATES; use crate::db::update_and_check::UpdateAndCheck; use crate::db::update_and_check::UpdateStatus; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use nexus_db_model::Instance; +use nexus_db_model::IpAttachState; use nexus_types::external_api::params; use nexus_types::identity::Resource; use omicron_common::api::external::http_pagination::PaginatedBy; @@ -35,13 +45,14 @@ 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; +const MAX_EXTERNAL_IPS_PLUS_SNAT: u32 = MAX_EXTERNAL_IPS_PER_INSTANCE + 1; + impl DataStore { /// Create an external IP address for source NAT for an instance. pub async fn allocate_instance_snat_ip( @@ -60,23 +71,43 @@ impl DataStore { } /// Create an Ephemeral IP address for an instance. + /// + /// For consistency between instance create and External IP attach/detach + /// operations, this IP will be created in the `Attaching` state to block + /// concurrent access. + /// Callers must call `external_ip_complete_op` on saga completion to move + /// the IP to `Attached`. + /// + /// To better handle idempotent attachment, this method returns an + /// additional bool: + /// - true: EIP was detached or attaching. proceed with saga. + /// - false: EIP was attached. No-op for remainder of saga. pub async fn allocate_instance_ephemeral_ip( &self, opctx: &OpContext, ip_id: Uuid, instance_id: Uuid, - pool_name: Option, - ) -> CreateResult { - let pool = match pool_name { - Some(name) => { - let (.., authz_pool, pool) = LookupPath::new(opctx, &self) - .ip_pool_name(&name) + pool: Option, + creating_instance: bool, + ) -> CreateResult<(ExternalIp, bool)> { + // This is slightly hacky: we need to create an unbound ephemeral IP, and + // then attempt to bind it to respect two separate constraints: + // - At most one Ephemeral IP per instance + // - At most MAX external IPs per instance + // Naturally, we now *need* to destroy the ephemeral IP if the newly alloc'd + // IP was not attached, including on idempotent success. + let pool = match pool { + Some(authz_pool) => { + let (.., pool) = LookupPath::new(opctx, &self) + .ip_pool_id(authz_pool.id()) // any authenticated user can CreateChild on an IP pool. this is // meant to represent allocating an IP .fetch_for(authz::Action::CreateChild) .await?; // If this pool is not linked to the current silo, 404 + // As name resolution happens one layer up, we need to use the *original* + // authz Pool. if self.ip_pool_fetch_link(opctx, pool.id()).await.is_err() { return Err(authz_pool.not_found()); } @@ -91,9 +122,49 @@ impl DataStore { }; let pool_id = pool.identity.id; - let data = - IncompleteExternalIp::for_ephemeral(ip_id, instance_id, pool_id); - self.allocate_external_ip(opctx, data).await + let data = IncompleteExternalIp::for_ephemeral(ip_id, pool_id); + + // We might not be able to acquire a new IP, but in the event of an + // idempotent or double attach this failure is allowed. + let temp_ip = self.allocate_external_ip(opctx, data).await; + if let Err(e) = temp_ip { + let eip = self + .instance_lookup_ephemeral_ip(opctx, instance_id) + .await? + .ok_or(e)?; + + return Ok((eip, false)); + } + let temp_ip = temp_ip?; + + match self + .begin_attach_ip( + opctx, + temp_ip.id, + instance_id, + IpKind::Ephemeral, + creating_instance, + ) + .await + { + Err(e) => { + self.deallocate_external_ip(opctx, temp_ip.id).await?; + Err(e) + } + // Idempotent case: attach failed due to a caught UniqueViolation. + Ok(None) => { + self.deallocate_external_ip(opctx, temp_ip.id).await?; + let eip = self + .instance_lookup_ephemeral_ip(opctx, instance_id) + .await? + .ok_or_else(|| Error::internal_error( + "failed to lookup current ephemeral IP for idempotent attach" + ))?; + let do_saga = eip.state != IpAttachState::Attached; + Ok((eip, do_saga)) + } + Ok(Some(v)) => Ok(v), + } } /// Allocates an IP address for internal service usage. @@ -140,33 +211,34 @@ impl DataStore { opctx: &OpContext, project_id: Uuid, params: params::FloatingIpCreate, + pool: Option, ) -> CreateResult { let ip_id = Uuid::new_v4(); - // TODO: NameOrId resolution should happen a level higher, in the nexus function - let (.., authz_pool, pool) = match params.pool { - Some(NameOrId::Name(name)) => { - LookupPath::new(opctx, self) - .ip_pool_name(&Name(name)) - .fetch_for(authz::Action::Read) - .await? + // This implements the same pattern as in `allocate_instance_ephemeral_ip` to + // check that a chosen pool is valid from within the current silo. + let pool = match pool { + Some(authz_pool) => { + let (.., pool) = LookupPath::new(opctx, &self) + .ip_pool_id(authz_pool.id()) + .fetch_for(authz::Action::CreateChild) + .await?; + + if self.ip_pool_fetch_link(opctx, pool.id()).await.is_err() { + return Err(authz_pool.not_found()); + } + + pool } - Some(NameOrId::Id(id)) => { - LookupPath::new(opctx, self) - .ip_pool_id(id) - .fetch_for(authz::Action::Read) - .await? + // If no name given, use the default logic + None => { + let (.., pool) = self.ip_pools_fetch_default(&opctx).await?; + pool } - None => self.ip_pools_fetch_default(opctx).await?, }; let pool_id = pool.id(); - // If this pool is not linked to the current silo, 404 - if self.ip_pool_fetch_link(opctx, pool_id).await.is_err() { - return Err(authz_pool.not_found()); - } - let data = if let Some(ip) = params.address { IncompleteExternalIp::for_floating_explicit( ip_id, @@ -228,6 +300,7 @@ impl DataStore { ) } } + // Floating IP: name conflict DatabaseError(UniqueViolation, ..) if name.is_some() => { TransactionError::CustomError(public_error_from_diesel( e, @@ -299,7 +372,266 @@ impl DataStore { self.allocate_external_ip(opctx, data).await } - /// Deallocate the external IP address with the provided ID. + /// Attempt to move a target external IP from detached to attaching, + /// checking that its parent instance does not have too many addresses + /// and is in a valid state. + /// + /// Returns the `ExternalIp` which was modified, where possible. This + /// is only nullable when trying to double-attach ephemeral IPs. + /// To better handle idempotent attachment, this method returns an + /// additional bool: + /// - true: EIP was detached or attaching. proceed with saga. + /// - false: EIP was attached. No-op for remainder of saga. + async fn begin_attach_ip( + &self, + opctx: &OpContext, + ip_id: Uuid, + instance_id: Uuid, + kind: IpKind, + creating_instance: bool, + ) -> Result, Error> { + use db::schema::external_ip::dsl; + use db::schema::external_ip::table; + use db::schema::instance::dsl as inst_dsl; + use db::schema::instance::table as inst_table; + use diesel::result::DatabaseErrorKind::UniqueViolation; + use diesel::result::Error::DatabaseError; + + let safe_states = if creating_instance { + &SAFE_TO_ATTACH_INSTANCE_STATES_CREATING[..] + } else { + &SAFE_TO_ATTACH_INSTANCE_STATES[..] + }; + + let query = Instance::attach_resource( + instance_id, + ip_id, + inst_table + .into_boxed() + .filter(inst_dsl::state.eq_any(safe_states)) + .filter(inst_dsl::migration_id.is_null()), + table + .into_boxed() + .filter(dsl::state.eq(IpAttachState::Detached)) + .filter(dsl::kind.eq(kind)) + .filter(dsl::parent_id.is_null()), + MAX_EXTERNAL_IPS_PLUS_SNAT, + diesel::update(dsl::external_ip).set(( + dsl::parent_id.eq(Some(instance_id)), + dsl::time_modified.eq(Utc::now()), + dsl::state.eq(IpAttachState::Attaching), + )), + ); + + let mut do_saga = true; + query.attach_and_get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map(|(_, resource)| Some(resource)) + .or_else(|e: AttachError| match e { + AttachError::CollectionNotFound => { + Err(Error::not_found_by_id( + ResourceType::Instance, + &instance_id, + )) + }, + AttachError::ResourceNotFound => { + Err(if kind == IpKind::Ephemeral { + Error::internal_error("call-scoped ephemeral IP was lost") + } else { + Error::not_found_by_id( + ResourceType::FloatingIp, + &ip_id, + ) + }) + }, + AttachError::NoUpdate { attached_count, resource, collection } => { + match resource.state { + // Idempotent errors: is in progress or complete for same resource pair -- this is fine. + IpAttachState::Attaching if resource.parent_id == Some(instance_id) => + return Ok(Some(resource)), + IpAttachState::Attached if resource.parent_id == Some(instance_id) => { + do_saga = false; + return Ok(Some(resource)) + }, + IpAttachState::Attached => + return Err(Error::invalid_request(&format!( + "{kind} IP cannot be attached to one \ + instance while still attached to another" + ))), + // User can reattempt depending on how the current saga unfolds. + // NB; only floating IP can return this case, eph will return + // a UniqueViolation. + IpAttachState::Attaching | IpAttachState::Detaching + => return Err(Error::unavail(&format!( + "tried to attach {kind} IP mid-attach/detach: \ + attach will be safe to retry once operation on \ + same IP resource completes" + ))), + + IpAttachState::Detached => {}, + } + + if collection.runtime_state.migration_id.is_some() { + return Err(Error::unavail(&format!( + "tried to attach {kind} IP while instance was migrating: \ + detach will be safe to retry once migrate completes" + ))) + } + + Err(match &collection.runtime_state.nexus_state { + state if SAFE_TRANSIENT_INSTANCE_STATES.contains(&state) + => Error::unavail(&format!( + "tried to attach {kind} IP while instance was changing state: \ + attach will be safe to retry once start/stop completes" + )), + state if SAFE_TO_ATTACH_INSTANCE_STATES.contains(&state) => { + if attached_count >= MAX_EXTERNAL_IPS_PLUS_SNAT as i64 { + Error::invalid_request(&format!( + "an instance may not have more than \ + {MAX_EXTERNAL_IPS_PER_INSTANCE} external IP addresses", + )) + } else { + Error::internal_error(&format!("failed to attach {kind} IP")) + } + }, + state => Error::invalid_request(&format!( + "cannot attach {kind} IP to instance in {state} state" + )), + }) + }, + // This case occurs for both currently attaching and attached ephemeral IPs: + AttachError::DatabaseError(DatabaseError(UniqueViolation, ..)) + if kind == IpKind::Ephemeral => { + Ok(None) + }, + AttachError::DatabaseError(e) => { + Err(public_error_from_diesel(e, ErrorHandler::Server)) + }, + }) + .map(|eip| eip.map(|v| (v, do_saga))) + } + + /// Attempt to move a target external IP from attached to detaching, + /// checking that its parent instance is in a valid state. + /// + /// Returns the `ExternalIp` which was modified, where possible. This + /// is only nullable when trying to double-detach ephemeral IPs. + /// To better handle idempotent attachment, this method returns an + /// additional bool: + /// - true: EIP was detached or attaching. proceed with saga. + /// - false: EIP was attached. No-op for remainder of saga. + async fn begin_detach_ip( + &self, + opctx: &OpContext, + ip_id: Uuid, + instance_id: Uuid, + kind: IpKind, + creating_instance: bool, + ) -> UpdateResult> { + use db::schema::external_ip::dsl; + use db::schema::external_ip::table; + use db::schema::instance::dsl as inst_dsl; + use db::schema::instance::table as inst_table; + + let safe_states = if creating_instance { + &SAFE_TO_ATTACH_INSTANCE_STATES_CREATING[..] + } else { + &SAFE_TO_ATTACH_INSTANCE_STATES[..] + }; + + let query = Instance::detach_resource( + instance_id, + ip_id, + inst_table + .into_boxed() + .filter(inst_dsl::state.eq_any(safe_states)) + .filter(inst_dsl::migration_id.is_null()), + table + .into_boxed() + .filter(dsl::state.eq(IpAttachState::Attached)) + .filter(dsl::kind.eq(kind)), + diesel::update(dsl::external_ip).set(( + dsl::time_modified.eq(Utc::now()), + dsl::state.eq(IpAttachState::Detaching), + )), + ); + + let mut do_saga = true; + query.detach_and_get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map(Some) + .or_else(|e: DetachError| Err(match e { + DetachError::CollectionNotFound => { + Error::not_found_by_id( + ResourceType::Instance, + &instance_id, + ) + }, + DetachError::ResourceNotFound => { + if kind == IpKind::Ephemeral { + return Ok(None); + } else { + Error::not_found_by_id( + ResourceType::FloatingIp, + &ip_id, + ) + } + }, + DetachError::NoUpdate { resource, collection } => { + let parent_match = resource.parent_id == Some(instance_id); + match resource.state { + // Idempotent cases: already detached OR detaching from same instance. + IpAttachState::Detached => { + do_saga = false; + return Ok(Some(resource)) + }, + IpAttachState::Detaching if parent_match => return Ok(Some(resource)), + IpAttachState::Attached if !parent_match + => return Err(Error::invalid_request(&format!( + "{kind} IP is not attached to the target instance", + ))), + // User can reattempt depending on how the current saga unfolds. + IpAttachState::Attaching + | IpAttachState::Detaching => return Err(Error::unavail(&format!( + "tried to detach {kind} IP mid-attach/detach: \ + detach will be safe to retry once operation on \ + same IP resource completes" + ))), + IpAttachState::Attached => {}, + } + + if collection.runtime_state.migration_id.is_some() { + return Err(Error::unavail(&format!( + "tried to detach {kind} IP while instance was migrating: \ + detach will be safe to retry once migrate completes" + ))) + } + + match collection.runtime_state.nexus_state { + state if SAFE_TRANSIENT_INSTANCE_STATES.contains(&state) => Error::unavail(&format!( + "tried to attach {kind} IP while instance was changing state: \ + detach will be safe to retry once start/stop completes" + )), + state if SAFE_TO_ATTACH_INSTANCE_STATES.contains(&state) => { + Error::internal_error(&format!("failed to detach {kind} IP")) + }, + state => Error::invalid_request(&format!( + "cannot detach {kind} IP from instance in {state} state" + )), + } + }, + DetachError::DatabaseError(e) => { + public_error_from_diesel(e, ErrorHandler::Server) + }, + + })) + .map(|eip| eip.map(|v| (v, do_saga))) + } + + /// Deallocate the external IP address with the provided ID. This is a complete + /// removal of the IP entry, in contrast with `begin_deallocate_ephemeral_ip`, + /// and should only be used for SNAT entries or cleanup of short-lived ephemeral + /// IPs on failure. /// /// To support idempotency, such as in saga operations, this method returns /// an extra boolean, rather than the usual `DeleteResult`. The meaning of @@ -329,7 +661,34 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } - /// Delete all external IP addresses associated with the provided instance + /// Moves an instance's ephemeral IP from 'Attached' to 'Detaching'. + /// + /// To support idempotency, this method will succeed if the instance + /// has no ephemeral IP or one is actively being removed. As a result, + /// information on an actual `ExternalIp` is best-effort. + pub async fn begin_deallocate_ephemeral_ip( + &self, + opctx: &OpContext, + ip_id: Uuid, + instance_id: Uuid, + ) -> Result, Error> { + let _ = LookupPath::new(&opctx, self) + .instance_id(instance_id) + .lookup_for(authz::Action::Modify) + .await?; + + self.begin_detach_ip( + opctx, + ip_id, + instance_id, + IpKind::Ephemeral, + false, + ) + .await + .map(|res| res.map(|(ip, _do_saga)| ip)) + } + + /// Delete all non-floating IP addresses associated with the provided instance /// ID. /// /// This method returns the number of records deleted, rather than the usual @@ -347,16 +706,22 @@ impl DataStore { .filter(dsl::is_service.eq(false)) .filter(dsl::parent_id.eq(instance_id)) .filter(dsl::kind.ne(IpKind::Floating)) - .set(dsl::time_deleted.eq(now)) + .set(( + dsl::time_deleted.eq(now), + dsl::state.eq(IpAttachState::Detached), + )) .execute_async(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } - /// Detach an individual Floating IP address from its parent instance. + /// Detach all Floating IP address from their parent instance. /// /// As in `deallocate_external_ip_by_instance_id`, this method returns the /// number of records altered, rather than an `UpdateResult`. + /// + /// This method ignores ongoing state transitions, and is only safely + /// usable from within the instance_delete saga. pub async fn detach_floating_ips_by_instance_id( &self, opctx: &OpContext, @@ -368,13 +733,18 @@ impl DataStore { .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)) + .set(( + dsl::time_modified.eq(Utc::now()), + dsl::parent_id.eq(Option::::None), + dsl::state.eq(IpAttachState::Detached), + )) .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 + /// in all attachment states. pub async fn instance_lookup_external_ips( &self, opctx: &OpContext, @@ -391,6 +761,20 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Fetch the ephmeral IP address assigned to the provided instance, if this + /// has been configured. + pub async fn instance_lookup_ephemeral_ip( + &self, + opctx: &OpContext, + instance_id: Uuid, + ) -> LookupResult> { + Ok(self + .instance_lookup_external_ips(opctx, instance_id) + .await? + .into_iter() + .find(|v| v.kind == IpKind::Ephemeral)) + } + /// Fetch all Floating IP addresses for the provided project. pub async fn floating_ips_list( &self, @@ -425,26 +809,20 @@ impl DataStore { &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())) + let result = diesel::update(dsl::external_ip) + .filter(dsl::id.eq(authz_fip.id())) .filter(dsl::time_deleted.is_null()) .filter(dsl::parent_id.is_null()) + .filter(dsl::state.eq(IpAttachState::Detached)) .set(dsl::time_deleted.eq(now)) - .execute_async(&*self.pool_connection_authorized(opctx).await?) + .check_if_exists::(authz_fip.id()) + .execute_and_check(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| { public_error_from_diesel( @@ -453,103 +831,208 @@ impl DataStore { ) })?; - if updated_rows == 0 { - return Err(Error::invalid_request( - "deletion failed due to concurrent modification", - )); + match result.status { + // Verify this FIP is not attached to any instances/services. + UpdateStatus::NotUpdatedButExists if result.found.parent_id.is_some() => Err(Error::invalid_request( + "Floating IP cannot be deleted while attached to an instance", + )), + // Only remaining cause of `NotUpdated` is earlier soft-deletion. + // Return success in this case to maintain idempotency. + UpdateStatus::Updated | UpdateStatus::NotUpdatedButExists => Ok(()), } - Ok(()) } /// Attaches a Floating IP address to an instance. - pub async fn floating_ip_attach( + /// + /// This moves a floating IP into the 'attaching' state. Callers are + /// responsible for calling `external_ip_complete_op` to finalise the + /// IP in 'attached' state at saga completion. + /// + /// To better handle idempotent attachment, this method returns an + /// additional bool: + /// - true: EIP was detached or attaching. proceed with saga. + /// - false: EIP was attached. No-op for remainder of saga. + pub async fn floating_ip_begin_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) + creating_instance: bool, + ) -> UpdateResult<(ExternalIp, bool)> { + let (.., authz_instance) = LookupPath::new(&opctx, self) .instance_id(instance_id) - .fetch_for(authz::Action::Modify) + .lookup_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), + self.begin_attach_ip( + opctx, + authz_fip.id(), + instance_id, + IpKind::Floating, + creating_instance, + ) + .await + .and_then(|v| { + v.ok_or_else(|| { + Error::internal_error( + "floating IP should never return `None` from begin_attach", ) }) - .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( + /// + /// This moves a floating IP into the 'detaching' state. Callers are + /// responsible for calling `external_ip_complete_op` to finalise the + /// IP in 'detached' state at saga completion. + /// + /// To better handle idempotent detachment, this method returns an + /// additional bool: + /// - true: EIP was attached or detaching. proceed with saga. + /// - false: EIP was detached. No-op for remainder of saga. + pub async fn floating_ip_begin_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: Uuid, + creating_instance: bool, + ) -> UpdateResult<(ExternalIp, bool)> { + let (.., authz_instance) = LookupPath::new(&opctx, self) .instance_id(instance_id) - .fetch_for(authz::Action::Modify) + .lookup_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), + self.begin_detach_ip( + opctx, + authz_fip.id(), + instance_id, + IpKind::Floating, + creating_instance, + ) + .await + .and_then(|v| { + v.ok_or_else(|| { + Error::internal_error( + "floating IP should never return `None` from begin_detach", ) }) - .and_then(|r| FloatingIp::try_from(r)) - .map_err(|e| Error::internal_error(&format!("{e}"))) + }) + } + + /// Move an external IP from a transitional state (attaching, detaching) + /// to its intended end state. + /// + /// Returns the number of rows modified, this may be zero on: + /// - instance delete by another saga + /// - saga action rerun + /// + /// This is valid in both cases for idempotency. + pub async fn external_ip_complete_op( + &self, + opctx: &OpContext, + ip_id: Uuid, + ip_kind: IpKind, + expected_state: IpAttachState, + target_state: IpAttachState, + ) -> Result { + use db::schema::external_ip::dsl; + + if matches!( + expected_state, + IpAttachState::Attached | IpAttachState::Detached + ) { + return Err(Error::internal_error(&format!( + "{expected_state:?} is not a valid transition state for attach/detach" + ))); + } + + let part_out = diesel::update(dsl::external_ip) + .filter(dsl::id.eq(ip_id)) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::state.eq(expected_state)); + + let now = Utc::now(); + let conn = self.pool_connection_authorized(opctx).await?; + match (ip_kind, expected_state, target_state) { + (IpKind::SNat, _, _) => return Err(Error::internal_error( + "SNAT should not be removed via `external_ip_complete_op`, \ + use `deallocate_external_ip`", + )), + + (IpKind::Ephemeral, _, IpAttachState::Detached) => { + part_out + .set(( + dsl::parent_id.eq(Option::::None), + dsl::time_modified.eq(now), + dsl::time_deleted.eq(now), + dsl::state.eq(target_state), + )) + .execute_async(&*conn) + .await + } + + (IpKind::Floating, _, IpAttachState::Detached) => { + part_out + .set(( + dsl::parent_id.eq(Option::::None), + dsl::time_modified.eq(now), + dsl::state.eq(target_state), + )) + .execute_async(&*conn) + .await + } + + // Attaching->Attached gets separate logic because we choose to fail + // and unwind on instance delete. This covers two cases: + // - External IP is deleted. + // - Floating IP is suddenly `detached`. + (_, IpAttachState::Attaching, IpAttachState::Attached) => { + return part_out + .set(( + dsl::time_modified.eq(Utc::now()), + dsl::state.eq(target_state), + )) + .check_if_exists::(ip_id) + .execute_and_check( + &*self.pool_connection_authorized(opctx).await?, + ) + .await + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + }) + .and_then(|r| match r.status { + UpdateStatus::Updated => Ok(1), + UpdateStatus::NotUpdatedButExists + if r.found.state == IpAttachState::Detached + || r.found.time_deleted.is_some() => + { + Err(Error::internal_error( + "unwinding due to concurrent instance delete", + )) + } + UpdateStatus::NotUpdatedButExists => Ok(0), + }) + } + + // Unwind from failed detach. + (_, _, IpAttachState::Attached) => { + part_out + .set(( + dsl::time_modified.eq(Utc::now()), + dsl::state.eq(target_state), + )) + .execute_async(&*conn) + .await + } + _ => return Err(Error::internal_error("unreachable")), + } + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } } diff --git a/nexus/db-queries/src/db/datastore/instance.rs b/nexus/db-queries/src/db/datastore/instance.rs index 188f5c30c96..c01f40e7917 100644 --- a/nexus/db-queries/src/db/datastore/instance.rs +++ b/nexus/db-queries/src/db/datastore/instance.rs @@ -11,6 +11,7 @@ use crate::context::OpContext; use crate::db; use crate::db::collection_detach_many::DatastoreDetachManyTarget; use crate::db::collection_detach_many::DetachManyError; +use crate::db::collection_detach_many::DetachManyFromCollectionStatement; use crate::db::collection_insert::AsyncInsertError; use crate::db::collection_insert::DatastoreCollection; use crate::db::error::public_error_from_diesel; @@ -28,6 +29,7 @@ use crate::db::update_and_check::UpdateStatus; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use nexus_db_model::Disk; use nexus_db_model::VmmRuntimeState; use omicron_common::api; use omicron_common::api::external::http_pagination::PaginatedBy; @@ -405,59 +407,63 @@ impl DataStore { let ok_to_detach_disk_state_labels: Vec<_> = ok_to_detach_disk_states.iter().map(|s| s.label()).collect(); - let _instance = Instance::detach_resources( - authz_instance.id(), - instance::table.into_boxed().filter( - instance::dsl::state - .eq_any(ok_to_delete_instance_states) - .and(instance::dsl::active_propolis_id.is_null()), - ), - disk::table.into_boxed().filter( - disk::dsl::disk_state.eq_any(ok_to_detach_disk_state_labels), - ), - diesel::update(instance::dsl::instance).set(( - instance::dsl::state.eq(destroyed), - instance::dsl::time_deleted.eq(Utc::now()), - )), - diesel::update(disk::dsl::disk).set(( - disk::dsl::disk_state.eq(detached_label), - disk::dsl::attach_instance_id.eq(Option::::None), - disk::dsl::slot.eq(Option::::None), - )), - ) - .detach_and_get_result_async( - &*self.pool_connection_authorized(opctx).await?, - ) - .await - .map_err(|e| match e { - DetachManyError::CollectionNotFound => Error::not_found_by_id( - ResourceType::Instance, - &authz_instance.id(), - ), - DetachManyError::NoUpdate { collection } => { - if collection.runtime_state.propolis_id.is_some() { - return Error::invalid_request( + let stmt: DetachManyFromCollectionStatement = + Instance::detach_resources( + authz_instance.id(), + instance::table.into_boxed().filter( + instance::dsl::state + .eq_any(ok_to_delete_instance_states) + .and(instance::dsl::active_propolis_id.is_null()), + ), + disk::table.into_boxed().filter( + disk::dsl::disk_state + .eq_any(ok_to_detach_disk_state_labels), + ), + diesel::update(instance::dsl::instance).set(( + instance::dsl::state.eq(destroyed), + instance::dsl::time_deleted.eq(Utc::now()), + )), + diesel::update(disk::dsl::disk).set(( + disk::dsl::disk_state.eq(detached_label), + disk::dsl::attach_instance_id.eq(Option::::None), + disk::dsl::slot.eq(Option::::None), + )), + ); + + let _instance = stmt + .detach_and_get_result_async( + &*self.pool_connection_authorized(opctx).await?, + ) + .await + .map_err(|e| match e { + DetachManyError::CollectionNotFound => Error::not_found_by_id( + ResourceType::Instance, + &authz_instance.id(), + ), + DetachManyError::NoUpdate { collection } => { + if collection.runtime_state.propolis_id.is_some() { + return Error::invalid_request( "cannot delete instance: instance is running or has \ not yet fully stopped", ); - } - let instance_state = - collection.runtime_state.nexus_state.state(); - match instance_state { - api::external::InstanceState::Stopped - | api::external::InstanceState::Failed => { - Error::internal_error("cannot delete instance") } - _ => Error::invalid_request(&format!( - "instance cannot be deleted in state \"{}\"", - instance_state, - )), + let instance_state = + collection.runtime_state.nexus_state.state(); + match instance_state { + api::external::InstanceState::Stopped + | api::external::InstanceState::Failed => { + Error::internal_error("cannot delete instance") + } + _ => Error::invalid_request(&format!( + "instance cannot be deleted in state \"{}\"", + instance_state, + )), + } } - } - DetachManyError::DatabaseError(e) => { - public_error_from_diesel(e, ErrorHandler::Server) - } - })?; + DetachManyError::DatabaseError(e) => { + public_error_from_diesel(e, ErrorHandler::Server) + } + })?; Ok(()) } 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 a44fed4cdfa..655a267fe15 100644 --- a/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs +++ b/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs @@ -23,12 +23,14 @@ impl DataStore { &self, opctx: &OpContext, nat_entry: Ipv4NatValues, - ) -> CreateResult<()> { + ) -> CreateResult { use db::schema::ipv4_nat_entry::dsl; use diesel::sql_types; // Look up any NAT entries that already have the exact parameters // we're trying to INSERT. + // We want to return any existing entry, but not to mask the UniqueViolation + // when trying to use an existing IP + port range with a different target. let matching_entry_subquery = dsl::ipv4_nat_entry .filter(dsl::external_address.eq(nat_entry.external_address)) .filter(dsl::first_port.eq(nat_entry.first_port)) @@ -58,7 +60,7 @@ impl DataStore { )) .filter(diesel::dsl::not(diesel::dsl::exists(matching_entry_subquery))); - diesel::insert_into(dsl::ipv4_nat_entry) + let out = diesel::insert_into(dsl::ipv4_nat_entry) .values(new_entry_subquery) .into_columns(( dsl::external_address, @@ -68,11 +70,24 @@ impl DataStore { dsl::vni, dsl::mac, )) - .execute_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; - - Ok(()) + .returning(Ipv4NatEntry::as_returning()) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await; + + match out { + Ok(o) => Ok(o), + Err(diesel::result::Error::NotFound) => { + // Idempotent ensure. Annoyingly, we can't easily extract + // the existing row as part of the insert query: + // - (SELECT ..) UNION (INSERT INTO .. RETURNING ..) isn't + // allowed by crdb. + // - Can't ON CONFLICT with a partial constraint, so we can't + // do a no-op write and return the row that way either. + // So, we do another lookup. + self.ipv4_nat_find_by_values(opctx, nat_entry).await + } + Err(e) => Err(public_error_from_diesel(e, ErrorHandler::Server)), + } } pub async fn ipv4_nat_delete( diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index d61ff15a3d0..78a7aeda875 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -397,22 +397,21 @@ mod test { use crate::db::identity::Asset; use crate::db::lookup::LookupPath; use crate::db::model::{ - BlockSize, ComponentUpdate, ComponentUpdateIdentity, ConsoleSession, - Dataset, DatasetKind, ExternalIp, PhysicalDisk, PhysicalDiskKind, - Project, Rack, Region, Service, ServiceKind, SiloUser, SledBaseboard, - SledProvisionState, SledSystemHardware, SledUpdate, SshKey, - SystemUpdate, UpdateableComponentType, VpcSubnet, Zpool, + BlockSize, ConsoleSession, Dataset, DatasetKind, ExternalIp, + PhysicalDisk, PhysicalDiskKind, Project, Rack, Region, Service, + ServiceKind, SiloUser, SledBaseboard, SledProvisionState, + SledSystemHardware, SledUpdate, SshKey, VpcSubnet, Zpool, }; use crate::db::queries::vpc_subnet::FilterConflictingVpcSubnetRangesQuery; - use assert_matches::assert_matches; use chrono::{Duration, Utc}; use futures::stream; use futures::StreamExt; + use nexus_db_model::IpAttachState; use nexus_test_utils::db::test_setup_database; use nexus_types::external_api::params; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::{ - self, ByteCount, Error, IdentityMetadataCreateParams, LookupType, Name, + ByteCount, Error, IdentityMetadataCreateParams, LookupType, Name, }; use omicron_common::nexus_config::RegionAllocationStrategy; use omicron_test_utils::dev; @@ -1625,7 +1624,8 @@ mod test { // Create a few records. let now = Utc::now(); let instance_id = Uuid::new_v4(); - let ips = (0..4) + let kinds = [IpKind::SNat, IpKind::Ephemeral]; + let ips = (0..2) .map(|i| ExternalIp { id: Uuid::new_v4(), name: None, @@ -1638,12 +1638,13 @@ mod test { project_id: None, is_service: false, parent_id: Some(instance_id), - kind: IpKind::Ephemeral, + kind: kinds[i as usize], ip: ipnetwork::IpNetwork::from(IpAddr::from(Ipv4Addr::new( 10, 0, 0, i, ))), first_port: crate::db::model::SqlU16(0), last_port: crate::db::model::SqlU16(10), + state: nexus_db_model::IpAttachState::Attached, }) .collect::>(); diesel::insert_into(dsl::external_ip) @@ -1705,6 +1706,7 @@ mod test { ))), first_port: crate::db::model::SqlU16(0), last_port: crate::db::model::SqlU16(10), + state: nexus_db_model::IpAttachState::Attached, }; diesel::insert_into(dsl::external_ip) .values(ip.clone()) @@ -1775,6 +1777,7 @@ mod test { ip: addresses.next().unwrap().into(), first_port: crate::db::model::SqlU16(0), last_port: crate::db::model::SqlU16(10), + state: nexus_db_model::IpAttachState::Attached, }; // Combinations of NULL and non-NULL for: @@ -1782,6 +1785,7 @@ mod test { // - description // - parent (instance / service) UUID // - project UUID + // - attach state let names = [None, Some("foo")]; let descriptions = [None, Some("foo".to_string())]; let parent_ids = [None, Some(Uuid::new_v4())]; @@ -1822,6 +1826,12 @@ mod test { continue; } + let state = if parent_id.is_some() { + IpAttachState::Attached + } else { + IpAttachState::Detached + }; + let new_ip = ExternalIp { id: Uuid::new_v4(), name: name_local.clone(), @@ -1830,6 +1840,7 @@ mod test { is_service, parent_id: *parent_id, project_id: *project_id, + state, ..ip }; @@ -1902,6 +1913,11 @@ mod test { let name_local = name.map(|v| { db::model::Name(Name::try_from(v.to_string()).unwrap()) }); + let state = if parent_id.is_some() { + IpAttachState::Attached + } else { + IpAttachState::Detached + }; let new_ip = ExternalIp { id: Uuid::new_v4(), name: name_local, @@ -1911,6 +1927,7 @@ mod test { is_service, parent_id: *parent_id, project_id: *project_id, + state, ..ip }; let res = diesel::insert_into(dsl::external_ip) @@ -1918,9 +1935,10 @@ mod test { .execute_async(&*conn) .await; let ip_type = if is_service { "Service" } else { "Instance" }; + let null_snat_parent = parent_id.is_none() && kind == IpKind::SNat; if name.is_none() && description.is_none() - && parent_id.is_some() + && !null_snat_parent && project_id.is_none() { // Name/description must be NULL, instance ID cannot @@ -1968,109 +1986,4 @@ mod test { db.cleanup().await.unwrap(); logctx.cleanup_successful(); } - - /// Expect DB error if we try to insert a system update with an id that - /// already exists. If version matches, update the existing row (currently - /// only time_modified) - #[tokio::test] - async fn test_system_update_conflict() { - let logctx = dev::test_setup_log("test_system_update_conflict"); - let mut db = test_setup_database(&logctx.log).await; - let (opctx, datastore) = datastore_test(&logctx, &db).await; - - let v1 = external::SemverVersion::new(1, 0, 0); - let update1 = SystemUpdate::new(v1.clone()).unwrap(); - datastore - .upsert_system_update(&opctx, update1.clone()) - .await - .expect("Failed to create system update"); - - // same version, but different ID (generated by constructor). should - // conflict and therefore update time_modified, keeping the old ID - let update2 = SystemUpdate::new(v1).unwrap(); - let updated_update = datastore - .upsert_system_update(&opctx, update2.clone()) - .await - .unwrap(); - assert!(updated_update.identity.id == update1.identity.id); - assert!( - updated_update.identity.time_modified - != update1.identity.time_modified - ); - - // now let's do same ID, but different version. should conflict on the - // ID because it's the PK, but since the version doesn't match an - // existing row, it errors out instead of updating one - let update3 = - SystemUpdate::new(external::SemverVersion::new(2, 0, 0)).unwrap(); - let update3 = SystemUpdate { identity: update1.identity, ..update3 }; - let conflict = - datastore.upsert_system_update(&opctx, update3).await.unwrap_err(); - assert_matches!(conflict, Error::ObjectAlreadyExists { .. }); - - db.cleanup().await.unwrap(); - logctx.cleanup_successful(); - } - - /// Expect DB error if we try to insert a component update with a (version, - /// component_type) that already exists - #[tokio::test] - async fn test_component_update_conflict() { - let logctx = dev::test_setup_log("test_component_update_conflict"); - let mut db = test_setup_database(&logctx.log).await; - let (opctx, datastore) = datastore_test(&logctx, &db).await; - - // we need a system update for the component updates to hang off of - let v1 = external::SemverVersion::new(1, 0, 0); - let system_update = SystemUpdate::new(v1.clone()).unwrap(); - datastore - .upsert_system_update(&opctx, system_update.clone()) - .await - .expect("Failed to create system update"); - - // create a component update, that's fine - let cu1 = ComponentUpdate { - identity: ComponentUpdateIdentity::new(Uuid::new_v4()), - component_type: UpdateableComponentType::HubrisForSidecarRot, - version: db::model::SemverVersion::new(1, 0, 0), - }; - datastore - .create_component_update( - &opctx, - system_update.identity.id, - cu1.clone(), - ) - .await - .expect("Failed to create component update"); - - // create a second component update with same version but different - // type, also fine - let cu2 = ComponentUpdate { - identity: ComponentUpdateIdentity::new(Uuid::new_v4()), - component_type: UpdateableComponentType::HubrisForSidecarSp, - version: db::model::SemverVersion::new(1, 0, 0), - }; - datastore - .create_component_update( - &opctx, - system_update.identity.id, - cu2.clone(), - ) - .await - .expect("Failed to create component update"); - - // but same type and version should fail - let cu3 = ComponentUpdate { - identity: ComponentUpdateIdentity::new(Uuid::new_v4()), - ..cu1 - }; - let conflict = datastore - .create_component_update(&opctx, system_update.identity.id, cu3) - .await - .unwrap_err(); - assert_matches!(conflict, Error::ObjectAlreadyExists { .. }); - - db.cleanup().await.unwrap(); - logctx.cleanup_successful(); - } } diff --git a/nexus/db-queries/src/db/datastore/update.rs b/nexus/db-queries/src/db/datastore/update.rs index 0790bd458ec..3725797f833 100644 --- a/nexus/db-queries/src/db/datastore/update.rs +++ b/nexus/db-queries/src/db/datastore/update.rs @@ -4,376 +4,368 @@ //! [`DataStore`] methods related to updates and artifacts. +use std::collections::HashMap; + use super::DataStore; use crate::authz; use crate::context::OpContext; use crate::db; use crate::db::error::{public_error_from_diesel, ErrorHandler}; -use crate::db::model::{ - ComponentUpdate, SemverVersion, SystemUpdate, UpdateArtifact, - UpdateDeployment, UpdateStatus, UpdateableComponent, -}; -use crate::db::pagination::paginated; +use crate::db::model::SemverVersion; +use crate::transaction_retry::OptionalError; use async_bb8_diesel::AsyncRunQueryDsl; -use chrono::Utc; use diesel::prelude::*; -use nexus_db_model::SystemUpdateComponentUpdate; -use nexus_types::identity::Asset; +use diesel::result::Error as DieselError; +use nexus_db_model::{ArtifactHash, TufArtifact, TufRepo, TufRepoDescription}; use omicron_common::api::external::{ - CreateResult, DataPageParams, DeleteResult, InternalContext, ListResultVec, - LookupResult, LookupType, ResourceType, UpdateResult, + self, CreateResult, LookupResult, LookupType, ResourceType, + TufRepoInsertStatus, }; +use swrite::{swrite, SWrite}; use uuid::Uuid; -impl DataStore { - pub async fn update_artifact_upsert( - &self, - opctx: &OpContext, - artifact: UpdateArtifact, - ) -> CreateResult { - opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; +/// The return value of [`DataStore::update_tuf_repo_description_insert`]. +/// +/// This is similar to [`external::TufRepoInsertResponse`], but uses +/// nexus-db-model's types instead of external types. +pub struct TufRepoInsertResponse { + pub recorded: TufRepoDescription, + pub status: TufRepoInsertStatus, +} - use db::schema::update_artifact::dsl; - diesel::insert_into(dsl::update_artifact) - .values(artifact.clone()) - .on_conflict((dsl::name, dsl::version, dsl::kind)) - .do_update() - .set(artifact.clone()) - .returning(UpdateArtifact::as_returning()) - .get_result_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) +impl TufRepoInsertResponse { + pub fn into_external(self) -> external::TufRepoInsertResponse { + external::TufRepoInsertResponse { + recorded: self.recorded.into_external(), + status: self.status, + } } +} + +async fn artifacts_for_repo( + repo_id: Uuid, + conn: &async_bb8_diesel::Connection, +) -> Result, DieselError> { + use db::schema::tuf_artifact::dsl as tuf_artifact_dsl; + use db::schema::tuf_repo_artifact::dsl as tuf_repo_artifact_dsl; + + let join_on_dsl = tuf_artifact_dsl::name + .eq(tuf_repo_artifact_dsl::tuf_artifact_name) + .and( + tuf_artifact_dsl::version + .eq(tuf_repo_artifact_dsl::tuf_artifact_version), + ) + .and( + tuf_artifact_dsl::kind.eq(tuf_repo_artifact_dsl::tuf_artifact_kind), + ); + // Don't bother paginating because each repo should only have a few (under + // 20) artifacts. + tuf_repo_artifact_dsl::tuf_repo_artifact + .filter(tuf_repo_artifact_dsl::tuf_repo_id.eq(repo_id)) + .inner_join(tuf_artifact_dsl::tuf_artifact.on(join_on_dsl)) + .select(TufArtifact::as_select()) + .load_async(conn) + .await +} - pub async fn update_artifact_hard_delete_outdated( +impl DataStore { + /// Inserts a new TUF repository into the database. + /// + /// Returns the repository just inserted, or an existing + /// `TufRepoDescription` if one was already found. (This is not an upsert, + /// because if we know about an existing repo but with different contents, + /// we reject that.) + pub async fn update_tuf_repo_insert( &self, opctx: &OpContext, - current_targets_role_version: i64, - ) -> DeleteResult { + description: TufRepoDescription, + ) -> CreateResult { opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + let log = opctx.log.new( + slog::o!( + "method" => "update_tuf_repo_insert", + "uploaded_system_version" => description.repo.system_version.to_string(), + ), + ); - // We use the `targets_role_version` column in the table to delete any - // old rows, keeping the table in sync with the current copy of - // artifacts.json. - use db::schema::update_artifact::dsl; - diesel::delete(dsl::update_artifact) - .filter(dsl::targets_role_version.lt(current_targets_role_version)) - .execute_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map(|_rows_deleted| ()) - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - .internal_context("deleting outdated available artifacts") - } + let err = OptionalError::new(); + let err2 = err.clone(); - pub async fn upsert_system_update( - &self, - opctx: &OpContext, - update: SystemUpdate, - ) -> CreateResult { - opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; - - use db::schema::system_update::dsl::*; - - diesel::insert_into(system_update) - .values(update.clone()) - .on_conflict(version) - .do_update() - // for now the only modifiable field is time_modified, but we intend - // to add more metadata to this model - .set(time_modified.eq(Utc::now())) - .returning(SystemUpdate::as_returning()) - .get_result_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::Conflict( - ResourceType::SystemUpdate, - &update.version.to_string(), - ), + let conn = self.pool_connection_authorized(opctx).await?; + self.transaction_retry_wrapper("update_tuf_repo_insert") + .transaction(&conn, move |conn| { + insert_impl( + log.clone(), + conn, + description.clone(), + err2.clone(), ) }) - } - - // version is unique but not the primary key, so we can't use LookupPath to handle this for us - pub async fn system_update_fetch_by_version( - &self, - opctx: &OpContext, - target: SemverVersion, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::system_update::dsl::*; - - let version_string = target.to_string(); - - system_update - .filter(version.eq(target)) - .select(SystemUpdate::as_select()) - .first_async(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::NotFoundByLookup( - ResourceType::SystemUpdate, - LookupType::ByCompositeId(version_string), - ), - ) - }) - } - - pub async fn create_component_update( - &self, - opctx: &OpContext, - system_update_id: Uuid, - update: ComponentUpdate, - ) -> CreateResult { - opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; - - // TODO: make sure system update with that ID exists first - // let (.., db_system_update) = LookupPath::new(opctx, &self) - - use db::schema::component_update; - use db::schema::system_update_component_update as join_table; - - let version_string = update.version.to_string(); - - let conn = self.pool_connection_authorized(opctx).await?; - - self.transaction_retry_wrapper("create_component_update") - .transaction(&conn, |conn| { - let update = update.clone(); - async move { - let db_update = - diesel::insert_into(component_update::table) - .values(update.clone()) - .returning(ComponentUpdate::as_returning()) - .get_result_async(&conn) - .await?; - - diesel::insert_into(join_table::table) - .values(SystemUpdateComponentUpdate { - system_update_id, - component_update_id: update.id(), - }) - .returning(SystemUpdateComponentUpdate::as_returning()) - .get_result_async(&conn) - .await?; - - Ok(db_update) + if let Some(err) = err.take() { + err.into() + } else { + public_error_from_diesel(e, ErrorHandler::Server) } }) - .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::Conflict( - ResourceType::ComponentUpdate, - &version_string, - ), - ) - }) } - pub async fn system_updates_list_by_id( + /// Returns the TUF repo description corresponding to this system version. + pub async fn update_tuf_repo_get( &self, opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + system_version: SemverVersion, + ) -> LookupResult { + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - use db::schema::system_update::dsl::*; + use db::schema::tuf_repo::dsl; - paginated(system_update, id, pagparams) - .select(SystemUpdate::as_select()) - .order(version.desc()) - .load_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - - pub async fn system_update_components_list( - &self, - opctx: &OpContext, - system_update_id: Uuid, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::component_update; - use db::schema::system_update_component_update as join_table; - - component_update::table - .inner_join(join_table::table) - .filter(join_table::columns::system_update_id.eq(system_update_id)) - .select(ComponentUpdate::as_select()) - .get_results_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - - pub async fn create_updateable_component( - &self, - opctx: &OpContext, - component: UpdateableComponent, - ) -> CreateResult { - opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; - - // make sure system version exists - let sys_version = component.system_version.clone(); - self.system_update_fetch_by_version(opctx, sys_version).await?; - - use db::schema::updateable_component::dsl::*; + let conn = self.pool_connection_authorized(opctx).await?; - diesel::insert_into(updateable_component) - .values(component.clone()) - .returning(UpdateableComponent::as_returning()) - .get_result_async(&*self.pool_connection_authorized(opctx).await?) + let repo = dsl::tuf_repo + .filter(dsl::system_version.eq(system_version.clone())) + .select(TufRepo::as_select()) + .first_async::(&*conn) .await .map_err(|e| { public_error_from_diesel( e, - ErrorHandler::Conflict( - ResourceType::UpdateableComponent, - &component.id().to_string(), // TODO: more informative identifier + ErrorHandler::NotFoundByLookup( + ResourceType::TufRepo, + LookupType::ByCompositeId(system_version.to_string()), ), ) - }) - } - - pub async fn updateable_components_list_by_id( - &self, - opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::updateable_component::dsl::*; - - paginated(updateable_component, id, pagparams) - .select(UpdateableComponent::as_select()) - .load_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - - pub async fn lowest_component_system_version( - &self, - opctx: &OpContext, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::updateable_component::dsl::*; - - updateable_component - .select(system_version) - .order(system_version.asc()) - .first_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - - pub async fn highest_component_system_version( - &self, - opctx: &OpContext, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::updateable_component::dsl::*; - - updateable_component - .select(system_version) - .order(system_version.desc()) - .first_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - - pub async fn create_update_deployment( - &self, - opctx: &OpContext, - deployment: UpdateDeployment, - ) -> CreateResult { - opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; - - use db::schema::update_deployment::dsl::*; + })?; - diesel::insert_into(update_deployment) - .values(deployment.clone()) - .returning(UpdateDeployment::as_returning()) - .get_result_async(&*self.pool_connection_authorized(opctx).await?) + let artifacts = artifacts_for_repo(repo.id, &conn) .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::Conflict( - ResourceType::UpdateDeployment, - &deployment.id().to_string(), - ), - ) - }) + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + Ok(TufRepoDescription { repo, artifacts }) } +} - pub async fn steady_update_deployment( - &self, - opctx: &OpContext, - deployment_id: Uuid, - ) -> UpdateResult { - // TODO: use authz::UpdateDeployment as the input so we can check Modify - // on that instead - opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; - - use db::schema::update_deployment::dsl::*; - - diesel::update(update_deployment) - .filter(id.eq(deployment_id)) - .set(( - status.eq(UpdateStatus::Steady), - time_modified.eq(diesel::dsl::now), - )) - .returning(UpdateDeployment::as_returning()) - .get_result_async(&*self.pool_connection_authorized(opctx).await?) +// This is a separate method mostly to make rustfmt not bail out on long lines +// of text. +async fn insert_impl( + log: slog::Logger, + conn: async_bb8_diesel::Connection, + desc: TufRepoDescription, + err: OptionalError, +) -> Result { + let repo = { + use db::schema::tuf_repo::dsl; + + // Load the existing repo by the system version, if + // any. + let existing_repo = dsl::tuf_repo + .filter(dsl::system_version.eq(desc.repo.system_version.clone())) + .select(TufRepo::as_select()) + .first_async::(&conn) .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::NotFoundByLookup( - ResourceType::UpdateDeployment, - LookupType::ById(deployment_id), - ), - ) - }) + .optional()?; + + if let Some(existing_repo) = existing_repo { + // It doesn't matter whether the UUID of the repo matches or not, + // since it's uniquely generated. But do check the hash. + if existing_repo.sha256 != desc.repo.sha256 { + return Err(err.bail(InsertError::RepoHashMismatch { + system_version: desc.repo.system_version, + uploaded: desc.repo.sha256, + existing: existing_repo.sha256, + })); + } + + // Just return the existing repo along with all of its artifacts. + let artifacts = artifacts_for_repo(existing_repo.id, &conn).await?; + + let recorded = + TufRepoDescription { repo: existing_repo, artifacts }; + return Ok(TufRepoInsertResponse { + recorded, + status: TufRepoInsertStatus::AlreadyExists, + }); + } + + // This will fail if this ID or system version already exists with a + // different hash, but that's a weird situation that should error out + // anyway (IDs are not user controlled, hashes are). + diesel::insert_into(dsl::tuf_repo) + .values(desc.repo.clone()) + .execute_async(&conn) + .await?; + desc.repo.clone() + }; + + // Since we've inserted a new repo, we also need to insert the + // corresponding artifacts. + let all_artifacts = { + use db::schema::tuf_artifact::dsl; + + // Multiple repos can have the same artifacts, so we shouldn't error + // out if we find an existing artifact. However, we should check that + // the SHA256 hash and length matches if an existing artifact matches. + + let mut filter_dsl = dsl::tuf_artifact.into_boxed(); + for artifact in desc.artifacts.clone() { + filter_dsl = filter_dsl.or_filter( + dsl::name + .eq(artifact.id.name) + .and(dsl::version.eq(artifact.id.version)) + .and(dsl::kind.eq(artifact.id.kind)), + ); + } + + let results = filter_dsl + .select(TufArtifact::as_select()) + .load_async(&conn) + .await?; + debug!( + log, + "found {} existing artifacts", results.len(); + "results" => ?results, + ); + + let results_by_id = results + .iter() + .map(|artifact| (&artifact.id, artifact)) + .collect::>(); + + // uploaded_and_existing contains non-matching artifacts in pairs of + // (uploaded, currently in db). + let mut uploaded_and_existing = Vec::new(); + let mut new_artifacts = Vec::new(); + let mut all_artifacts = Vec::new(); + + for uploaded_artifact in desc.artifacts.clone() { + let Some(&existing_artifact) = + results_by_id.get(&uploaded_artifact.id) + else { + // This is a new artifact. + new_artifacts.push(uploaded_artifact.clone()); + all_artifacts.push(uploaded_artifact); + continue; + }; + + if existing_artifact.sha256 != uploaded_artifact.sha256 + || existing_artifact.artifact_size() + != uploaded_artifact.artifact_size() + { + uploaded_and_existing.push(( + uploaded_artifact.clone(), + existing_artifact.clone(), + )); + } else { + all_artifacts.push(uploaded_artifact); + } + } + + if !uploaded_and_existing.is_empty() { + debug!(log, "uploaded artifacts don't match existing artifacts"; + "uploaded_and_existing" => ?uploaded_and_existing, + ); + return Err(err.bail(InsertError::ArtifactMismatch { + uploaded_and_existing, + })); + } + + debug!( + log, + "inserting {} new artifacts", new_artifacts.len(); + "new_artifacts" => ?new_artifacts, + ); + + // Insert new artifacts into the database. + diesel::insert_into(dsl::tuf_artifact) + .values(new_artifacts) + .execute_async(&conn) + .await?; + all_artifacts + }; + + // Finally, insert all the associations into the tuf_repo_artifact table. + { + use db::schema::tuf_repo_artifact::dsl; + + let mut values = Vec::new(); + for artifact in desc.artifacts.clone() { + slog::debug!( + log, + "inserting artifact into tuf_repo_artifact table"; + "artifact" => %artifact.id, + ); + values.push(( + dsl::tuf_repo_id.eq(desc.repo.id), + dsl::tuf_artifact_name.eq(artifact.id.name), + dsl::tuf_artifact_version.eq(artifact.id.version), + dsl::tuf_artifact_kind.eq(artifact.id.kind), + )); + } + + diesel::insert_into(dsl::tuf_repo_artifact) + .values(values) + .execute_async(&conn) + .await?; } - pub async fn update_deployments_list_by_id( - &self, - opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - use db::schema::update_deployment::dsl::*; - - paginated(update_deployment, id, pagparams) - .select(UpdateDeployment::as_select()) - .load_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } + let recorded = TufRepoDescription { repo, artifacts: all_artifacts }; + Ok(TufRepoInsertResponse { + recorded, + status: TufRepoInsertStatus::Inserted, + }) +} - pub async fn latest_update_deployment( - &self, - opctx: &OpContext, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; +#[derive(Clone, Debug)] +enum InsertError { + /// The SHA256 of the uploaded repository doesn't match the SHA256 of the + /// existing repository with the same system version. + RepoHashMismatch { + system_version: SemverVersion, + uploaded: ArtifactHash, + existing: ArtifactHash, + }, + /// The SHA256 or length of one or more artifacts doesn't match the + /// corresponding entries in the database. + ArtifactMismatch { + // Pairs of (uploaded, existing) artifacts. + uploaded_and_existing: Vec<(TufArtifact, TufArtifact)>, + }, +} - use db::schema::update_deployment::dsl::*; +impl From for external::Error { + fn from(e: InsertError) -> Self { + match e { + InsertError::RepoHashMismatch { + system_version, + uploaded, + existing, + } => external::Error::conflict(format!( + "Uploaded repository with system version {} has SHA256 hash \ + {}, but existing repository has SHA256 hash {}.", + system_version, uploaded, existing, + )), + InsertError::ArtifactMismatch { uploaded_and_existing } => { + // Build a message out of uploaded and existing artifacts. + let mut message = "Uploaded artifacts don't match existing \ + artifacts with same IDs:\n" + .to_string(); + for (uploaded, existing) in uploaded_and_existing { + swrite!( + message, + "- Uploaded artifact {} has SHA256 hash {} and length \ + {}, but existing artifact {} has SHA256 hash {} and \ + length {}.\n", + uploaded.id, + uploaded.sha256, + uploaded.artifact_size(), + existing.id, + existing.sha256, + existing.artifact_size(), + ); + } - update_deployment - .select(UpdateDeployment::as_returning()) - .order(time_created.desc()) - .first_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + external::Error::conflict(message) + } + } } } diff --git a/nexus/db-queries/src/db/lookup.rs b/nexus/db-queries/src/db/lookup.rs index 028694dc4b8..1cf14c5a8fc 100644 --- a/nexus/db-queries/src/db/lookup.rs +++ b/nexus/db-queries/src/db/lookup.rs @@ -17,7 +17,6 @@ use async_bb8_diesel::AsyncRunQueryDsl; use db_macros::lookup_resource; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use ipnetwork::IpNetwork; -use nexus_db_model::KnownArtifactKind; use nexus_db_model::Name; use omicron_common::api::external::Error; use omicron_common::api::external::InternalContext; @@ -431,27 +430,27 @@ impl<'a> LookupPath<'a> { ) } + /// Select a resource of type TufRepo, identified by its UUID. + pub fn tuf_repo_id(self, id: Uuid) -> TufRepo<'a> { + TufRepo::PrimaryKey(Root { lookup_root: self }, id) + } + /// Select a resource of type UpdateArtifact, identified by its /// `(name, version, kind)` tuple - pub fn update_artifact_tuple( + pub fn tuf_artifact_tuple( self, - name: &str, + name: impl Into, version: db::model::SemverVersion, - kind: KnownArtifactKind, - ) -> UpdateArtifact<'a> { - UpdateArtifact::PrimaryKey( + kind: impl Into, + ) -> TufArtifact<'a> { + TufArtifact::PrimaryKey( Root { lookup_root: self }, - name.to_string(), + name.into(), version, - kind, + kind.into(), ) } - /// Select a resource of type UpdateDeployment, identified by its id - pub fn update_deployment_id(self, id: Uuid) -> UpdateDeployment<'a> { - UpdateDeployment::PrimaryKey(Root { lookup_root: self }, id) - } - /// Select a resource of type UserBuiltin, identified by its `name` pub fn user_builtin_id<'b>(self, id: Uuid) -> UserBuiltin<'b> where @@ -857,21 +856,10 @@ lookup_resource! { } lookup_resource! { - name = "UpdateArtifact", - ancestors = [], - children = [], - lookup_by_name = false, - soft_deletes = false, - primary_key_columns = [ - { column_name = "name", rust_type = String }, - { column_name = "version", rust_type = db::model::SemverVersion }, - { column_name = "kind", rust_type = KnownArtifactKind } - ] -} - -lookup_resource! { - name = "SystemUpdate", + name = "TufRepo", ancestors = [], + // TODO: should this have TufArtifact as a child? This is a many-many + // relationship. children = [], lookup_by_name = false, soft_deletes = false, @@ -879,12 +867,16 @@ lookup_resource! { } lookup_resource! { - name = "UpdateDeployment", + name = "TufArtifact", ancestors = [], children = [], lookup_by_name = false, soft_deletes = false, - primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] + primary_key_columns = [ + { column_name = "name", rust_type = String }, + { column_name = "version", rust_type = db::model::SemverVersion }, + { column_name = "kind", rust_type = String }, + ] } lookup_resource! { diff --git a/nexus/db-queries/src/db/pool_connection.rs b/nexus/db-queries/src/db/pool_connection.rs index 090c6865b7e..2d572749092 100644 --- a/nexus/db-queries/src/db/pool_connection.rs +++ b/nexus/db-queries/src/db/pool_connection.rs @@ -47,6 +47,7 @@ static CUSTOM_TYPE_KEYS: &'static [&'static str] = &[ "hw_rot_slot", "identity_type", "instance_state", + "ip_attach_state", "ip_kind", "ip_pool_resource_type", "network_interface_kind", @@ -66,9 +67,6 @@ static CUSTOM_TYPE_KEYS: &'static [&'static str] = &[ "switch_link_fec", "switch_link_speed", "switch_port_geometry", - "update_artifact_kind", - "update_status", - "updateable_component_type", "user_provision_type", "vpc_firewall_rule_action", "vpc_firewall_rule_direction", diff --git a/nexus/db-queries/src/db/queries/external_ip.rs b/nexus/db-queries/src/db/queries/external_ip.rs index 49403aac613..8114b9e3632 100644 --- a/nexus/db-queries/src/db/queries/external_ip.rs +++ b/nexus/db-queries/src/db/queries/external_ip.rs @@ -26,10 +26,42 @@ use diesel::Column; use diesel::Expression; use diesel::QueryResult; use diesel::RunQueryDsl; +use nexus_db_model::InstanceState as DbInstanceState; +use nexus_db_model::IpAttachState; +use nexus_db_model::IpAttachStateEnum; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_common::api::external; +use omicron_common::api::external::InstanceState as ApiInstanceState; use uuid::Uuid; +// Broadly, we want users to be able to attach/detach at will +// once an instance is created and functional. +pub const SAFE_TO_ATTACH_INSTANCE_STATES_CREATING: [DbInstanceState; 3] = [ + DbInstanceState(ApiInstanceState::Stopped), + DbInstanceState(ApiInstanceState::Running), + DbInstanceState(ApiInstanceState::Creating), +]; +pub const SAFE_TO_ATTACH_INSTANCE_STATES: [DbInstanceState; 2] = [ + DbInstanceState(ApiInstanceState::Stopped), + DbInstanceState(ApiInstanceState::Running), +]; +// If we're in a state which will naturally resolve to either +// stopped/running, we want users to know that the request can be +// retried safely via Error::unavail. +// TODO: We currently stop if there's a migration or other state change. +// There may be a good case for RPWing +// external_ip_state -> { NAT RPW, sled-agent } in future. +pub const SAFE_TRANSIENT_INSTANCE_STATES: [DbInstanceState; 5] = [ + DbInstanceState(ApiInstanceState::Starting), + DbInstanceState(ApiInstanceState::Stopping), + DbInstanceState(ApiInstanceState::Creating), + DbInstanceState(ApiInstanceState::Rebooting), + DbInstanceState(ApiInstanceState::Migrating), +]; + +/// The maximum number of disks that can be attached to an instance. +pub const MAX_EXTERNAL_IPS_PER_INSTANCE: u32 = 32; + type FromClause = diesel::internal::table_macro::StaticQueryFragmentInstance; type IpPoolRangeFromClause = FromClause; @@ -99,7 +131,8 @@ const MAX_PORT: u16 = u16::MAX; /// candidate_ip AS ip, /// CAST(candidate_first_port AS INT4) AS first_port, /// CAST(candidate_last_port AS INT4) AS last_port, -/// AS project_id +/// AS project_id, +/// AS state /// FROM /// SELECT * FROM ( /// -- Select all IP addresses by pool and range. @@ -378,6 +411,14 @@ impl NextExternalIp { out.push_bind_param::, Option>(self.ip.project_id())?; out.push_sql(" AS "); out.push_identifier(dsl::project_id::NAME)?; + out.push_sql(", "); + + // Initial state, mainly needed by Ephemeral/Floating IPs. + out.push_bind_param::( + self.ip.state(), + )?; + out.push_sql(" AS "); + out.push_identifier(dsl::state::NAME)?; out.push_sql(" FROM ("); self.push_address_sequence_subquery(out.reborrow())?; @@ -822,10 +863,12 @@ impl RunQueryDsl for NextExternalIp {} #[cfg(test)] mod tests { + use crate::authz; use crate::context::OpContext; use crate::db::datastore::DataStore; use crate::db::datastore::SERVICE_IP_POOL_NAME; use crate::db::identity::Resource; + use crate::db::lookup::LookupPath; use crate::db::model::IpKind; use crate::db::model::IpPool; use crate::db::model::IpPoolRange; @@ -833,9 +876,13 @@ mod tests { use async_bb8_diesel::AsyncRunQueryDsl; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use dropshot::test_util::LogContext; + use nexus_db_model::ByteCount; + use nexus_db_model::Instance; + use nexus_db_model::InstanceCpuCount; use nexus_db_model::IpPoolResource; use nexus_db_model::IpPoolResourceType; use nexus_test_utils::db::test_setup_database; + use nexus_types::external_api::params::InstanceCreate; use nexus_types::external_api::shared::IpRange; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_common::api::external::Error; @@ -878,7 +925,7 @@ mod tests { name: &str, range: IpRange, is_default: bool, - ) { + ) -> authz::IpPool { let pool = IpPool::new(&IdentityMetadataCreateParams { name: String::from(name).parse().unwrap(), description: format!("ip pool {}", name), @@ -902,6 +949,13 @@ mod tests { .expect("Failed to associate IP pool with silo"); self.initialize_ip_pool(name, range).await; + + LookupPath::new(&self.opctx, &self.db_datastore) + .ip_pool_id(pool.id()) + .lookup_for(authz::Action::Read) + .await + .unwrap() + .0 } async fn initialize_ip_pool(&self, name: &str, range: IpRange) { @@ -937,6 +991,37 @@ mod tests { .expect("Failed to create IP Pool range"); } + async fn create_instance(&self, name: &str) -> Uuid { + let instance_id = Uuid::new_v4(); + let project_id = Uuid::new_v4(); + let instance = Instance::new(instance_id, project_id, &InstanceCreate { + identity: IdentityMetadataCreateParams { name: String::from(name).parse().unwrap(), description: format!("instance {}", name) }, + ncpus: InstanceCpuCount(omicron_common::api::external::InstanceCpuCount(1)).into(), + memory: ByteCount(omicron_common::api::external::ByteCount::from_gibibytes_u32(1)).into(), + hostname: "test".into(), + user_data: vec![], + network_interfaces: Default::default(), + external_ips: vec![], + disks: vec![], + start: false, + }); + + let conn = self + .db_datastore + .pool_connection_authorized(&self.opctx) + .await + .unwrap(); + + use crate::db::schema::instance::dsl as instance_dsl; + diesel::insert_into(instance_dsl::instance) + .values(instance.clone()) + .execute_async(&*conn) + .await + .expect("Failed to create Instance"); + + instance_id + } + async fn default_pool_id(&self) -> Uuid { let (.., pool) = self .db_datastore @@ -1021,7 +1106,7 @@ mod tests { // Allocate an Ephemeral IP, which should take the entire port range of // the only address in the pool. - let instance_id = Uuid::new_v4(); + let instance_id = context.create_instance("for-eph").await; let ephemeral_ip = context .db_datastore .allocate_instance_ephemeral_ip( @@ -1029,16 +1114,18 @@ mod tests { Uuid::new_v4(), instance_id, /* pool_name = */ None, + true, ) .await - .expect("Failed to allocate Ephemeral IP when there is space"); + .expect("Failed to allocate Ephemeral IP when there is space") + .0; assert_eq!(ephemeral_ip.ip.ip(), range.last_address()); assert_eq!(ephemeral_ip.first_port.0, 0); assert_eq!(ephemeral_ip.last_port.0, super::MAX_PORT); // At this point, we should be able to allocate neither a new Ephemeral // nor any SNAT IPs. - let instance_id = Uuid::new_v4(); + let instance_id = context.create_instance("for-snat").await; let res = context .db_datastore .allocate_instance_snat_ip( @@ -1069,6 +1156,7 @@ mod tests { Uuid::new_v4(), instance_id, /* pool_name = */ None, + true, ) .await; assert!( @@ -1203,7 +1291,7 @@ mod tests { .unwrap(); context.create_ip_pool("default", range, true).await; - let instance_id = Uuid::new_v4(); + let instance_id = context.create_instance("all-the-ports").await; let id = Uuid::new_v4(); let pool_name = None; @@ -1214,9 +1302,11 @@ mod tests { id, instance_id, pool_name, + true, ) .await - .expect("Failed to allocate instance ephemeral IP address"); + .expect("Failed to allocate instance ephemeral IP address") + .0; assert_eq!(ip.kind, IpKind::Ephemeral); assert_eq!(ip.ip.ip(), range.first_address()); assert_eq!(ip.first_port.0, 0); @@ -1729,13 +1819,12 @@ mod tests { Ipv4Addr::new(10, 0, 0, 6), )) .unwrap(); - context.create_ip_pool("p1", second_range, false).await; + let p1 = context.create_ip_pool("p1", second_range, false).await; // Allocating an address on an instance in the second pool should be // respected, even though there are IPs available in the first. - let instance_id = Uuid::new_v4(); + let instance_id = context.create_instance("test").await; let id = Uuid::new_v4(); - let pool_name = Some(Name("p1".parse().unwrap())); let ip = context .db_datastore @@ -1743,10 +1832,12 @@ mod tests { &context.opctx, id, instance_id, - pool_name, + Some(p1), + true, ) .await - .expect("Failed to allocate instance ephemeral IP address"); + .expect("Failed to allocate instance ephemeral IP address") + .0; assert_eq!(ip.kind, IpKind::Ephemeral); assert_eq!(ip.ip.ip(), second_range.first_address()); assert_eq!(ip.first_port.0, 0); @@ -1772,24 +1863,26 @@ mod tests { let last_address = Ipv4Addr::new(10, 0, 0, 6); let second_range = IpRange::try_from((first_address, last_address)).unwrap(); - context.create_ip_pool("p1", second_range, false).await; + let p1 = context.create_ip_pool("p1", second_range, false).await; // Allocate all available addresses in the second pool. - let instance_id = Uuid::new_v4(); - let pool_name = Some(Name("p1".parse().unwrap())); let first_octet = first_address.octets()[3]; let last_octet = last_address.octets()[3]; for octet in first_octet..=last_octet { + let instance_id = + context.create_instance(&format!("o{octet}")).await; let ip = context .db_datastore .allocate_instance_ephemeral_ip( &context.opctx, Uuid::new_v4(), instance_id, - pool_name.clone(), + Some(p1.clone()), + true, ) .await - .expect("Failed to allocate instance ephemeral IP address"); + .expect("Failed to allocate instance ephemeral IP address") + .0; println!("{ip:#?}"); if let IpAddr::V4(addr) = ip.ip.ip() { assert_eq!(addr.octets()[3], octet); @@ -1799,13 +1892,15 @@ mod tests { } // Allocating another address should _fail_, and not use the first pool. + let instance_id = context.create_instance("final").await; context .db_datastore .allocate_instance_ephemeral_ip( &context.opctx, Uuid::new_v4(), instance_id, - pool_name, + Some(p1), + true, ) .await .expect_err("Should not use IP addresses from a different pool"); diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 26cc13fc6ae..ee55d775f03 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -950,7 +950,7 @@ resource: Blueprint id "b9e923f6-caf3-4c83-96f9-8ffe8c627dd2" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! -resource: SystemUpdate id "9c86d713-1bc2-4927-9892-ada3eb6f5f62" +resource: TufRepo id "3c52d72f-cbf7-4951-a62f-a4154e74da87" USER Q R LC RP M MP CC D fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ @@ -964,7 +964,7 @@ resource: SystemUpdate id "9c86d713-1bc2-4927-9892-ada3eb6f5f62" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! -resource: UpdateDeployment id "c617a035-7c42-49ff-a36a-5dfeee382832" +resource: TufArtifact id "a v1.0.0 (b)" USER Q R LC RP M MP CC D fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index 9d6bf2d22f8..f13ea721b84 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -40,9 +40,13 @@ external_dns_servers = [ "1.1.1.1", "9.9.9.9" ] [deployment.dropshot_external] # IP Address and TCP port on which to listen for the external API bind_address = "127.0.0.1:12220" -# Allow larger request bodies (1MiB) to accomodate firewall endpoints (one -# rule is ~500 bytes) -request_body_max_bytes = 1048576 +# Allow large request bodies to support uploading TUF archives. The number here +# is picked based on the typical size for tuf-mupdate.zip as of 2024-01 +# (~1.5GiB) and multiplying it by 2. +# +# This should be brought back down to a more reasonable value once per-endpoint +# request body limits are implemented. +request_body_max_bytes = 3221225472 # To have Nexus's external HTTP endpoint use TLS, uncomment the line below. You # will also need to provide an initial TLS certificate during rack # initialization. If you're using this config file, you're probably running a diff --git a/nexus/src/app/external_ip.rs b/nexus/src/app/external_ip.rs index 404f5972888..45b05fbb0b6 100644 --- a/nexus/src/app/external_ip.rs +++ b/nexus/src/app/external_ip.rs @@ -4,14 +4,18 @@ //! External IP addresses for instances +use std::sync::Arc; + use crate::external_api::views::ExternalIp; use crate::external_api::views::FloatingIp; +use nexus_db_model::IpAttachState; 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 nexus_types::external_api::views; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DeleteResult; @@ -19,6 +23,7 @@ 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::UpdateResult; impl super::Nexus { pub(crate) async fn instance_list_external_ips( @@ -34,7 +39,9 @@ impl super::Nexus { .await? .into_iter() .filter_map(|ip| { - if ip.kind == IpKind::SNat { + if ip.kind == IpKind::SNat + || ip.state != IpAttachState::Attached + { None } else { Some(ip.try_into().unwrap()) @@ -102,9 +109,19 @@ impl super::Nexus { let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; + let pool = match ¶ms.pool { + Some(pool) => Some( + self.ip_pool_lookup(opctx, pool)? + .lookup_for(authz::Action::Read) + .await? + .0, + ), + None => None, + }; + Ok(self .db_datastore - .allocate_floating_ip(opctx, authz_project.id(), params) + .allocate_floating_ip(opctx, authz_project.id(), params, pool) .await? .try_into() .unwrap()) @@ -115,9 +132,68 @@ impl super::Nexus { opctx: &OpContext, ip_lookup: lookup::FloatingIp<'_>, ) -> DeleteResult { + let (.., authz_fip) = + ip_lookup.lookup_for(authz::Action::Delete).await?; + + self.db_datastore.floating_ip_delete(opctx, &authz_fip).await + } + + pub(crate) async fn floating_ip_attach( + self: &Arc, + opctx: &OpContext, + fip_selector: params::FloatingIpSelector, + target: params::FloatingIpAttach, + ) -> UpdateResult { + match target.kind { + params::FloatingIpParentKind::Instance => { + let instance_selector = params::InstanceSelector { + project: fip_selector.project, + instance: target.parent, + }; + let instance = + self.instance_lookup(opctx, instance_selector)?; + let attach_params = ¶ms::ExternalIpCreate::Floating { + floating_ip: fip_selector.floating_ip, + }; + self.instance_attach_external_ip( + opctx, + &instance, + attach_params, + ) + .await + .and_then(FloatingIp::try_from) + } + } + } + + pub(crate) async fn floating_ip_detach( + self: &Arc, + opctx: &OpContext, + ip_lookup: lookup::FloatingIp<'_>, + ) -> UpdateResult { + // XXX: Today, this only happens for instances. + // In future, we will need to separate out by the *type* of + // parent attached to a floating IP. We don't yet store this + // in db for user-facing FIPs (is_service => internal-only + // at this point). let (.., authz_fip, db_fip) = - ip_lookup.fetch_for(authz::Action::Delete).await?; + ip_lookup.fetch_for(authz::Action::Modify).await?; + + let Some(parent_id) = db_fip.parent_id else { + return Ok(db_fip.into()); + }; + + let instance_selector = params::InstanceSelector { + project: None, + instance: parent_id.into(), + }; + let instance = self.instance_lookup(opctx, instance_selector)?; + let attach_params = ¶ms::ExternalIpDetach::Floating { + floating_ip: authz_fip.id().into(), + }; - self.db_datastore.floating_ip_delete(opctx, &authz_fip, &db_fip).await + self.instance_detach_external_ip(opctx, &instance, attach_params) + .await + .and_then(FloatingIp::try_from) } } diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index 957a126c401..42f8e2d6a01 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -18,6 +18,7 @@ use crate::external_api::params; use cancel_safe_futures::prelude::*; use futures::future::Fuse; use futures::{FutureExt, SinkExt, StreamExt}; +use nexus_db_model::IpAttachState; use nexus_db_model::IpKind; use nexus_db_queries::authn; use nexus_db_queries::authz; @@ -27,6 +28,7 @@ use nexus_db_queries::db::datastore::InstanceAndActiveVmm; use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup; use nexus_db_queries::db::lookup::LookupPath; +use nexus_types::external_api::views; use omicron_common::address::PROPOLIS_PORT; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::ByteCount; @@ -1082,6 +1084,15 @@ impl super::Nexus { )); } + // If there are any external IPs not yet fully attached/detached,then + // there are attach/detach sagas in progress. That should complete in + // its own time, so return a 503 to indicate a possible retry. + if external_ips.iter().any(|v| v.state != IpAttachState::Attached) { + return Err(Error::unavail( + "External IP attach/detach is in progress during instance_ensure_registered" + )); + } + // Partition remaining external IPs by class: we can have at most // one ephemeral ip. let (ephemeral_ips, floating_ips): (Vec<_>, Vec<_>) = external_ips @@ -1924,6 +1935,73 @@ impl super::Nexus { Ok(()) } + + /// Attach an external IP to an instance. + pub(crate) async fn instance_attach_external_ip( + self: &Arc, + opctx: &OpContext, + instance_lookup: &lookup::Instance<'_>, + ext_ip: ¶ms::ExternalIpCreate, + ) -> UpdateResult { + let (.., authz_project, authz_instance) = + instance_lookup.lookup_for(authz::Action::Modify).await?; + + let saga_params = sagas::instance_ip_attach::Params { + create_params: ext_ip.clone(), + authz_instance, + project_id: authz_project.id(), + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + }; + + let saga_outputs = self + .execute_saga::( + saga_params, + ) + .await?; + + saga_outputs + .lookup_node_output::("output") + .map_err(|e| Error::internal_error(&format!("{:#}", &e))) + .internal_context("looking up output from ip attach saga") + } + + /// Detach an external IP from an instance. + pub(crate) async fn instance_detach_external_ip( + self: &Arc, + opctx: &OpContext, + instance_lookup: &lookup::Instance<'_>, + ext_ip: ¶ms::ExternalIpDetach, + ) -> UpdateResult { + let (.., authz_project, authz_instance) = + instance_lookup.lookup_for(authz::Action::Modify).await?; + + let saga_params = sagas::instance_ip_detach::Params { + delete_params: ext_ip.clone(), + authz_instance, + project_id: authz_project.id(), + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + }; + + let saga_outputs = self + .execute_saga::( + saga_params, + ) + .await?; + + saga_outputs + .lookup_node_output::>("output") + .map_err(|e| Error::internal_error(&format!("{:#}", &e))) + .internal_context("looking up output from ip detach saga") + .and_then(|eip| { + // Saga idempotency means we'll get Ok(None) on double detach + // of an ephemeral IP. Convert this case to an error here. + eip.ok_or_else(|| { + Error::invalid_request( + "instance does not have an ephemeral IP attached", + ) + }) + }) + } } #[cfg(test)] diff --git a/nexus/src/app/instance_network.rs b/nexus/src/app/instance_network.rs index 8f97642c888..c0bc5d237ba 100644 --- a/nexus/src/app/instance_network.rs +++ b/nexus/src/app/instance_network.rs @@ -7,6 +7,9 @@ use crate::app::sagas::retry_until_known_result; use ipnetwork::IpNetwork; use ipnetwork::Ipv6Network; +use nexus_db_model::ExternalIp; +use nexus_db_model::IpAttachState; +use nexus_db_model::Ipv4NatEntry; use nexus_db_model::Ipv4NatValues; use nexus_db_model::Vni as DbVni; use nexus_db_queries::authz; @@ -24,7 +27,6 @@ use sled_agent_client::types::DeleteVirtualNetworkInterfaceHost; use sled_agent_client::types::SetVirtualNetworkInterfaceHost; use std::collections::HashSet; use std::str::FromStr; -use std::sync::Arc; use uuid::Uuid; impl super::Nexus { @@ -276,6 +278,10 @@ impl super::Nexus { /// Ensures that the Dendrite configuration for the supplied instance is /// up-to-date. /// + /// Returns a list of live NAT RPW table entries from this call. Generally + /// these should only be needed for specific unwind operations, like in + /// the IP attach saga. + /// /// # Parameters /// /// - `opctx`: An operation context that grants read and list-children @@ -283,22 +289,21 @@ impl super::Nexus { /// - `instance_id`: The ID of the instance to act on. /// - `sled_ip_address`: The internal IP address assigned to the sled's /// sled agent. - /// - `ip_index_filter`: An optional filter on the index into the instance's + /// - `ip_filter`: An optional filter on the index into the instance's /// external IP array. - /// - If this is `Some(n)`, this routine configures DPD state for only the - /// Nth external IP in the collection returned from CRDB. The caller is - /// responsible for ensuring that the IP collection has stable indices - /// when making this call. + /// - If this is `Some(id)`, this routine configures DPD state for only the + /// external IP with `id` in the collection returned from CRDB. This will + /// proceed even when the target IP is 'attaching'. /// - If this is `None`, this routine configures DPD for all external - /// IPs. + /// IPs and *will back out* if any IPs are not yet fully attached to + /// the instance. pub(crate) async fn instance_ensure_dpd_config( &self, opctx: &OpContext, instance_id: Uuid, sled_ip_address: &std::net::SocketAddrV6, - ip_index_filter: Option, - dpd_client: &Arc, - ) -> Result<(), Error> { + ip_filter: Option, + ) -> Result, Error> { let log = &self.log; info!(log, "looking up instance's primary network interface"; @@ -309,6 +314,9 @@ impl super::Nexus { .lookup_for(authz::Action::ListChildren) .await?; + // XXX: Need to abstract over v6 and v4 entries here. + let mut nat_entries = vec![]; + // All external IPs map to the primary network interface, so find that // interface. If there is no such interface, there's no way to route // traffic destined to those IPs, so there's nothing to configure and @@ -324,7 +332,7 @@ impl super::Nexus { None => { info!(log, "Instance has no primary network interface"; "instance_id" => %instance_id); - return Ok(()); + return Ok(nat_entries); } }; @@ -344,49 +352,104 @@ impl super::Nexus { .instance_lookup_external_ips(&opctx, instance_id) .await?; - if let Some(wanted_index) = ip_index_filter { - if let None = ips.get(wanted_index) { + let (ips_of_interest, must_all_be_attached) = if let Some(wanted_id) = + ip_filter + { + if let Some(ip) = ips.iter().find(|v| v.id == wanted_id) { + (std::slice::from_ref(ip), false) + } else { return Err(Error::internal_error(&format!( - "failed to find external ip address at index: {}", - wanted_index + "failed to find external ip address with id: {wanted_id}, saw {ips:?}", ))); } + } else { + (&ips[..], true) + }; + + // This is performed so that an IP attach/detach will block the + // instance_start saga. Return service unavailable to indicate + // the request is retryable. + if must_all_be_attached + && ips_of_interest + .iter() + .any(|ip| ip.state != IpAttachState::Attached) + { + return Err(Error::unavail( + "cannot push all DPD state: IP attach/detach in progress", + )); } let sled_address = Ipv6Net(Ipv6Network::new(*sled_ip_address.ip(), 128).unwrap()); - for target_ip in ips - .iter() - .enumerate() - .filter(|(index, _)| { - if let Some(wanted_index) = ip_index_filter { - *index == wanted_index - } else { - true - } - }) - .map(|(_, ip)| ip) - { + // If all of our IPs are attached or are guaranteed to be owned + // by the saga calling this fn, then we need to disregard and + // remove conflicting rows. No other instance/service should be + // using these as its own, and we are dealing with detritus, e.g., + // the case where we have a concurrent stop -> detach followed + // by an attach to another instance, or other ongoing attach saga + // cleanup. + let mut err_and_limit = None; + for (i, external_ip) in ips_of_interest.iter().enumerate() { // For each external ip, add a nat entry to the database - self.ensure_nat_entry( - target_ip, - sled_address, - &network_interface, - mac_address, - opctx, - ) - .await?; + if let Ok(id) = self + .ensure_nat_entry( + external_ip, + sled_address, + &network_interface, + mac_address, + opctx, + ) + .await + { + nat_entries.push(id); + continue; + } + + // We seem to be blocked by a bad row -- take it out and retry. + // This will return Ok() for a non-existent row. + if let Err(e) = self + .external_ip_delete_dpd_config_inner(opctx, external_ip) + .await + { + err_and_limit = Some((e, i)); + break; + }; + + match self + .ensure_nat_entry( + external_ip, + sled_address, + &network_interface, + mac_address, + opctx, + ) + .await + { + Ok(id) => nat_entries.push(id), + Err(e) => { + err_and_limit = Some((e, i)); + break; + } + } } - // Notify dendrite that there are changes for it to reconcile. - // In the event of a failure to notify dendrite, we'll log an error - // and rely on dendrite's RPW timer to catch it up. - if let Err(e) = dpd_client.ipv4_nat_trigger_update().await { - error!(self.log, "failed to notify dendrite of nat updates"; "error" => ?e); - }; + // In the event of an unresolvable failure, we need to remove + // the entries we just added because the undo won't call into + // `instance_delete_dpd_config`. These entries won't stop a + // future caller, but it's better not to pollute switch state. + if let Some((e, max)) = err_and_limit { + for external_ip in &ips_of_interest[..max] { + let _ = self + .external_ip_delete_dpd_config_inner(opctx, external_ip) + .await; + } + return Err(e); + } - Ok(()) + self.notify_dendrite_nat_state(Some(instance_id), true).await?; + + Ok(nat_entries) } async fn ensure_nat_entry( @@ -396,7 +459,7 @@ impl super::Nexus { network_interface: &sled_agent_client::types::NetworkInterface, mac_address: macaddr::MacAddr6, opctx: &OpContext, - ) -> Result<(), Error> { + ) -> Result { match target_ip.ip { IpNetwork::V4(v4net) => { let nat_entry = Ipv4NatValues { @@ -409,9 +472,10 @@ impl super::Nexus { omicron_common::api::external::MacAddr(mac_address), ), }; - self.db_datastore + Ok(self + .db_datastore .ensure_ipv4_nat_entry(opctx, nat_entry) - .await?; + .await?) } IpNetwork::V6(_v6net) => { // TODO: implement handling of v6 nat. @@ -419,13 +483,16 @@ impl super::Nexus { internal_message: "ipv6 nat is not yet implemented".into(), }); } - }; - Ok(()) + } } /// Attempts to delete all of the Dendrite NAT configuration for the /// instance identified by `authz_instance`. /// + /// Unlike `instance_ensure_dpd_config`, this function will disregard the + /// attachment states of any external IPs because likely callers (instance + /// delete) cannot be piecewise undone. + /// /// # Return value /// /// - `Ok(())` if all NAT entries were successfully deleted. @@ -435,6 +502,12 @@ impl super::Nexus { /// - If an operation fails while this routine is walking NAT entries, it /// will continue trying to delete subsequent entries but will return the /// first error it encountered. + /// - `ip_filter`: An optional filter on the index into the instance's + /// external IP array. + /// - If this is `Some(id)`, this routine configures DPD state for only the + /// external IP with `id` in the collection returned from CRDB. + /// - If this is `None`, this routine configures DPD for all external + /// IPs. pub(crate) async fn instance_delete_dpd_config( &self, opctx: &OpContext, @@ -451,37 +524,122 @@ impl super::Nexus { .instance_lookup_external_ips(opctx, instance_id) .await?; - let mut errors = vec![]; for entry in external_ips { - // Soft delete the NAT entry - match self - .db_datastore - .ipv4_nat_delete_by_external_ip(&opctx, &entry) - .await - { - Ok(_) => Ok(()), - Err(err) => match err { - Error::ObjectNotFound { .. } => { - warn!(log, "no matching nat entries to soft delete"); - Ok(()) - } - _ => { - let message = format!( - "failed to delete nat entry due to error: {err:?}" - ); - error!(log, "{}", message); - Err(Error::internal_error(&message)) - } - }, - }?; + self.external_ip_delete_dpd_config_inner(opctx, &entry).await?; } + self.notify_dendrite_nat_state(Some(instance_id), false).await + } + + /// Attempts to delete Dendrite NAT configuration for a single external IP. + /// + /// This function is primarily used to detach an IP which currently belongs + /// to a known instance. + pub(crate) async fn external_ip_delete_dpd_config( + &self, + opctx: &OpContext, + external_ip: &ExternalIp, + ) -> Result<(), Error> { + let log = &self.log; + let instance_id = external_ip.parent_id; + + info!(log, "deleting individual NAT entry from dpd configuration"; + "instance_id" => ?instance_id, + "external_ip" => %external_ip.ip); + + self.external_ip_delete_dpd_config_inner(opctx, external_ip).await?; + + self.notify_dendrite_nat_state(instance_id, false).await + } + + /// Attempts to soft-delete Dendrite NAT configuration for a specific entry + /// via ID. + /// + /// This function is needed to safely cleanup in at least one unwind scenario + /// where a potential second user could need to use the same (IP, portset) pair, + /// e.g. a rapid reattach or a reallocated ephemeral IP. + pub(crate) async fn delete_dpd_config_by_entry( + &self, + opctx: &OpContext, + nat_entry: &Ipv4NatEntry, + ) -> Result<(), Error> { + let log = &self.log; + + info!(log, "deleting individual NAT entry from dpd configuration"; + "id" => ?nat_entry.id, + "version_added" => %nat_entry.external_address.0); + + match self.db_datastore.ipv4_nat_delete(&opctx, nat_entry).await { + Ok(_) => {} + Err(err) => match err { + Error::ObjectNotFound { .. } => { + warn!(log, "no matching nat entries to soft delete"); + } + _ => { + let message = format!( + "failed to delete nat entry due to error: {err:?}" + ); + error!(log, "{}", message); + return Err(Error::internal_error(&message)); + } + }, + } + + self.notify_dendrite_nat_state(None, false).await + } + + /// Soft-delete an individual external IP from the NAT RPW, without + /// triggering a Dendrite notification. + async fn external_ip_delete_dpd_config_inner( + &self, + opctx: &OpContext, + external_ip: &ExternalIp, + ) -> Result<(), Error> { + let log = &self.log; + + // Soft delete the NAT entry + match self + .db_datastore + .ipv4_nat_delete_by_external_ip(&opctx, external_ip) + .await + { + Ok(_) => Ok(()), + Err(err) => match err { + Error::ObjectNotFound { .. } => { + warn!(log, "no matching nat entries to soft delete"); + Ok(()) + } + _ => { + let message = format!( + "failed to delete nat entry due to error: {err:?}" + ); + error!(log, "{}", message); + Err(Error::internal_error(&message)) + } + }, + } + } + + /// Informs all available boundary switches that the set of NAT entries + /// has changed. + /// + /// When `fail_fast` is set, this function will return on any error when + /// acquiring a handle to a DPD client. Otherwise, it will attempt to notify + /// all clients and then finally return the first error. + async fn notify_dendrite_nat_state( + &self, + instance_id: Option, + fail_fast: bool, + ) -> Result<(), Error> { + // Querying boundary switches also requires fleet access and the use of the + // instance allocator context. let boundary_switches = self.boundary_switches(&self.opctx_alloc).await?; + let mut errors = vec![]; for switch in &boundary_switches { debug!(&self.log, "notifying dendrite of updates"; - "instance_id" => %authz_instance.id(), + "instance_id" => ?instance_id, "switch" => switch.to_string()); let client_result = self.dpd_clients.get(switch).ok_or_else(|| { @@ -494,7 +652,11 @@ impl super::Nexus { Ok(client) => client, Err(new_error) => { errors.push(new_error); - continue; + if fail_fast { + break; + } else { + continue; + } } }; @@ -506,7 +668,7 @@ impl super::Nexus { }; } - if let Some(e) = errors.into_iter().nth(0) { + if let Some(e) = errors.into_iter().next() { return Err(e); } @@ -525,58 +687,9 @@ impl super::Nexus { ) -> Result<(), Error> { self.delete_instance_v2p_mappings(opctx, authz_instance.id()).await?; - let external_ips = self - .datastore() - .instance_lookup_external_ips(opctx, authz_instance.id()) - .await?; - - let boundary_switches = self.boundary_switches(opctx).await?; - for external_ip in external_ips { - match self - .db_datastore - .ipv4_nat_delete_by_external_ip(&opctx, &external_ip) - .await - { - Ok(_) => Ok(()), - Err(err) => match err { - Error::ObjectNotFound { .. } => { - warn!( - self.log, - "no matching nat entries to soft delete" - ); - Ok(()) - } - _ => { - let message = format!( - "failed to delete nat entry due to error: {err:?}" - ); - error!(self.log, "{}", message); - Err(Error::internal_error(&message)) - } - }, - }?; - } - - for switch in &boundary_switches { - debug!(&self.log, "notifying dendrite of updates"; - "instance_id" => %authz_instance.id(), - "switch" => switch.to_string()); - - let dpd_client = self.dpd_clients.get(switch).ok_or_else(|| { - Error::internal_error(&format!( - "unable to find dendrite client for {switch}" - )) - })?; + self.instance_delete_dpd_config(opctx, authz_instance).await?; - // Notify dendrite that there are changes for it to reconcile. - // In the event of a failure to notify dendrite, we'll log an error - // and rely on dendrite's RPW timer to catch it up. - if let Err(e) = dpd_client.ipv4_nat_trigger_update().await { - error!(self.log, "failed to notify dendrite of nat updates"; "error" => ?e); - }; - } - - Ok(()) + self.notify_dendrite_nat_state(Some(authz_instance.id()), true).await } /// Given old and new instance runtime states, determines the desired @@ -715,24 +828,13 @@ impl super::Nexus { .fetch() .await?; - let boundary_switches = - self.boundary_switches(&self.opctx_alloc).await?; - - for switch in &boundary_switches { - let dpd_client = self.dpd_clients.get(switch).ok_or_else(|| { - Error::internal_error(&format!( - "could not find dpd client for {switch}" - )) - })?; - self.instance_ensure_dpd_config( - opctx, - instance_id, - &sled.address(), - None, - dpd_client, - ) - .await?; - } + self.instance_ensure_dpd_config( + opctx, + instance_id, + &sled.address(), + None, + ) + .await?; Ok(()) } diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 7975e9fd1c2..5434f31e020 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -88,7 +88,9 @@ pub(crate) const MAX_NICS_PER_INSTANCE: usize = 8; // 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_EXTERNAL_IPS_PER_INSTANCE: usize = + nexus_db_queries::db::queries::external_ip::MAX_EXTERNAL_IPS_PER_INSTANCE + as usize; pub(crate) const MAX_EPHEMERAL_IPS_PER_INSTANCE: usize = 1; pub const MAX_VCPU_PER_INSTANCE: u16 = 64; @@ -141,6 +143,7 @@ pub struct Nexus { timeseries_client: LazyTimeseriesClient, /// Contents of the trusted root role for the TUF repository. + #[allow(dead_code)] updates_config: Option, /// The tunable parameters from a configuration file diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 23ee39415f6..38c7861e469 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -33,7 +33,7 @@ use nexus_types::external_api::params::RouteConfig; use nexus_types::external_api::params::SwitchPortConfigCreate; use nexus_types::external_api::params::UninitializedSledId; use nexus_types::external_api::params::{ - AddressLotCreate, BgpPeerConfig, LoopbackAddressCreate, Route, SiloCreate, + AddressLotCreate, BgpPeerConfig, Route, SiloCreate, SwitchPortSettingsCreate, }; use nexus_types::external_api::shared::Baseboard; @@ -151,9 +151,6 @@ impl super::Nexus { }) .collect(); - // internally ignores ObjectAlreadyExists, so will not error on repeat runs - let _ = self.populate_mock_system_updates(&opctx).await?; - let dns_zone = request .internal_dns_zone_config .zones @@ -375,24 +372,7 @@ impl super::Nexus { let ipv4_block = AddressLotBlockCreate { first_address, last_address }; - let first_address = - IpAddr::from_str("fd00:99::1").map_err(|e| { - Error::internal_error(&format!( - "failed to parse `fd00:99::1` as `IpAddr`: {e}" - )) - })?; - - let last_address = - IpAddr::from_str("fd00:99::ffff").map_err(|e| { - Error::internal_error(&format!( - "failed to parse `fd00:99::ffff` as `IpAddr`: {e}" - )) - })?; - - let ipv6_block = - AddressLotBlockCreate { first_address, last_address }; - - let blocks = vec![ipv4_block, ipv6_block]; + let blocks = vec![ipv4_block]; let address_lot_params = AddressLotCreate { identity, kind, blocks }; @@ -412,24 +392,6 @@ impl super::Nexus { }, }?; - let address_lot_lookup = self - .address_lot_lookup( - &opctx, - NameOrId::Name(address_lot_name.clone()), - ) - .map_err(|e| { - Error::internal_error(&format!( - "unable to lookup infra address_lot: {e}" - )) - })?; - - let (.., authz_address_lot) = address_lot_lookup - .lookup_for(authz::Action::Modify) - .await - .map_err(|e| { - Error::internal_error(&format!("unable to retrieve authz_address_lot for infra address_lot: {e}")) - })?; - let mut bgp_configs = HashMap::new(); for bgp_config in &rack_network_config.bgp { @@ -542,43 +504,6 @@ impl super::Nexus { )) })?; - // TODO: #3603 Use separate address lots for loopback addresses and infra ips - let loopback_address_params = LoopbackAddressCreate { - address_lot: NameOrId::Name(address_lot_name.clone()), - rack_id, - switch_location: switch_location.clone(), - address: first_address, - mask: 64, - anycast: true, - }; - - if self - .loopback_address_lookup( - &opctx, - rack_id, - switch_location.clone().into(), - ipnetwork::IpNetwork::new( - loopback_address_params.address, - loopback_address_params.mask, - ) - .map_err(|_| { - Error::invalid_request("invalid loopback address") - })? - .into(), - )? - .lookup_for(authz::Action::Read) - .await - .is_err() - { - self.db_datastore - .loopback_address_create( - opctx, - &loopback_address_params, - None, - &authz_address_lot, - ) - .await?; - } let uplink_name = format!("default-uplink{idx}"); let name = Name::from_str(&uplink_name).unwrap(); diff --git a/nexus/src/app/sagas/instance_common.rs b/nexus/src/app/sagas/instance_common.rs index 8f9197b03b8..445abd5daf9 100644 --- a/nexus/src/app/sagas/instance_common.rs +++ b/nexus/src/app/sagas/instance_common.rs @@ -8,12 +8,22 @@ use std::net::{IpAddr, Ipv6Addr}; use crate::Nexus; use chrono::Utc; -use nexus_db_model::{ByteCount, SledReservationConstraints, SledResource}; -use nexus_db_queries::{context::OpContext, db, db::DataStore}; +use nexus_db_model::{ + ByteCount, ExternalIp, IpAttachState, Ipv4NatEntry, + SledReservationConstraints, SledResource, +}; +use nexus_db_queries::authz; +use nexus_db_queries::db::lookup::LookupPath; +use nexus_db_queries::db::queries::external_ip::SAFE_TRANSIENT_INSTANCE_STATES; +use nexus_db_queries::{authn, context::OpContext, db, db::DataStore}; +use omicron_common::api::external::Error; use omicron_common::api::external::InstanceState; +use serde::{Deserialize, Serialize}; use steno::ActionError; use uuid::Uuid; +use super::NexusActionContext; + /// Reserves resources for a new VMM whose instance has `ncpus` guest logical /// processors and `guest_memory` bytes of guest RAM. The selected sled is /// random within the set of sleds allowed by the supplied `constraints`. @@ -133,3 +143,325 @@ pub(super) async fn allocate_vmm_ipv6( .await .map_err(ActionError::action_failed) } + +/// External IP state needed for IP attach/detachment. +/// +/// This holds a record of the mid-processing external IP, where possible. +/// there are cases where this might not be known (e.g., double detach of an +/// ephemeral IP). +/// In particular we need to explicitly no-op if not `do_saga`, to prevent +/// failures borne from instance state changes from knocking out a valid IP binding. +#[derive(Debug, Deserialize, Serialize)] +pub struct ModifyStateForExternalIp { + pub external_ip: Option, + pub do_saga: bool, +} + +/// Move an external IP from one state to another as a saga operation, +/// returning `Ok(true)` if the record was successfully moved and `Ok(false)` +/// if the record was lost. +/// +/// Returns `Err` if given an illegal state transition or several rows +/// were updated, which are programmer errors. +pub async fn instance_ip_move_state( + sagactx: &NexusActionContext, + serialized_authn: &authn::saga::Serialized, + from: IpAttachState, + to: IpAttachState, + new_ip: &ModifyStateForExternalIp, +) -> Result { + let osagactx = sagactx.user_data(); + let datastore = osagactx.datastore(); + let opctx = + crate::context::op_context_for_saga_action(&sagactx, serialized_authn); + + if !new_ip.do_saga { + return Ok(true); + } + let Some(new_ip) = new_ip.external_ip.as_ref() else { + return Err(ActionError::action_failed(Error::internal_error( + "tried to `do_saga` without valid external IP", + ))); + }; + + match datastore + .external_ip_complete_op(&opctx, new_ip.id, new_ip.kind, from, to) + .await + .map_err(ActionError::action_failed)? + { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(ActionError::action_failed(Error::internal_error( + "ip state change affected > 1 row", + ))), + } +} + +pub async fn instance_ip_get_instance_state( + sagactx: &NexusActionContext, + serialized_authn: &authn::saga::Serialized, + authz_instance: &authz::Instance, + verb: &str, +) -> Result, ActionError> { + // XXX: we can get instance state (but not sled ID) in same transaction + // as attach (but not detach) wth current design. We need to re-query + // for sled ID anyhow, so keep consistent between attach/detach. + let osagactx = sagactx.user_data(); + let datastore = osagactx.datastore(); + let opctx = + crate::context::op_context_for_saga_action(&sagactx, serialized_authn); + + let inst_and_vmm = datastore + .instance_fetch_with_vmm(&opctx, authz_instance) + .await + .map_err(ActionError::action_failed)?; + + let found_state = inst_and_vmm.instance().runtime_state.nexus_state.0; + let mut sled_id = inst_and_vmm.sled_id(); + + // Arriving here means we started in a correct state (running/stopped). + // We need to consider how we interact with the other sagas/ops: + // - starting: our claim on an IP will block it from moving past + // DPD_ensure and instance_start will undo. If we complete + // before then, it can move past and will fill in routes/opte. + // Act as though we have no sled_id. + // - stopping: this is not sagaized, and the propolis/sled-agent might + // go away. Act as though stopped if we catch it here, + // otherwise convert OPTE ensure to 'service unavailable' + // and undo. + // - deleting: can only be called from stopped -- we won't push to dpd + // or sled-agent, and IP record might be deleted or forcibly + // detached. Catch here just in case. + match found_state { + InstanceState::Stopped + | InstanceState::Starting + | InstanceState::Stopping => { + sled_id = None; + } + InstanceState::Running => {} + state if SAFE_TRANSIENT_INSTANCE_STATES.contains(&state.into()) => { + return Err(ActionError::action_failed(Error::unavail(&format!( + "can't {verb} in transient state {state}" + )))) + } + InstanceState::Destroyed => { + return Err(ActionError::action_failed(Error::not_found_by_id( + omicron_common::api::external::ResourceType::Instance, + &authz_instance.id(), + ))) + } + // Final cases are repairing/failed. + _ => { + return Err(ActionError::action_failed(Error::invalid_request( + "cannot modify instance IPs, instance is in unhealthy state", + ))) + } + } + + Ok(sled_id) +} + +/// Adds a NAT entry to DPD, routing packets bound for `target_ip` to a +/// target sled. +/// +/// This call is a no-op if `sled_uuid` is `None` or the saga is explicitly +/// set to be inactive in event of double attach/detach (`!target_ip.do_saga`). +pub async fn instance_ip_add_nat( + sagactx: &NexusActionContext, + serialized_authn: &authn::saga::Serialized, + authz_instance: &authz::Instance, + sled_uuid: Option, + target_ip: ModifyStateForExternalIp, +) -> Result, ActionError> { + let osagactx = sagactx.user_data(); + let datastore = osagactx.datastore(); + let opctx = + crate::context::op_context_for_saga_action(&sagactx, serialized_authn); + + // No physical sled? Don't push NAT. + let Some(sled_uuid) = sled_uuid else { + return Ok(None); + }; + + if !target_ip.do_saga { + return Ok(None); + } + let Some(target_ip) = target_ip.external_ip else { + return Err(ActionError::action_failed(Error::internal_error( + "tried to `do_saga` without valid external IP", + ))); + }; + + // Querying sleds requires fleet access; use the instance allocator context + // for this. + let (.., sled) = LookupPath::new(&osagactx.nexus().opctx_alloc, &datastore) + .sled_id(sled_uuid) + .fetch() + .await + .map_err(ActionError::action_failed)?; + + osagactx + .nexus() + .instance_ensure_dpd_config( + &opctx, + authz_instance.id(), + &sled.address(), + Some(target_ip.id), + ) + .await + .and_then(|v| { + v.into_iter().next().map(Some).ok_or_else(|| { + Error::internal_error( + "NAT RPW failed to return concrete NAT entry", + ) + }) + }) + .map_err(ActionError::action_failed) +} + +/// Remove a single NAT entry from DPD, dropping packets bound for `target_ip`. +/// +/// This call is a no-op if `sled_uuid` is `None` or the saga is explicitly +/// set to be inactive in event of double attach/detach (`!target_ip.do_saga`). +pub async fn instance_ip_remove_nat( + sagactx: &NexusActionContext, + serialized_authn: &authn::saga::Serialized, + sled_uuid: Option, + target_ip: ModifyStateForExternalIp, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let opctx = + crate::context::op_context_for_saga_action(&sagactx, serialized_authn); + + // No physical sled? Don't push NAT. + if sled_uuid.is_none() { + return Ok(()); + }; + + if !target_ip.do_saga { + return Ok(()); + } + let Some(target_ip) = target_ip.external_ip else { + return Err(ActionError::action_failed(Error::internal_error( + "tried to `do_saga` without valid external IP", + ))); + }; + + osagactx + .nexus() + .external_ip_delete_dpd_config(&opctx, &target_ip) + .await + .map_err(ActionError::action_failed)?; + + Ok(()) +} + +/// Inform the OPTE port for a running instance that it should start +/// sending/receiving traffic on a given IP address. +/// +/// This call is a no-op if `sled_uuid` is `None` or the saga is explicitly +/// set to be inactive in event of double attach/detach (`!target_ip.do_saga`). +pub async fn instance_ip_add_opte( + sagactx: &NexusActionContext, + authz_instance: &authz::Instance, + sled_uuid: Option, + target_ip: ModifyStateForExternalIp, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + + // No physical sled? Don't inform OPTE. + let Some(sled_uuid) = sled_uuid else { + return Ok(()); + }; + + if !target_ip.do_saga { + return Ok(()); + } + let Some(target_ip) = target_ip.external_ip else { + return Err(ActionError::action_failed(Error::internal_error( + "tried to `do_saga` without valid external IP", + ))); + }; + + let sled_agent_body = + target_ip.try_into().map_err(ActionError::action_failed)?; + + osagactx + .nexus() + .sled_client(&sled_uuid) + .await + .map_err(|_| { + ActionError::action_failed(Error::unavail( + "sled agent client went away mid-attach/detach", + )) + })? + .instance_put_external_ip(&authz_instance.id(), &sled_agent_body) + .await + .map_err(|e| { + ActionError::action_failed(match e { + progenitor_client::Error::CommunicationError(_) => { + Error::unavail( + "sled agent client went away mid-attach/detach", + ) + } + e => Error::internal_error(&format!("{e}")), + }) + })?; + + Ok(()) +} + +/// Inform the OPTE port for a running instance that it should cease +/// sending/receiving traffic on a given IP address. +/// +/// This call is a no-op if `sled_uuid` is `None` or the saga is explicitly +/// set to be inactive in event of double attach/detach (`!target_ip.do_saga`). +pub async fn instance_ip_remove_opte( + sagactx: &NexusActionContext, + authz_instance: &authz::Instance, + sled_uuid: Option, + target_ip: ModifyStateForExternalIp, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + + // No physical sled? Don't inform OPTE. + let Some(sled_uuid) = sled_uuid else { + return Ok(()); + }; + + if !target_ip.do_saga { + return Ok(()); + } + let Some(target_ip) = target_ip.external_ip else { + return Err(ActionError::action_failed(Error::internal_error( + "tried to `do_saga` without valid external IP", + ))); + }; + + let sled_agent_body = + target_ip.try_into().map_err(ActionError::action_failed)?; + + osagactx + .nexus() + .sled_client(&sled_uuid) + .await + .map_err(|_| { + ActionError::action_failed(Error::unavail( + "sled agent client went away mid-attach/detach", + )) + })? + .instance_delete_external_ip(&authz_instance.id(), &sled_agent_body) + .await + .map_err(|e| { + ActionError::action_failed(match e { + progenitor_client::Error::CommunicationError(_) => { + Error::unavail( + "sled agent client went away mid-attach/detach", + ) + } + e => Error::internal_error(&format!("{e}")), + }) + })?; + + Ok(()) +} diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index abdffcfe44c..edd3f79238c 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -10,7 +10,7 @@ use crate::app::{ MAX_NICS_PER_INSTANCE, }; use crate::external_api::params; -use nexus_db_model::NetworkInterfaceKind; +use nexus_db_model::{ExternalIp, NetworkInterfaceKind}; use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup::LookupPath; use nexus_db_queries::db::queries::network_interface::InsertError as InsertNicError; @@ -20,8 +20,10 @@ use nexus_types::external_api::params::InstanceDiskAttachment; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::InstanceState; use omicron_common::api::external::Name; +use omicron_common::api::external::NameOrId; use omicron_common::api::external::{Error, InternalContext}; use omicron_common::api::internal::shared::SwitchLocation; +use ref_cast::RefCast; use serde::Deserialize; use serde::Serialize; use slog::warn; @@ -229,7 +231,7 @@ impl NexusSaga for SagaInstanceCreate { SagaName::new(&format!("instance-create-external-ip{i}")); let mut subsaga_builder = DagBuilder::new(subsaga_name); subsaga_builder.append(Node::action( - "output", + format!("external-ip-{i}").as_str(), format!("CreateExternalIp{i}").as_str(), CREATE_EXTERNAL_IP.as_ref(), )); @@ -685,7 +687,7 @@ async fn sic_allocate_instance_snat_ip_undo( /// index `ip_index`, and return its ID if one is created (or None). async fn sic_allocate_instance_external_ip( sagactx: NexusActionContext, -) -> Result<(), ActionError> { +) -> 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(); @@ -695,7 +697,7 @@ async fn sic_allocate_instance_external_ip( let ip_index = repeat_saga_params.which; let Some(ip_params) = saga_params.create_params.external_ips.get(ip_index) else { - return Ok(()); + return Ok(None); }; let opctx = crate::context::op_context_for_saga_action( &sagactx, @@ -703,39 +705,80 @@ async fn sic_allocate_instance_external_ip( ); let instance_id = repeat_saga_params.instance_id; - match ip_params { + // We perform the 'complete_op' in this saga stage because our IPs are + // created in the attaching state, and we need to move them to attached. + // We *can* do so because the `creating` state will block the IP attach/detach + // sagas from running, so we can safely undo in event of later error in this saga + // without worrying they have been detached by another API call. + // Runtime state should never be able to make 'complete_op' fallible. + let ip = match ip_params { // Allocate a new IP address from the target, possibly default, pool - params::ExternalIpCreate::Ephemeral { ref pool_name } => { - let pool_name = - pool_name.as_ref().map(|name| db::model::Name(name.clone())); + params::ExternalIpCreate::Ephemeral { pool } => { + let pool = if let Some(name_or_id) = pool { + Some( + osagactx + .nexus() + .ip_pool_lookup(&opctx, name_or_id) + .map_err(ActionError::action_failed)? + .lookup_for(authz::Action::CreateChild) + .await + .map_err(ActionError::action_failed)? + .0, + ) + } else { + None + }; + let ip_id = repeat_saga_params.new_id; datastore .allocate_instance_ephemeral_ip( &opctx, ip_id, instance_id, - pool_name, + pool, + true, ) .await - .map_err(ActionError::action_failed)?; + .map_err(ActionError::action_failed)? + .0 } // 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)?; + params::ExternalIpCreate::Floating { floating_ip } => { + let (.., authz_fip) = match floating_ip { + NameOrId::Name(name) => LookupPath::new(&opctx, datastore) + .project_id(saga_params.project_id) + .floating_ip_name(db::model::Name::ref_cast(name)), + NameOrId::Id(id) => { + LookupPath::new(&opctx, datastore).floating_ip_id(*id) + } + } + .lookup_for(authz::Action::Modify) + .await + .map_err(ActionError::action_failed)?; datastore - .floating_ip_attach(&opctx, &authz_fip, &db_fip, instance_id) + .floating_ip_begin_attach(&opctx, &authz_fip, instance_id, true) .await - .map_err(ActionError::action_failed)?; + .map_err(ActionError::action_failed)? + .0 } - } - Ok(()) + }; + + // Ignore row count here, this is infallible with correct + // (state, state', kind) but may be zero on repeat call for + // idempotency. + _ = datastore + .external_ip_complete_op( + &opctx, + ip.id, + ip.kind, + nexus_db_model::IpAttachState::Attaching, + nexus_db_model::IpAttachState::Attached, + ) + .await + .map_err(ActionError::action_failed)?; + + Ok(Some(ip)) } async fn sic_allocate_instance_external_ip_undo( @@ -750,6 +793,16 @@ async fn sic_allocate_instance_external_ip_undo( &sagactx, &saga_params.serialized_authn, ); + + // We store and lookup `ExternalIp` so that we can detach + // and/or deallocate without double name resolution. + let new_ip = sagactx + .lookup::>(&format!("external-ip-{ip_index}"))?; + + let Some(ip) = new_ip else { + return Ok(()); + }; + let Some(ip_params) = saga_params.create_params.external_ips.get(ip_index) else { return Ok(()); @@ -757,18 +810,42 @@ async fn sic_allocate_instance_external_ip_undo( match ip_params { params::ExternalIpCreate::Ephemeral { .. } => { - let ip_id = repeat_saga_params.new_id; - datastore.deallocate_external_ip(&opctx, ip_id).await?; + 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) + params::ExternalIpCreate::Floating { .. } => { + let (.., authz_fip) = LookupPath::new(&opctx, &datastore) + .floating_ip_id(ip.id) + .lookup_for(authz::Action::Modify) + .await?; + + datastore + .floating_ip_begin_detach( + &opctx, + &authz_fip, + repeat_saga_params.instance_id, + true, + ) .await?; - datastore.floating_ip_detach(&opctx, &authz_fip, &db_fip).await?; + let n_rows = datastore + .external_ip_complete_op( + &opctx, + ip.id, + ip.kind, + nexus_db_model::IpAttachState::Detaching, + nexus_db_model::IpAttachState::Detached, + ) + .await + .map_err(ActionError::action_failed)?; + + if n_rows != 1 { + error!( + osagactx.log(), + "sic_allocate_instance_external_ip_undo: failed to \ + completely detach ip {}", + ip.id + ); + } } } Ok(()) @@ -1042,7 +1119,7 @@ pub mod test { network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: None, + pool: None, }], disks: vec![params::InstanceDiskAttachment::Attach( params::InstanceDiskAttach { diff --git a/nexus/src/app/sagas/instance_delete.rs b/nexus/src/app/sagas/instance_delete.rs index b43d7db86a5..4717a1e548a 100644 --- a/nexus/src/app/sagas/instance_delete.rs +++ b/nexus/src/app/sagas/instance_delete.rs @@ -241,7 +241,7 @@ mod test { network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: None, + pool: None, }], disks: vec![params::InstanceDiskAttachment::Attach( params::InstanceDiskAttach { name: DISK_NAME.parse().unwrap() }, diff --git a/nexus/src/app/sagas/instance_ip_attach.rs b/nexus/src/app/sagas/instance_ip_attach.rs new file mode 100644 index 00000000000..be7f81368ec --- /dev/null +++ b/nexus/src/app/sagas/instance_ip_attach.rs @@ -0,0 +1,583 @@ +// 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/. + +use super::instance_common::{ + instance_ip_add_nat, instance_ip_add_opte, instance_ip_get_instance_state, + instance_ip_move_state, instance_ip_remove_opte, ModifyStateForExternalIp, +}; +use super::{ActionRegistry, NexusActionContext, NexusSaga}; +use crate::app::sagas::declare_saga_actions; +use crate::app::{authn, authz, db}; +use crate::external_api::params; +use nexus_db_model::{IpAttachState, Ipv4NatEntry}; +use nexus_db_queries::db::lookup::LookupPath; +use nexus_types::external_api::views; +use omicron_common::api::external::{Error, NameOrId}; +use ref_cast::RefCast; +use serde::Deserialize; +use serde::Serialize; +use steno::ActionError; +use uuid::Uuid; + +// The IP attach/detach sagas do some resource locking -- because we +// allow them to be called in [Running, Stopped], they must contend +// with each other/themselves, instance start, instance delete, and +// the instance stop action (noting the latter is not a saga). +// +// The main means of access control here is an external IP's `state`. +// Entering either saga begins with an atomic swap from Attached/Detached +// to Attaching/Detaching. This prevents concurrent attach/detach on the +// same EIP, and prevents instance start and migrate from completing with an +// Error::unavail via instance_ensure_registered and/or DPD. +// +// Overlap with stop is handled by treating comms failures with +// sled-agent as temporary errors and unwinding. For the delete case, we +// allow the detach completion to have a missing record -- both instance delete +// and detach will leave NAT in the correct state. For attach, if we make it +// to completion and an IP is `detached`, we unwind as a precaution. +// See `instance_common::instance_ip_get_instance_state` for more info. +// +// One more consequence of sled state being able to change beneath us +// is that the central undo actions (DPD/OPTE state) *must* be best-effort. +// This is not bad per-se: instance stop does not itself remove NAT routing +// rules. The only reason these should fail is because an instance has stopped, +// or DPD has died. + +declare_saga_actions! { + instance_ip_attach; + ATTACH_EXTERNAL_IP -> "target_ip" { + + siia_begin_attach_ip + - siia_begin_attach_ip_undo + } + + INSTANCE_STATE -> "instance_state" { + + siia_get_instance_state + } + + REGISTER_NAT -> "nat_entry" { + + siia_nat + - siia_nat_undo + } + + ENSURE_OPTE_PORT -> "no_result1" { + + siia_update_opte + - siia_update_opte_undo + } + + COMPLETE_ATTACH -> "output" { + + siia_complete_attach + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Params { + pub create_params: params::ExternalIpCreate, + pub authz_instance: authz::Instance, + pub project_id: Uuid, + /// Authentication context to use to fetch the instance's current state from + /// the database. + pub serialized_authn: authn::saga::Serialized, +} + +async fn siia_begin_attach_ip( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + let datastore = osagactx.datastore(); + let params = sagactx.saga_params::()?; + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + match ¶ms.create_params { + // Allocate a new IP address from the target, possibly default, pool + params::ExternalIpCreate::Ephemeral { pool } => { + let pool = if let Some(name_or_id) = pool { + Some( + osagactx + .nexus() + .ip_pool_lookup(&opctx, name_or_id) + .map_err(ActionError::action_failed)? + .lookup_for(authz::Action::CreateChild) + .await + .map_err(ActionError::action_failed)? + .0, + ) + } else { + None + }; + + datastore + .allocate_instance_ephemeral_ip( + &opctx, + Uuid::new_v4(), + params.authz_instance.id(), + pool, + false, + ) + .await + .map_err(ActionError::action_failed) + .map(|(external_ip, do_saga)| ModifyStateForExternalIp { + external_ip: Some(external_ip), + do_saga, + }) + } + // Set the parent of an existing floating IP to the new instance's ID. + params::ExternalIpCreate::Floating { floating_ip } => { + let (.., authz_fip) = match floating_ip { + NameOrId::Name(name) => LookupPath::new(&opctx, datastore) + .project_id(params.project_id) + .floating_ip_name(db::model::Name::ref_cast(name)), + NameOrId::Id(id) => { + LookupPath::new(&opctx, datastore).floating_ip_id(*id) + } + } + .lookup_for(authz::Action::Modify) + .await + .map_err(ActionError::action_failed)?; + + datastore + .floating_ip_begin_attach( + &opctx, + &authz_fip, + params.authz_instance.id(), + false, + ) + .await + .map_err(ActionError::action_failed) + .map(|(external_ip, do_saga)| ModifyStateForExternalIp { + external_ip: Some(external_ip), + do_saga, + }) + } + } +} + +async fn siia_begin_attach_ip_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + warn!(log, "siia_begin_attach_ip_undo: Reverting detached->attaching"); + let params = sagactx.saga_params::()?; + let new_ip = sagactx.lookup::("target_ip")?; + if !instance_ip_move_state( + &sagactx, + ¶ms.serialized_authn, + IpAttachState::Attaching, + IpAttachState::Detached, + &new_ip, + ) + .await? + { + error!(log, "siia_begin_attach_ip_undo: external IP was deleted") + } + + Ok(()) +} + +async fn siia_get_instance_state( + sagactx: NexusActionContext, +) -> Result, ActionError> { + let params = sagactx.saga_params::()?; + instance_ip_get_instance_state( + &sagactx, + ¶ms.serialized_authn, + ¶ms.authz_instance, + "attach", + ) + .await +} + +// XXX: Need to abstract over v4 and v6 NAT entries when the time comes. +async fn siia_nat( + sagactx: NexusActionContext, +) -> Result, ActionError> { + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + instance_ip_add_nat( + &sagactx, + ¶ms.serialized_authn, + ¶ms.authz_instance, + sled_id, + target_ip, + ) + .await +} + +async fn siia_nat_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let nat_entry = sagactx.lookup::>("nat_entry")?; + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + let Some(nat_entry) = nat_entry else { + // Seeing `None` here means that we never pushed DPD state in + // the first instance. Nothing to undo. + return Ok(()); + }; + + // This requires some explanation in one case, where we can fail because an + // instance may have moved running -> stopped -> deleted. + // An instance delete will cause us to unwind and return to this stage *but* + // the ExternalIp will no longer have a useful parent (or even a + // different parent!). + // + // Internally, we delete the NAT entry *without* checking its instance state because + // it may either be `None`, or another instance may have attached. The + // first case is fine, but we need to consider NAT RPW semantics for the second: + // * The NAT entry table will ensure uniqueness on (external IP, low_port, + // high_port) for non-deleted rows. + // * Instance start and IP attach on a running instance will try to insert such + // a row, fail, and then delete this row before moving forwards. + // - Until either side deletes the row, we're polluting switch NAT. + // - We can't guarantee quick reuse to remove this rule via attach. + // - This will lead to a *new* NAT entry we need to protect, so we need to be careful + // that we only remove *our* incarnation. This is likelier to be hit + // if an ephemeral IP is deallocated, reallocated, and reused in a short timeframe. + // * Instance create will successfully set parent, since it won't attempt to ensure + // DPD has correct NAT state unless set to `start: true`. + // So it is safe/necessary to remove using the old entry here to target the + // exact row we created.. + + if let Err(e) = osagactx + .nexus() + .delete_dpd_config_by_entry(&opctx, &nat_entry) + .await + .map_err(ActionError::action_failed) + { + error!(log, "siia_nat_undo: failed to notify DPD: {e}"); + } + + Ok(()) +} + +async fn siia_update_opte( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + instance_ip_add_opte(&sagactx, ¶ms.authz_instance, sled_id, target_ip) + .await +} + +async fn siia_update_opte_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + if let Err(e) = instance_ip_remove_opte( + &sagactx, + ¶ms.authz_instance, + sled_id, + target_ip, + ) + .await + { + error!(log, "siia_update_opte_undo: failed to notify sled-agent: {e}"); + } + Ok(()) +} + +async fn siia_complete_attach( + sagactx: NexusActionContext, +) -> Result { + let log = sagactx.user_data().log(); + let params = sagactx.saga_params::()?; + let target_ip = sagactx.lookup::("target_ip")?; + + // There is a clause in `external_ip_complete_op` which specifically + // causes an unwind here if the instance delete saga fires and an IP is either + // detached or deleted. + if !instance_ip_move_state( + &sagactx, + ¶ms.serialized_authn, + IpAttachState::Attaching, + IpAttachState::Attached, + &target_ip, + ) + .await? + { + warn!(log, "siia_complete_attach: call was idempotent") + } + + target_ip + .external_ip + .ok_or_else(|| { + Error::internal_error( + "must always have a defined external IP during instance attach", + ) + }) + .and_then(TryInto::try_into) + .map_err(ActionError::action_failed) +} + +#[derive(Debug)] +pub struct SagaInstanceIpAttach; +impl NexusSaga for SagaInstanceIpAttach { + const NAME: &'static str = "external-ip-attach"; + type Params = Params; + + fn register_actions(registry: &mut ActionRegistry) { + instance_ip_attach_register_actions(registry); + } + + fn make_saga_dag( + _params: &Self::Params, + mut builder: steno::DagBuilder, + ) -> Result { + builder.append(attach_external_ip_action()); + builder.append(instance_state_action()); + builder.append(register_nat_action()); + builder.append(ensure_opte_port_action()); + builder.append(complete_attach_action()); + Ok(builder.build()?) + } +} + +#[cfg(test)] +pub(crate) mod test { + use super::*; + use crate::app::{saga::create_saga_dag, sagas::test_helpers}; + use async_bb8_diesel::AsyncRunQueryDsl; + use diesel::{ + ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper, + }; + use dropshot::test_util::ClientTestContext; + use nexus_db_model::{ExternalIp, IpKind}; + use nexus_db_queries::context::OpContext; + use nexus_test_utils::resource_helpers::{ + create_default_ip_pool, create_floating_ip, create_instance, + create_project, + }; + use nexus_test_utils_macros::nexus_test; + use omicron_common::api::external::{Name, SimpleIdentity}; + + type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + + const PROJECT_NAME: &str = "cafe"; + const INSTANCE_NAME: &str = "menu"; + const FIP_NAME: &str = "affogato"; + + pub async fn ip_manip_test_setup(client: &ClientTestContext) -> Uuid { + create_default_ip_pool(&client).await; + let project = create_project(client, PROJECT_NAME).await; + create_floating_ip( + client, + FIP_NAME, + &project.identity.id.to_string(), + None, + None, + ) + .await; + + project.id() + } + + pub async fn new_test_params( + opctx: &OpContext, + datastore: &db::DataStore, + use_floating: bool, + ) -> Params { + let create_params = if use_floating { + params::ExternalIpCreate::Floating { + floating_ip: FIP_NAME.parse::().unwrap().into(), + } + } else { + params::ExternalIpCreate::Ephemeral { pool: None } + }; + + let (.., authz_project, authz_instance) = + LookupPath::new(opctx, datastore) + .project_name(&db::model::Name(PROJECT_NAME.parse().unwrap())) + .instance_name(&db::model::Name(INSTANCE_NAME.parse().unwrap())) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + project_id: authz_project.id(), + create_params, + authz_instance, + } + } + + #[nexus_test(server = crate::Server)] + async fn test_saga_basic_usage_succeeds( + cptestctx: &ControlPlaneTestContext, + ) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + let sled_agent = &cptestctx.sled_agent.sled_agent; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + for use_float in [false, true] { + let params = new_test_params(&opctx, datastore, use_float).await; + + let dag = create_saga_dag::(params).unwrap(); + let saga = nexus.create_runnable_saga(dag).await.unwrap(); + nexus.run_saga(saga).await.expect("Attach saga should succeed"); + } + + let instance_id = instance.id(); + + // Sled agent has a record of the new external IPs. + let mut eips = sled_agent.external_ips.lock().await; + let my_eips = eips.entry(instance_id).or_default(); + assert!(my_eips.iter().any(|v| matches!( + v, + omicron_sled_agent::params::InstanceExternalIpBody::Floating(_) + ))); + assert!(my_eips.iter().any(|v| matches!( + v, + omicron_sled_agent::params::InstanceExternalIpBody::Ephemeral(_) + ))); + + // DB has records for SNAT plus the new IPs. + let db_eips = datastore + .instance_lookup_external_ips(&opctx, instance_id) + .await + .unwrap(); + assert_eq!(db_eips.len(), 3); + assert!(db_eips.iter().any(|v| v.kind == IpKind::Ephemeral)); + assert!(db_eips.iter().any(|v| v.kind == IpKind::Floating)); + assert!(db_eips.iter().any(|v| v.kind == IpKind::SNat)); + } + + pub(crate) async fn verify_clean_slate( + cptestctx: &ControlPlaneTestContext, + instance_id: Uuid, + ) { + use nexus_db_queries::db::schema::external_ip::dsl; + + let sled_agent = &cptestctx.sled_agent.sled_agent; + let datastore = cptestctx.server.apictx().nexus.datastore(); + + let conn = datastore.pool_connection_for_tests().await.unwrap(); + + // No Floating IPs exist in states other than 'detached'. + assert!(dsl::external_ip + .filter(dsl::kind.eq(IpKind::Floating)) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.eq(instance_id)) + .filter(dsl::state.ne(IpAttachState::Detached)) + .select(ExternalIp::as_select()) + .first_async::(&*conn) + .await + .optional() + .unwrap() + .is_none()); + + // All ephemeral IPs are removed. + assert!(dsl::external_ip + .filter(dsl::kind.eq(IpKind::Ephemeral)) + .filter(dsl::time_deleted.is_null()) + .select(ExternalIp::as_select()) + .first_async::(&*conn) + .await + .optional() + .unwrap() + .is_none()); + + // No IP bindings remain on sled-agent. + let mut eips = sled_agent.external_ips.lock().await; + let my_eips = eips.entry(instance_id).or_default(); + assert!(my_eips.is_empty()); + } + + #[nexus_test(server = crate::Server)] + async fn test_action_failure_can_unwind( + cptestctx: &ControlPlaneTestContext, + ) { + let log = &cptestctx.logctx.log; + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + for use_float in [false, true] { + test_helpers::action_failure_can_unwind::( + nexus, + || Box::pin(new_test_params(&opctx, datastore, use_float) ), + || Box::pin(verify_clean_slate(&cptestctx, instance.id())), + log, + ) + .await; + } + } + + #[nexus_test(server = crate::Server)] + async fn test_action_failure_can_unwind_idempotently( + cptestctx: &ControlPlaneTestContext, + ) { + let log = &cptestctx.logctx.log; + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + for use_float in [false, true] { + test_helpers::action_failure_can_unwind_idempotently::< + SagaInstanceIpAttach, + _, + _, + >( + nexus, + || Box::pin(new_test_params(&opctx, datastore, use_float)), + || Box::pin(verify_clean_slate(&cptestctx, instance.id())), + log, + ) + .await; + } + } + + #[nexus_test(server = crate::Server)] + async fn test_actions_succeed_idempotently( + cptestctx: &ControlPlaneTestContext, + ) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let _instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + for use_float in [false, true] { + let params = new_test_params(&opctx, datastore, use_float).await; + let dag = create_saga_dag::(params).unwrap(); + test_helpers::actions_succeed_idempotently(nexus, dag).await; + } + } +} diff --git a/nexus/src/app/sagas/instance_ip_detach.rs b/nexus/src/app/sagas/instance_ip_detach.rs new file mode 100644 index 00000000000..da6c92077d9 --- /dev/null +++ b/nexus/src/app/sagas/instance_ip_detach.rs @@ -0,0 +1,551 @@ +// 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/. + +use super::instance_common::{ + instance_ip_add_nat, instance_ip_add_opte, instance_ip_get_instance_state, + instance_ip_move_state, instance_ip_remove_nat, instance_ip_remove_opte, + ModifyStateForExternalIp, +}; +use super::{ActionRegistry, NexusActionContext, NexusSaga}; +use crate::app::sagas::declare_saga_actions; +use crate::app::{authn, authz, db}; +use crate::external_api::params; +use nexus_db_model::IpAttachState; +use nexus_db_queries::db::lookup::LookupPath; +use nexus_types::external_api::views; +use omicron_common::api::external::NameOrId; +use ref_cast::RefCast; +use serde::Deserialize; +use serde::Serialize; +use steno::ActionError; +use uuid::Uuid; + +// This runs on similar logic to instance IP attach: see its head +// comment for an explanation of the structure wrt. other sagas. + +declare_saga_actions! { + instance_ip_detach; + DETACH_EXTERNAL_IP -> "target_ip" { + + siid_begin_detach_ip + - siid_begin_detach_ip_undo + } + + INSTANCE_STATE -> "instance_state" { + + siid_get_instance_state + } + + REMOVE_NAT -> "no_result0" { + + siid_nat + - siid_nat_undo + } + + REMOVE_OPTE_PORT -> "no_result1" { + + siid_update_opte + - siid_update_opte_undo + } + + COMPLETE_DETACH -> "output" { + + siid_complete_detach + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Params { + pub delete_params: params::ExternalIpDetach, + pub authz_instance: authz::Instance, + pub project_id: Uuid, + /// Authentication context to use to fetch the instance's current state from + /// the database. + pub serialized_authn: authn::saga::Serialized, +} + +async fn siid_begin_detach_ip( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + let datastore = osagactx.datastore(); + let params = sagactx.saga_params::()?; + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + match ¶ms.delete_params { + params::ExternalIpDetach::Ephemeral => { + let eip = datastore + .instance_lookup_ephemeral_ip( + &opctx, + params.authz_instance.id(), + ) + .await + .map_err(ActionError::action_failed)?; + + if let Some(eph_ip) = eip { + datastore + .begin_deallocate_ephemeral_ip( + &opctx, + eph_ip.id, + params.authz_instance.id(), + ) + .await + .map_err(ActionError::action_failed) + .map(|external_ip| ModifyStateForExternalIp { + do_saga: external_ip.is_some(), + external_ip, + }) + } else { + Ok(ModifyStateForExternalIp { + do_saga: false, + external_ip: None, + }) + } + } + params::ExternalIpDetach::Floating { floating_ip } => { + let (.., authz_fip) = match floating_ip { + NameOrId::Name(name) => LookupPath::new(&opctx, datastore) + .project_id(params.project_id) + .floating_ip_name(db::model::Name::ref_cast(name)), + NameOrId::Id(id) => { + LookupPath::new(&opctx, datastore).floating_ip_id(*id) + } + } + .lookup_for(authz::Action::Modify) + .await + .map_err(ActionError::action_failed)?; + + datastore + .floating_ip_begin_detach( + &opctx, + &authz_fip, + params.authz_instance.id(), + false, + ) + .await + .map_err(ActionError::action_failed) + .map(|(external_ip, do_saga)| ModifyStateForExternalIp { + external_ip: Some(external_ip), + do_saga, + }) + } + } +} + +async fn siid_begin_detach_ip_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + warn!(log, "siid_begin_detach_ip_undo: Reverting attached->detaching"); + let params = sagactx.saga_params::()?; + let new_ip = sagactx.lookup::("target_ip")?; + if !instance_ip_move_state( + &sagactx, + ¶ms.serialized_authn, + IpAttachState::Detaching, + IpAttachState::Attached, + &new_ip, + ) + .await? + { + error!(log, "siid_begin_detach_ip_undo: external IP was deleted") + } + + Ok(()) +} + +async fn siid_get_instance_state( + sagactx: NexusActionContext, +) -> Result, ActionError> { + let params = sagactx.saga_params::()?; + instance_ip_get_instance_state( + &sagactx, + ¶ms.serialized_authn, + ¶ms.authz_instance, + "detach", + ) + .await +} + +async fn siid_nat(sagactx: NexusActionContext) -> Result<(), ActionError> { + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + instance_ip_remove_nat( + &sagactx, + ¶ms.serialized_authn, + sled_id, + target_ip, + ) + .await +} + +async fn siid_nat_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + if let Err(e) = instance_ip_add_nat( + &sagactx, + ¶ms.serialized_authn, + ¶ms.authz_instance, + sled_id, + target_ip, + ) + .await + { + error!(log, "siid_nat_undo: failed to notify DPD: {e}"); + } + + Ok(()) +} + +async fn siid_update_opte( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + instance_ip_remove_opte( + &sagactx, + ¶ms.authz_instance, + sled_id, + target_ip, + ) + .await +} + +async fn siid_update_opte_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let log = sagactx.user_data().log(); + let params = sagactx.saga_params::()?; + let sled_id = sagactx.lookup::>("instance_state")?; + let target_ip = sagactx.lookup::("target_ip")?; + if let Err(e) = instance_ip_add_opte( + &sagactx, + ¶ms.authz_instance, + sled_id, + target_ip, + ) + .await + { + error!(log, "siid_update_opte_undo: failed to notify sled-agent: {e}"); + } + Ok(()) +} + +async fn siid_complete_detach( + sagactx: NexusActionContext, +) -> Result, ActionError> { + let log = sagactx.user_data().log(); + let params = sagactx.saga_params::()?; + let target_ip = sagactx.lookup::("target_ip")?; + + if !instance_ip_move_state( + &sagactx, + ¶ms.serialized_authn, + IpAttachState::Detaching, + IpAttachState::Detached, + &target_ip, + ) + .await? + { + warn!( + log, + "siid_complete_detach: external IP was deleted or call was idempotent" + ) + } + + target_ip + .external_ip + .map(TryInto::try_into) + .transpose() + .map_err(ActionError::action_failed) +} + +#[derive(Debug)] +pub struct SagaInstanceIpDetach; +impl NexusSaga for SagaInstanceIpDetach { + const NAME: &'static str = "external-ip-detach"; + type Params = Params; + + fn register_actions(registry: &mut ActionRegistry) { + instance_ip_detach_register_actions(registry); + } + + fn make_saga_dag( + _params: &Self::Params, + mut builder: steno::DagBuilder, + ) -> Result { + builder.append(detach_external_ip_action()); + builder.append(instance_state_action()); + builder.append(remove_nat_action()); + builder.append(remove_opte_port_action()); + builder.append(complete_detach_action()); + Ok(builder.build()?) + } +} + +#[cfg(test)] +pub(crate) mod test { + use super::*; + use crate::{ + app::{ + saga::create_saga_dag, + sagas::{ + instance_ip_attach::{self, test::ip_manip_test_setup}, + test_helpers, + }, + }, + Nexus, + }; + use async_bb8_diesel::AsyncRunQueryDsl; + use diesel::{ + ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper, + }; + use nexus_db_model::{ExternalIp, IpKind}; + use nexus_db_queries::context::OpContext; + use nexus_test_utils::resource_helpers::create_instance; + use nexus_test_utils_macros::nexus_test; + use omicron_common::api::external::{Name, SimpleIdentity}; + use std::sync::Arc; + + type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + + const PROJECT_NAME: &str = "cafe"; + const INSTANCE_NAME: &str = "menu"; + const FIP_NAME: &str = "affogato"; + + async fn new_test_params( + opctx: &OpContext, + datastore: &db::DataStore, + use_floating: bool, + ) -> Params { + let delete_params = if use_floating { + params::ExternalIpDetach::Floating { + floating_ip: FIP_NAME.parse::().unwrap().into(), + } + } else { + params::ExternalIpDetach::Ephemeral + }; + + let (.., authz_project, authz_instance) = + LookupPath::new(opctx, datastore) + .project_name(&db::model::Name(PROJECT_NAME.parse().unwrap())) + .instance_name(&db::model::Name(INSTANCE_NAME.parse().unwrap())) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + project_id: authz_project.id(), + delete_params, + authz_instance, + } + } + + async fn attach_instance_ips(nexus: &Arc, opctx: &OpContext) { + let datastore = &nexus.db_datastore; + + let proj_name = db::model::Name(PROJECT_NAME.parse().unwrap()); + let inst_name = db::model::Name(INSTANCE_NAME.parse().unwrap()); + let lookup = LookupPath::new(opctx, datastore) + .project_name(&proj_name) + .instance_name(&inst_name); + + for use_float in [false, true] { + let params = instance_ip_attach::test::new_test_params( + opctx, datastore, use_float, + ) + .await; + nexus + .instance_attach_external_ip( + opctx, + &lookup, + ¶ms.create_params, + ) + .await + .unwrap(); + } + } + + #[nexus_test(server = crate::Server)] + async fn test_saga_basic_usage_succeeds( + cptestctx: &ControlPlaneTestContext, + ) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + let sled_agent = &cptestctx.sled_agent.sled_agent; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _ = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + attach_instance_ips(nexus, &opctx).await; + + for use_float in [false, true] { + let params = new_test_params(&opctx, datastore, use_float).await; + + let dag = create_saga_dag::(params).unwrap(); + let saga = nexus.create_runnable_saga(dag).await.unwrap(); + nexus.run_saga(saga).await.expect("Detach saga should succeed"); + } + + let instance_id = instance.id(); + + // Sled agent has removed its records of the external IPs. + let mut eips = sled_agent.external_ips.lock().await; + let my_eips = eips.entry(instance_id).or_default(); + assert!(my_eips.is_empty()); + + // DB only has record for SNAT. + let db_eips = datastore + .instance_lookup_external_ips(&opctx, instance_id) + .await + .unwrap(); + assert_eq!(db_eips.len(), 1); + assert!(db_eips.iter().any(|v| v.kind == IpKind::SNat)); + } + + pub(crate) async fn verify_clean_slate( + cptestctx: &ControlPlaneTestContext, + instance_id: Uuid, + ) { + use nexus_db_queries::db::schema::external_ip::dsl; + + let opctx = test_helpers::test_opctx(cptestctx); + let sled_agent = &cptestctx.sled_agent.sled_agent; + let datastore = cptestctx.server.apictx().nexus.datastore(); + + let conn = datastore.pool_connection_for_tests().await.unwrap(); + + // No IPs in transitional states w/ current instance. + assert!(dsl::external_ip + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.eq(instance_id)) + .filter(dsl::state.ne(IpAttachState::Attached)) + .select(ExternalIp::as_select()) + .first_async::(&*conn) + .await + .optional() + .unwrap() + .is_none()); + + // No external IPs in detached state. + assert!(dsl::external_ip + .filter(dsl::time_deleted.is_null()) + .filter(dsl::state.eq(IpAttachState::Detached)) + .select(ExternalIp::as_select()) + .first_async::(&*conn) + .await + .optional() + .unwrap() + .is_none()); + + // Instance still has one Ephemeral IP, and one Floating IP. + let db_eips = datastore + .instance_lookup_external_ips(&opctx, instance_id) + .await + .unwrap(); + assert_eq!(db_eips.len(), 3); + assert!(db_eips.iter().any(|v| v.kind == IpKind::Ephemeral)); + assert!(db_eips.iter().any(|v| v.kind == IpKind::Floating)); + assert!(db_eips.iter().any(|v| v.kind == IpKind::SNat)); + + // No IP bindings remain on sled-agent. + let eips = &*sled_agent.external_ips.lock().await; + for (_nic_id, eip_set) in eips { + assert_eq!(eip_set.len(), 2); + } + } + + #[nexus_test(server = crate::Server)] + async fn test_action_failure_can_unwind( + cptestctx: &ControlPlaneTestContext, + ) { + let log = &cptestctx.logctx.log; + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + attach_instance_ips(nexus, &opctx).await; + + for use_float in [false, true] { + test_helpers::action_failure_can_unwind::( + nexus, + || Box::pin(new_test_params(&opctx, datastore, use_float) ), + || Box::pin(verify_clean_slate(&cptestctx, instance.id())), + log, + ) + .await; + } + } + + #[nexus_test(server = crate::Server)] + async fn test_action_failure_can_unwind_idempotently( + cptestctx: &ControlPlaneTestContext, + ) { + let log = &cptestctx.logctx.log; + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + attach_instance_ips(nexus, &opctx).await; + + for use_float in [false, true] { + test_helpers::action_failure_can_unwind_idempotently::< + SagaInstanceIpDetach, + _, + _, + >( + nexus, + || Box::pin(new_test_params(&opctx, datastore, use_float)), + || Box::pin(verify_clean_slate(&cptestctx, instance.id())), + log, + ) + .await; + } + } + + #[nexus_test(server = crate::Server)] + async fn test_actions_succeed_idempotently( + cptestctx: &ControlPlaneTestContext, + ) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + let opctx = test_helpers::test_opctx(cptestctx); + let datastore = &nexus.db_datastore; + let _project_id = ip_manip_test_setup(&client).await; + let _instance = + create_instance(client, PROJECT_NAME, INSTANCE_NAME).await; + + attach_instance_ips(nexus, &opctx).await; + + for use_float in [false, true] { + let params = new_test_params(&opctx, datastore, use_float).await; + let dag = create_saga_dag::(params).unwrap(); + test_helpers::actions_succeed_idempotently(nexus, dag).await; + } + } +} diff --git a/nexus/src/app/sagas/instance_start.rs b/nexus/src/app/sagas/instance_start.rs index a1e7a976993..157a000e37e 100644 --- a/nexus/src/app/sagas/instance_start.rs +++ b/nexus/src/app/sagas/instance_start.rs @@ -405,35 +405,12 @@ async fn sis_dpd_ensure( .await .map_err(ActionError::action_failed)?; - // Querying boundary switches also requires fleet access and the use of the - // instance allocator context. - let boundary_switches = osagactx + osagactx .nexus() - .boundary_switches(&osagactx.nexus().opctx_alloc) + .instance_ensure_dpd_config(&opctx, instance_id, &sled.address(), None) .await .map_err(ActionError::action_failed)?; - for switch in boundary_switches { - let dpd_client = - osagactx.nexus().dpd_clients.get(&switch).ok_or_else(|| { - ActionError::action_failed(Error::internal_error(&format!( - "unable to find client for switch {switch}" - ))) - })?; - - osagactx - .nexus() - .instance_ensure_dpd_config( - &opctx, - instance_id, - &sled.address(), - None, - dpd_client, - ) - .await - .map_err(ActionError::action_failed)?; - } - Ok(()) } diff --git a/nexus/src/app/sagas/mod.rs b/nexus/src/app/sagas/mod.rs index c5918d32ef7..1bd85ecf32e 100644 --- a/nexus/src/app/sagas/mod.rs +++ b/nexus/src/app/sagas/mod.rs @@ -26,6 +26,8 @@ pub mod image_delete; mod instance_common; pub mod instance_create; pub mod instance_delete; +pub mod instance_ip_attach; +pub mod instance_ip_detach; pub mod instance_migrate; pub mod instance_start; pub mod loopback_address_create; @@ -130,6 +132,12 @@ fn make_action_registry() -> ActionRegistry { ::register_actions( &mut registry, ); + ::register_actions( + &mut registry, + ); + ::register_actions( + &mut registry, + ); ::register_actions( &mut registry, ); diff --git a/nexus/src/app/sagas/switch_port_settings_apply.rs b/nexus/src/app/sagas/switch_port_settings_apply.rs index 0d6bb52421e..9d0573f6b06 100644 --- a/nexus/src/app/sagas/switch_port_settings_apply.rs +++ b/nexus/src/app/sagas/switch_port_settings_apply.rs @@ -15,6 +15,10 @@ use crate::app::sagas::{ use anyhow::Error; use db::datastore::SwitchPortSettingsCombinedResult; use dpd_client::types::PortId; +use mg_admin_client::types::{ + AddStaticRoute4Request, DeleteStaticRoute4Request, Prefix4, StaticRoute4, + StaticRoute4List, +}; use nexus_db_model::NETWORK_KEY; use nexus_db_queries::db::datastore::UpdatePrecondition; use nexus_db_queries::{authn, db}; @@ -52,6 +56,10 @@ declare_saga_actions! { + spa_ensure_switch_port_settings - spa_undo_ensure_switch_port_settings } + ENSURE_SWITCH_ROUTES -> "ensure_switch_routes" { + + spa_ensure_switch_routes + - spa_undo_ensure_switch_routes + } ENSURE_SWITCH_PORT_UPLINK -> "ensure_switch_port_uplink" { + spa_ensure_switch_port_uplink - spa_undo_ensure_switch_port_uplink @@ -210,6 +218,82 @@ async fn spa_ensure_switch_port_settings( Ok(()) } +async fn spa_ensure_switch_routes( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let params = sagactx.saga_params::()?; + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + let settings = sagactx + .lookup::("switch_port_settings")?; + + let mut rq = AddStaticRoute4Request { + routes: StaticRoute4List { list: Vec::new() }, + }; + for r in settings.routes { + let nexthop = match r.gw.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => continue, + }; + let prefix = match r.gw.ip() { + IpAddr::V4(v4) => Prefix4 { value: v4, length: r.gw.prefix() }, + IpAddr::V6(_) => continue, + }; + let sr = StaticRoute4 { nexthop, prefix }; + rq.routes.list.push(sr); + } + + let mg_client: Arc = + select_mg_client(&sagactx, &opctx, params.switch_port_id).await?; + + mg_client.inner.static_add_v4_route(&rq).await.map_err(|e| { + ActionError::action_failed(format!("mgd static route add {e}")) + })?; + + Ok(()) +} + +async fn spa_undo_ensure_switch_routes( + sagactx: NexusActionContext, +) -> Result<(), Error> { + let params = sagactx.saga_params::()?; + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + let settings = sagactx + .lookup::("switch_port_settings")?; + + let mut rq = DeleteStaticRoute4Request { + routes: StaticRoute4List { list: Vec::new() }, + }; + + for r in settings.routes { + let nexthop = match r.gw.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => continue, + }; + let prefix = match r.gw.ip() { + IpAddr::V4(v4) => Prefix4 { value: v4, length: r.gw.prefix() }, + IpAddr::V6(_) => continue, + }; + let sr = StaticRoute4 { nexthop, prefix }; + rq.routes.list.push(sr); + } + + let mg_client: Arc = + select_mg_client(&sagactx, &opctx, params.switch_port_id).await?; + + mg_client.inner.static_remove_v4_route(&rq).await.map_err(|e| { + ActionError::action_failed(format!("mgd static route remove {e}")) + })?; + + Ok(()) +} + async fn spa_undo_ensure_switch_port_settings( sagactx: NexusActionContext, ) -> Result<(), Error> { @@ -223,7 +307,7 @@ async fn spa_undo_ensure_switch_port_settings( let log = sagactx.user_data().log(); let port_id: PortId = PortId::from_str(¶ms.switch_port_name) - .map_err(|e| external::Error::internal_error(e))?; + .map_err(|e| external::Error::internal_error(e.to_string().as_str()))?; let orig_port_settings_id = sagactx .lookup::>("original_switch_port_settings_id") diff --git a/nexus/src/app/sagas/switch_port_settings_clear.rs b/nexus/src/app/sagas/switch_port_settings_clear.rs index 0d876f8159f..15290dd75bf 100644 --- a/nexus/src/app/sagas/switch_port_settings_clear.rs +++ b/nexus/src/app/sagas/switch_port_settings_clear.rs @@ -15,12 +15,16 @@ use crate::app::sagas::{ }; use anyhow::Error; use dpd_client::types::PortId; -use mg_admin_client::types::DeleteNeighborRequest; +use mg_admin_client::types::{ + AddStaticRoute4Request, DeleteNeighborRequest, DeleteStaticRoute4Request, + Prefix4, StaticRoute4, StaticRoute4List, +}; use nexus_db_model::NETWORK_KEY; use nexus_db_queries::authn; use nexus_db_queries::db::datastore::UpdatePrecondition; use omicron_common::api::external::{self, NameOrId, SwitchLocation}; use serde::{Deserialize, Serialize}; +use std::net::IpAddr; use std::str::FromStr; use std::sync::Arc; use steno::ActionError; @@ -43,6 +47,10 @@ declare_saga_actions! { + spa_clear_switch_port_settings - spa_undo_clear_switch_port_settings } + CLEAR_SWITCH_PORT_ROUTES -> "clear_switch_port_routes" { + + spa_clear_switch_port_routes + - spa_undo_clear_switch_port_routes + } CLEAR_SWITCH_PORT_UPLINK -> "clear_switch_port_uplink" { + spa_clear_switch_port_uplink - spa_undo_clear_switch_port_uplink @@ -179,7 +187,7 @@ async fn spa_undo_clear_switch_port_settings( let log = sagactx.user_data().log(); let port_id: PortId = PortId::from_str(¶ms.port_name) - .map_err(|e| external::Error::internal_error(e))?; + .map_err(|e| external::Error::internal_error(e.to_string().as_str()))?; let orig_port_settings_id = sagactx .lookup::>("original_switch_port_settings_id") @@ -351,6 +359,108 @@ async fn spa_undo_clear_switch_port_bgp_settings( .await?) } +async fn spa_clear_switch_port_routes( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let nexus = osagactx.nexus(); + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + let orig_port_settings_id = + sagactx.lookup::>("original_switch_port_settings_id")?; + + let id = match orig_port_settings_id { + Some(id) => id, + None => return Ok(()), + }; + + let settings = nexus + .switch_port_settings_get(&opctx, &NameOrId::Id(id)) + .await + .map_err(ActionError::action_failed)?; + + let mut rq = DeleteStaticRoute4Request { + routes: StaticRoute4List { list: Vec::new() }, + }; + + for r in settings.routes { + let nexthop = match r.gw.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => continue, + }; + let prefix = match r.gw.ip() { + IpAddr::V4(v4) => Prefix4 { value: v4, length: r.gw.prefix() }, + IpAddr::V6(_) => continue, + }; + let sr = StaticRoute4 { nexthop, prefix }; + rq.routes.list.push(sr); + } + + let mg_client: Arc = + select_mg_client(&sagactx, &opctx, params.switch_port_id).await?; + + mg_client.inner.static_remove_v4_route(&rq).await.map_err(|e| { + ActionError::action_failed(format!("mgd static route remove {e}")) + })?; + + Ok(()) +} + +async fn spa_undo_clear_switch_port_routes( + sagactx: NexusActionContext, +) -> Result<(), Error> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let nexus = osagactx.nexus(); + let opctx = crate::context::op_context_for_saga_action( + &sagactx, + ¶ms.serialized_authn, + ); + + let orig_port_settings_id = + sagactx.lookup::>("original_switch_port_settings_id")?; + + let id = match orig_port_settings_id { + Some(id) => id, + None => return Ok(()), + }; + + let settings = nexus + .switch_port_settings_get(&opctx, &NameOrId::Id(id)) + .await + .map_err(ActionError::action_failed)?; + + let mut rq = AddStaticRoute4Request { + routes: StaticRoute4List { list: Vec::new() }, + }; + + for r in settings.routes { + let nexthop = match r.gw.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => continue, + }; + let prefix = match r.gw.ip() { + IpAddr::V4(v4) => Prefix4 { value: v4, length: r.gw.prefix() }, + IpAddr::V6(_) => continue, + }; + let sr = StaticRoute4 { nexthop, prefix }; + rq.routes.list.push(sr); + } + + let mg_client: Arc = + select_mg_client(&sagactx, &opctx, params.switch_port_id).await?; + + mg_client.inner.static_add_v4_route(&rq).await.map_err(|e| { + ActionError::action_failed(format!("mgd static route remove {e}")) + })?; + + Ok(()) +} + async fn spa_clear_switch_port_bootstore_network_settings( sagactx: NexusActionContext, ) -> Result<(), ActionError> { diff --git a/nexus/src/app/sagas/switch_port_settings_common.rs b/nexus/src/app/sagas/switch_port_settings_common.rs index 9ef23ebf44c..9c710d837dd 100644 --- a/nexus/src/app/sagas/switch_port_settings_common.rs +++ b/nexus/src/app/sagas/switch_port_settings_common.rs @@ -1,12 +1,14 @@ +// 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/. + use super::NexusActionContext; use crate::app::map_switch_zone_addrs; use crate::Nexus; use db::datastore::SwitchPortSettingsCombinedResult; use dpd_client::types::{ LinkCreate, LinkId, LinkSettings, PortFec, PortSettings, PortSpeed, - RouteSettingsV4, RouteSettingsV6, }; -use dpd_client::{Ipv4Cidr, Ipv6Cidr}; use internal_dns::ServiceName; use ipnetwork::IpNetwork; use mg_admin_client::types::Prefix4; @@ -85,41 +87,6 @@ pub(crate) fn api_to_dpd_port_settings( ); } - for r in &settings.routes { - match &r.dst { - IpNetwork::V4(n) => { - let gw = match r.gw.ip() { - IpAddr::V4(gw) => gw, - IpAddr::V6(_) => { - return Err( - "IPv4 destination cannot have IPv6 nexthop".into() - ) - } - }; - dpd_port_settings.v4_routes.insert( - Ipv4Cidr { prefix: n.ip(), prefix_len: n.prefix() } - .to_string(), - vec![RouteSettingsV4 { link_id: link_id.0, nexthop: gw }], - ); - } - IpNetwork::V6(n) => { - let gw = match r.gw.ip() { - IpAddr::V6(gw) => gw, - IpAddr::V4(_) => { - return Err( - "IPv6 destination cannot have IPv4 nexthop".into() - ) - } - }; - dpd_port_settings.v6_routes.insert( - Ipv6Cidr { prefix: n.ip(), prefix_len: n.prefix() } - .to_string(), - vec![RouteSettingsV6 { link_id: link_id.0, nexthop: gw }], - ); - } - } - } - Ok(dpd_port_settings) } diff --git a/nexus/src/app/update/mod.rs b/nexus/src/app/update/mod.rs index 36d4dbcb9ef..d4a47375bc1 100644 --- a/nexus/src/app/update/mod.rs +++ b/nexus/src/app/update/mod.rs @@ -4,27 +4,17 @@ //! Software Updates -use chrono::Utc; -use hex; +use bytes::Bytes; +use dropshot::HttpError; +use futures::Stream; +use nexus_db_model::TufRepoDescription; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; -use nexus_db_queries::db; -use nexus_db_queries::db::identity::Asset; -use nexus_db_queries::db::lookup::LookupPath; -use nexus_db_queries::db::model::KnownArtifactKind; -use nexus_types::external_api::{params, shared}; use omicron_common::api::external::{ - self, CreateResult, DataPageParams, Error, ListResultVec, LookupResult, - PaginationOrder, UpdateResult, + Error, SemverVersion, TufRepoInsertResponse, }; -use omicron_common::api::internal::nexus::UpdateArtifactId; -use rand::Rng; -use ring::digest; -use std::convert::TryFrom; -use std::num::NonZeroU32; -use std::path::Path; -use tokio::io::AsyncWriteExt; -use uuid::Uuid; +use omicron_common::update::ArtifactId; +use update_common::artifacts::ArtifactsWithPlan; mod common_sp_update; mod host_phase1_updater; @@ -47,927 +37,70 @@ pub enum UpdateProgress { Failed(String), } -static BASE_ARTIFACT_DIR: &str = "/var/tmp/oxide_artifacts"; - impl super::Nexus { - async fn tuf_base_url( + pub(crate) async fn updates_put_repository( &self, opctx: &OpContext, - ) -> Result, Error> { - let rack = self.rack_lookup(opctx, &self.rack_id).await?; - - Ok(self.updates_config.as_ref().map(|c| { - rack.tuf_base_url.unwrap_or_else(|| c.default_base_url.clone()) - })) - } - - pub(crate) async fn updates_refresh_metadata( - &self, - opctx: &OpContext, - ) -> Result<(), Error> { + body: impl Stream> + Send + Sync + 'static, + file_name: String, + ) -> Result { opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; - let updates_config = self.updates_config.as_ref().ok_or_else(|| { - Error::invalid_request("updates system not configured") - })?; - let base_url = self.tuf_base_url(opctx).await?.ok_or_else(|| { - Error::invalid_request("updates system not configured") - })?; - let trusted_root = tokio::fs::read(&updates_config.trusted_root) - .await - .map_err(|e| Error::InternalError { - internal_message: format!( - "error trying to read trusted root: {}", - e - ), + // XXX: this needs to validate against the trusted root! + let _updates_config = + self.updates_config.as_ref().ok_or_else(|| { + Error::internal_error("updates system not initialized") })?; - let artifacts = crate::updates::read_artifacts(&trusted_root, base_url) - .await - .map_err(|e| Error::InternalError { - internal_message: format!( - "error trying to refresh updates: {}", - e - ), - })?; - - // FIXME: if we hit an error in any of these database calls, the - // available artifact table will be out of sync with the current - // artifacts.json. can we do a transaction or something? + let artifacts_with_plan = + ArtifactsWithPlan::from_stream(body, Some(file_name), &self.log) + .await + .map_err(|error| error.to_http_error())?; - let mut current_version = None; - for artifact in &artifacts { - current_version = Some(artifact.targets_role_version); - self.db_datastore - .update_artifact_upsert(&opctx, artifact.clone()) - .await?; - } - - // ensure table is in sync with current copy of artifacts.json - if let Some(current_version) = current_version { - self.db_datastore - .update_artifact_hard_delete_outdated(&opctx, current_version) - .await?; - } - - // demo-grade update logic: tell all sleds to apply all artifacts - for sled in self - .db_datastore - .sled_list( - &opctx, - &DataPageParams { - marker: None, - direction: PaginationOrder::Ascending, - limit: NonZeroU32::new(100).unwrap(), - }, - ) - .await? - { - let client = self.sled_client(&sled.id()).await?; - for artifact in &artifacts { - info!( - self.log, - "telling sled {} to apply {}", - sled.id(), - artifact.target_name - ); - client - .update_artifact( - &sled_agent_client::types::UpdateArtifactId { - name: artifact.name.clone(), - version: artifact.version.0.clone().into(), - kind: artifact.kind.0.into(), - }, - ) - .await?; - } - } - - Ok(()) - } - - /// Downloads a file from within [`BASE_ARTIFACT_DIR`]. - pub(crate) async fn download_artifact( - &self, - opctx: &OpContext, - artifact: UpdateArtifactId, - ) -> Result, Error> { - let mut base_url = - self.tuf_base_url(opctx).await?.ok_or_else(|| { - Error::invalid_request("updates system not configured") - })?; - if !base_url.ends_with('/') { - base_url.push('/'); - } - - // We cache the artifact based on its checksum, so fetch that from the - // database. - let (.., artifact_entry) = LookupPath::new(opctx, &self.db_datastore) - .update_artifact_tuple( - &artifact.name, - db::model::SemverVersion(artifact.version.clone()), - KnownArtifactKind(artifact.kind), - ) - .fetch() - .await?; - let filename = format!( - "{}.{}.{}-{}", - artifact_entry.target_sha256, - artifact.kind, - artifact.name, - artifact.version + // Now store the artifacts in the database. + let tuf_repo_description = TufRepoDescription::from_external( + artifacts_with_plan.description().clone(), ); - let path = Path::new(BASE_ARTIFACT_DIR).join(&filename); - - if !path.exists() { - // If the artifact doesn't exist, we should download it. - // - // TODO: There also exists the question of "when should we *remove* - // things from BASE_ARTIFACT_DIR", which we should also resolve. - // Demo-quality solution could be "destroy it on boot" or something? - // (we aren't doing that yet). - info!(self.log, "Accessing {} - needs to be downloaded", filename); - tokio::fs::create_dir_all(BASE_ARTIFACT_DIR).await.map_err( - |e| { - Error::internal_error(&format!( - "Failed to create artifacts directory: {}", - e - )) - }, - )?; - - let mut response = reqwest::get(format!( - "{}targets/{}.{}", - base_url, - artifact_entry.target_sha256, - artifact_entry.target_name - )) - .await - .map_err(|e| { - Error::internal_error(&format!( - "Failed to fetch artifact: {}", - e - )) - })?; - // To ensure another request isn't trying to use this target while we're downloading it - // or before we've verified it, write to a random path in the same directory, then move - // it to the correct path after verification. - let temp_path = path.with_file_name(format!( - ".{}.{:x}", - filename, - rand::thread_rng().gen::() - )); - let mut file = - tokio::fs::File::create(&temp_path).await.map_err(|e| { - Error::internal_error(&format!( - "Failed to create file: {}", - e - )) - })?; - - let mut context = digest::Context::new(&digest::SHA256); - let mut length: i64 = 0; - while let Some(chunk) = response.chunk().await.map_err(|e| { - Error::internal_error(&format!( - "Failed to read HTTP body: {}", - e - )) - })? { - file.write_all(&chunk).await.map_err(|e| { - Error::internal_error(&format!( - "Failed to write to file: {}", - e - )) - })?; - context.update(&chunk); - length += i64::try_from(chunk.len()).unwrap(); - - if length > artifact_entry.target_length { - return Err(Error::internal_error(&format!( - "target {} is larger than expected", - artifact_entry.target_name - ))); - } - } - drop(file); - - if hex::encode(context.finish()) == artifact_entry.target_sha256 - && length == artifact_entry.target_length - { - tokio::fs::rename(temp_path, &path).await.map_err(|e| { - Error::internal_error(&format!( - "Failed to rename file after verification: {}", - e - )) - })? - } else { - return Err(Error::internal_error(&format!( - "failed to verify target {}", - artifact_entry.target_name - ))); - } - - info!( - self.log, - "wrote {} to artifact dir", artifact_entry.target_name - ); - } else { - info!(self.log, "Accessing {} - already exists", path.display()); - } - - // TODO: These artifacts could be quite large - we should figure out how to - // stream this file back instead of holding it entirely in-memory in a - // Vec. - // - // Options: - // - RFC 7233 - "Range Requests" (is this HTTP/1.1 only?) - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests - // - "Roll our own". See: - // https://stackoverflow.com/questions/20969331/standard-method-for-http-partial-upload-resume-upload - let body = tokio::fs::read(&path).await.map_err(|e| { - Error::internal_error(&format!( - "Cannot read artifact from filesystem: {}", - e - )) - })?; - Ok(body) - } - - pub async fn upsert_system_update( - &self, - opctx: &OpContext, - create_update: params::SystemUpdateCreate, - ) -> CreateResult { - let update = db::model::SystemUpdate::new(create_update.version)?; - self.db_datastore.upsert_system_update(opctx, update).await - } - - pub async fn create_component_update( - &self, - opctx: &OpContext, - create_update: params::ComponentUpdateCreate, - ) -> CreateResult { - let now = Utc::now(); - let update = db::model::ComponentUpdate { - identity: db::model::ComponentUpdateIdentity { - id: Uuid::new_v4(), - time_created: now, - time_modified: now, - }, - version: db::model::SemverVersion(create_update.version), - component_type: create_update.component_type.into(), - }; - - self.db_datastore - .create_component_update( - opctx, - create_update.system_update_id, - update, - ) - .await - } - - pub(crate) async fn system_update_fetch_by_version( - &self, - opctx: &OpContext, - version: &external::SemverVersion, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - self.db_datastore - .system_update_fetch_by_version(opctx, version.clone().into()) + let response = self + .db_datastore + .update_tuf_repo_insert(opctx, tuf_repo_description) .await + .map_err(HttpError::from)?; + Ok(response.into_external()) } - pub(crate) async fn system_updates_list_by_id( + pub(crate) async fn updates_get_repository( &self, opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - self.db_datastore.system_updates_list_by_id(opctx, pagparams).await - } + system_version: SemverVersion, + ) -> Result { + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - pub(crate) async fn system_update_list_components( - &self, - opctx: &OpContext, - version: &external::SemverVersion, - ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let _updates_config = + self.updates_config.as_ref().ok_or_else(|| { + Error::internal_error("updates system not initialized") + })?; - let system_update = self + let tuf_repo_description = self .db_datastore - .system_update_fetch_by_version(opctx, version.clone().into()) - .await?; - - self.db_datastore - .system_update_components_list(opctx, system_update.id()) + .update_tuf_repo_get(opctx, system_version.into()) .await - } - - pub async fn create_updateable_component( - &self, - opctx: &OpContext, - create_component: params::UpdateableComponentCreate, - ) -> CreateResult { - let component = - db::model::UpdateableComponent::try_from(create_component)?; - self.db_datastore.create_updateable_component(opctx, component).await - } - - pub(crate) async fn updateable_components_list_by_id( - &self, - opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - self.db_datastore - .updateable_components_list_by_id(opctx, pagparams) - .await - } - - pub(crate) async fn create_update_deployment( - &self, - opctx: &OpContext, - start: params::SystemUpdateStart, - ) -> CreateResult { - // 404 if specified version doesn't exist - // TODO: is 404 the right error for starting an update with a nonexistent version? - self.system_update_fetch_by_version(opctx, &start.version).await?; - - // We only need to look at the latest deployment because it's the only - // one that could be running - - let latest_deployment = self.latest_update_deployment(opctx).await; - if let Ok(dep) = latest_deployment { - if dep.status == db::model::UpdateStatus::Updating { - // TODO: should "already updating" conflict be a new kind of error? - return Err(Error::ObjectAlreadyExists { - type_name: external::ResourceType::UpdateDeployment, - object_name: dep.id().to_string(), - }); - } - } - - let deployment = db::model::UpdateDeployment { - identity: db::model::UpdateDeploymentIdentity::new(Uuid::new_v4()), - version: db::model::SemverVersion(start.version), - status: db::model::UpdateStatus::Updating, - }; - self.db_datastore.create_update_deployment(opctx, deployment).await - } - - /// If there's a running update, change it to steady. Otherwise do nothing. - // TODO: codify the state machine around update deployments - pub(crate) async fn steady_update_deployment( - &self, - opctx: &OpContext, - ) -> UpdateResult { - let latest = self.latest_update_deployment(opctx).await?; - // already steady. do nothing in order to avoid updating `time_modified` - if latest.status == db::model::UpdateStatus::Steady { - return Ok(latest); - } - - self.db_datastore.steady_update_deployment(opctx, latest.id()).await - } - - pub(crate) async fn update_deployments_list_by_id( - &self, - opctx: &OpContext, - pagparams: &DataPageParams<'_, Uuid>, - ) -> ListResultVec { - self.db_datastore.update_deployments_list_by_id(opctx, pagparams).await - } + .map_err(HttpError::from)?; - pub(crate) async fn update_deployment_fetch_by_id( - &self, - opctx: &OpContext, - deployment_id: &Uuid, - ) -> LookupResult { - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - let (.., db_deployment) = LookupPath::new(opctx, &self.db_datastore) - .update_deployment_id(*deployment_id) - .fetch() - .await?; - Ok(db_deployment) + Ok(tuf_repo_description) } - pub(crate) async fn latest_update_deployment( + /// Downloads a file (currently not implemented). + pub(crate) async fn updates_download_artifact( &self, - opctx: &OpContext, - ) -> LookupResult { - self.db_datastore.latest_update_deployment(opctx).await - } - - pub(crate) async fn lowest_component_system_version( - &self, - opctx: &OpContext, - ) -> LookupResult { - self.db_datastore.lowest_component_system_version(opctx).await - } - - pub(crate) async fn highest_component_system_version( - &self, - opctx: &OpContext, - ) -> LookupResult { - self.db_datastore.highest_component_system_version(opctx).await - } - - /// Inner function makes it easier to implement the logic where we ignore - /// ObjectAlreadyExists errors but let the others pass through - async fn populate_mock_system_updates_inner( - &self, - opctx: &OpContext, - ) -> CreateResult<()> { - let types = vec![ - shared::UpdateableComponentType::HubrisForPscRot, - shared::UpdateableComponentType::HubrisForPscSp, - shared::UpdateableComponentType::HubrisForSidecarRot, - shared::UpdateableComponentType::HubrisForSidecarSp, - shared::UpdateableComponentType::HubrisForGimletRot, - shared::UpdateableComponentType::HubrisForGimletSp, - shared::UpdateableComponentType::HeliosHostPhase1, - shared::UpdateableComponentType::HeliosHostPhase2, - shared::UpdateableComponentType::HostOmicron, - ]; - - // create system updates and associated component updates - for v in [1, 2, 3] { - let version = external::SemverVersion::new(v, 0, 0); - let su = self - .upsert_system_update( - opctx, - params::SystemUpdateCreate { version: version.clone() }, - ) - .await?; - - for component_type in types.clone() { - self.create_component_update( - &opctx, - params::ComponentUpdateCreate { - version: external::SemverVersion::new(1, v, 0), - system_update_id: su.identity.id, - component_type, - }, - ) - .await?; - } - } - - // create deployment for v1.0.0, stop it, then create one for v2.0.0. - // This makes plausible the state of the components: all v1 except for one v2 - self.create_update_deployment( - &opctx, - params::SystemUpdateStart { - version: external::SemverVersion::new(1, 0, 0), - }, - ) - .await?; - self.steady_update_deployment(opctx).await?; - - self.create_update_deployment( - &opctx, - params::SystemUpdateStart { - version: external::SemverVersion::new(2, 0, 0), - }, - ) - .await?; - - // now create components, with one component on a different system - // version from the others - - for (i, component_type) in types.iter().enumerate() { - let version = if i == 0 { - external::SemverVersion::new(1, 2, 0) - } else { - external::SemverVersion::new(1, 1, 0) - }; - - let system_version = if i == 0 { - external::SemverVersion::new(2, 0, 0) - } else { - external::SemverVersion::new(1, 0, 0) - }; - - self.create_updateable_component( - opctx, - params::UpdateableComponentCreate { - version, - system_version, - device_id: "a-device".to_string(), - component_type: component_type.clone(), - }, - ) - .await?; - } - - Ok(()) - } - - /// Populate the DB with update-related data. Data is hard-coded until we - /// figure out how to pull it from the TUF repo. - /// - /// We need this to be idempotent because it can be called arbitrarily many - /// times. The service functions we call to create these resources will - /// error on ID or version conflicts, so to remain idempotent we can simply - /// ignore those errors. We let other errors through. - pub(crate) async fn populate_mock_system_updates( - &self, - opctx: &OpContext, - ) -> CreateResult<()> { - self.populate_mock_system_updates_inner(opctx).await.or_else(|error| { - match error { - // ignore ObjectAlreadyExists but pass through other errors - external::Error::ObjectAlreadyExists { .. } => Ok(()), - _ => Err(error), - } - }) - } -} - -// TODO: convert system update tests to integration tests now that I know how to -// call nexus functions in those - -#[cfg(test)] -mod tests { - use assert_matches::assert_matches; - use std::num::NonZeroU32; - - use dropshot::PaginationOrder; - use nexus_db_queries::context::OpContext; - use nexus_db_queries::db::model::UpdateStatus; - use nexus_test_utils_macros::nexus_test; - use nexus_types::external_api::{ - params::{ - ComponentUpdateCreate, SystemUpdateCreate, SystemUpdateStart, - UpdateableComponentCreate, - }, - shared::UpdateableComponentType, - }; - use omicron_common::api::external::{self, DataPageParams}; - use uuid::Uuid; - - type ControlPlaneTestContext = - nexus_test_utils::ControlPlaneTestContext; - - pub fn test_opctx(cptestctx: &ControlPlaneTestContext) -> OpContext { - OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - cptestctx.server.apictx.nexus.datastore().clone(), - ) - } - - pub fn test_pagparams() -> DataPageParams<'static, Uuid> { - DataPageParams { - marker: None, - direction: PaginationOrder::Ascending, - limit: NonZeroU32::new(100).unwrap(), - } - } - - #[nexus_test(server = crate::Server)] - async fn test_system_updates(cptestctx: &ControlPlaneTestContext) { - let nexus = &cptestctx.server.apictx.nexus; - let opctx = test_opctx(&cptestctx); - - // starts out with 3 populated - let system_updates = nexus - .system_updates_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap(); - - assert_eq!(system_updates.len(), 3); - - let su1_create = SystemUpdateCreate { - version: external::SemverVersion::new(5, 0, 0), - }; - let su1 = nexus.upsert_system_update(&opctx, su1_create).await.unwrap(); - - // weird order is deliberate - let su3_create = SystemUpdateCreate { - version: external::SemverVersion::new(10, 0, 0), - }; - nexus.upsert_system_update(&opctx, su3_create).await.unwrap(); - - let su2_create = SystemUpdateCreate { - version: external::SemverVersion::new(0, 7, 0), - }; - let su2 = nexus.upsert_system_update(&opctx, su2_create).await.unwrap(); - - // now there should be a bunch of system updates, sorted by version descending - let versions: Vec = nexus - .system_updates_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap() - .iter() - .map(|su| su.version.to_string()) - .collect(); - - assert_eq!(versions.len(), 6); - assert_eq!(versions[0], "10.0.0".to_string()); - assert_eq!(versions[1], "5.0.0".to_string()); - assert_eq!(versions[2], "3.0.0".to_string()); - assert_eq!(versions[3], "2.0.0".to_string()); - assert_eq!(versions[4], "1.0.0".to_string()); - assert_eq!(versions[5], "0.7.0".to_string()); - - // let's also make sure we can fetch by version - let su1_fetched = nexus - .system_update_fetch_by_version(&opctx, &su1.version) - .await - .unwrap(); - assert_eq!(su1.identity.id, su1_fetched.identity.id); - - // now create two component updates for update 1, one at root, and one - // hanging off the first - nexus - .create_component_update( - &opctx, - ComponentUpdateCreate { - version: external::SemverVersion::new(1, 0, 0), - component_type: UpdateableComponentType::BootloaderForRot, - system_update_id: su1.identity.id, - }, - ) - .await - .expect("Failed to create component update"); - nexus - .create_component_update( - &opctx, - ComponentUpdateCreate { - version: external::SemverVersion::new(2, 0, 0), - component_type: UpdateableComponentType::HubrisForGimletSp, - system_update_id: su1.identity.id, - }, - ) - .await - .expect("Failed to create component update"); - - // now there should be two component updates - let cus_for_su1 = nexus - .system_update_list_components(&opctx, &su1.version) - .await - .unwrap(); - - assert_eq!(cus_for_su1.len(), 2); - - // other system update should not be associated with any component updates - let cus_for_su2 = nexus - .system_update_list_components(&opctx, &su2.version) - .await - .unwrap(); - - assert_eq!(cus_for_su2.len(), 0); - } - - #[nexus_test(server = crate::Server)] - async fn test_semver_max(cptestctx: &ControlPlaneTestContext) { - let nexus = &cptestctx.server.apictx.nexus; - let opctx = test_opctx(&cptestctx); - - let expected = "Invalid Value: version, Major, minor, and patch version must be less than 99999999"; - - // major, minor, and patch are all capped - - let su_create = SystemUpdateCreate { - version: external::SemverVersion::new(100000000, 0, 0), - }; - let error = - nexus.upsert_system_update(&opctx, su_create).await.unwrap_err(); - assert!(error.to_string().contains(expected)); - - let su_create = SystemUpdateCreate { - version: external::SemverVersion::new(0, 100000000, 0), - }; - let error = - nexus.upsert_system_update(&opctx, su_create).await.unwrap_err(); - assert!(error.to_string().contains(expected)); - - let su_create = SystemUpdateCreate { - version: external::SemverVersion::new(0, 0, 100000000), - }; - let error = - nexus.upsert_system_update(&opctx, su_create).await.unwrap_err(); - assert!(error.to_string().contains(expected)); - } - - #[nexus_test(server = crate::Server)] - async fn test_updateable_components(cptestctx: &ControlPlaneTestContext) { - let nexus = &cptestctx.server.apictx.nexus; - let opctx = test_opctx(&cptestctx); - - // starts out populated - let components = nexus - .updateable_components_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap(); - - assert_eq!(components.len(), 9); - - // with no components these should both 500. as discussed in the - // implementation, this is appropriate because we should never be - // running the external API without components populated - // - // let low = - // nexus.lowest_component_system_version(&opctx).await.unwrap_err(); - // assert_matches!(low, external::Error::InternalError { .. }); - // let high = - // nexus.highest_component_system_version(&opctx).await.unwrap_err(); - // assert_matches!(high, external::Error::InternalError { .. }); - - // creating a component if its system_version doesn't exist is a 404 - let uc_create = UpdateableComponentCreate { - version: external::SemverVersion::new(0, 4, 1), - system_version: external::SemverVersion::new(0, 2, 0), - component_type: UpdateableComponentType::BootloaderForSp, - device_id: "look-a-device".to_string(), - }; - let uc_404 = nexus - .create_updateable_component(&opctx, uc_create.clone()) - .await - .unwrap_err(); - assert_matches!(uc_404, external::Error::ObjectNotFound { .. }); - - // create system updates for the component updates to hang off of - let v020 = external::SemverVersion::new(0, 2, 0); - nexus - .upsert_system_update(&opctx, SystemUpdateCreate { version: v020 }) - .await - .expect("Failed to create system update"); - let v3 = external::SemverVersion::new(4, 0, 0); - nexus - .upsert_system_update(&opctx, SystemUpdateCreate { version: v3 }) - .await - .expect("Failed to create system update"); - let v10 = external::SemverVersion::new(10, 0, 0); - nexus - .upsert_system_update(&opctx, SystemUpdateCreate { version: v10 }) - .await - .expect("Failed to create system update"); - - // now uc_create and friends will work - nexus - .create_updateable_component(&opctx, uc_create) - .await - .expect("failed to create updateable component"); - nexus - .create_updateable_component( - &opctx, - UpdateableComponentCreate { - version: external::SemverVersion::new(0, 4, 1), - system_version: external::SemverVersion::new(3, 0, 0), - component_type: UpdateableComponentType::HeliosHostPhase2, - device_id: "another-device".to_string(), - }, - ) - .await - .expect("failed to create updateable component"); - nexus - .create_updateable_component( - &opctx, - UpdateableComponentCreate { - version: external::SemverVersion::new(0, 4, 1), - system_version: external::SemverVersion::new(10, 0, 0), - component_type: UpdateableComponentType::HeliosHostPhase1, - device_id: "a-third-device".to_string(), - }, - ) - .await - .expect("failed to create updateable component"); - - // now there should be 3 more, or 12 - let components = nexus - .updateable_components_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap(); - - assert_eq!(components.len(), 12); - - let low = nexus.lowest_component_system_version(&opctx).await.unwrap(); - assert_eq!(&low.to_string(), "0.2.0"); - let high = - nexus.highest_component_system_version(&opctx).await.unwrap(); - assert_eq!(&high.to_string(), "10.0.0"); - - // TODO: update the version of a component - } - - #[nexus_test(server = crate::Server)] - async fn test_update_deployments(cptestctx: &ControlPlaneTestContext) { - let nexus = &cptestctx.server.apictx.nexus; - let opctx = test_opctx(&cptestctx); - - // starts out with one populated - let deployments = nexus - .update_deployments_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap(); - - assert_eq!(deployments.len(), 2); - - // start update fails with nonexistent version - let not_found = nexus - .create_update_deployment( - &opctx, - SystemUpdateStart { - version: external::SemverVersion::new(6, 0, 0), - }, - ) - .await - .unwrap_err(); - - assert_matches!(not_found, external::Error::ObjectNotFound { .. }); - - // starting with existing version fails because there's already an - // update running - let start_v3 = SystemUpdateStart { - version: external::SemverVersion::new(3, 0, 0), - }; - let already_updating = nexus - .create_update_deployment(&opctx, start_v3.clone()) - .await - .unwrap_err(); - - assert_matches!( - already_updating, - external::Error::ObjectAlreadyExists { .. } - ); - - // stop the running update - nexus - .steady_update_deployment(&opctx) - .await - .expect("Failed to stop running update"); - - // now starting an update succeeds - let d = nexus - .create_update_deployment(&opctx, start_v3) - .await - .expect("Failed to create deployment"); - - let deployment_ids: Vec = nexus - .update_deployments_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap() - .into_iter() - .map(|d| d.identity.id) - .collect(); - - assert_eq!(deployment_ids.len(), 3); - assert!(deployment_ids.contains(&d.identity.id)); - - // latest deployment returns the one just created - let latest_deployment = - nexus.latest_update_deployment(&opctx).await.unwrap(); - - assert_eq!(latest_deployment.identity.id, d.identity.id); - assert_eq!(latest_deployment.status, UpdateStatus::Updating); - assert!( - latest_deployment.identity.time_modified - == d.identity.time_modified - ); - - // stopping update updates both its status and its time_modified - nexus - .steady_update_deployment(&opctx) - .await - .expect("Failed to steady running update"); - - let latest_deployment = - nexus.latest_update_deployment(&opctx).await.unwrap(); - - assert_eq!(latest_deployment.identity.id, d.identity.id); - assert_eq!(latest_deployment.status, UpdateStatus::Steady); - assert!( - latest_deployment.identity.time_modified > d.identity.time_modified - ); - } - - #[nexus_test(server = crate::Server)] - async fn test_populate_mock_system_updates( - cptestctx: &ControlPlaneTestContext, - ) { - let nexus = &cptestctx.server.apictx.nexus; - let opctx = test_opctx(&cptestctx); - - // starts out with updates because they're populated at rack init - let su_count = nexus - .system_updates_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap() - .len(); - assert!(su_count > 0); - - // additional call doesn't error because the conflict gets eaten - let result = nexus.populate_mock_system_updates(&opctx).await; - assert!(result.is_ok()); - - // count didn't change - let system_updates = nexus - .system_updates_list_by_id(&opctx, &test_pagparams()) - .await - .unwrap(); - assert_eq!(system_updates.len(), su_count); + _opctx: &OpContext, + _artifact: ArtifactId, + ) -> Result, Error> { + // TODO: this is part of the TUF repo depot. + return Err(Error::internal_error( + "artifact download not implemented, \ + will be part of TUF repo depot", + )); } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 66f91396d72..3ecce9c7ada 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -8,14 +8,13 @@ use super::{ console_api, device_auth, params, views::{ self, Certificate, Group, IdentityProvider, Image, IpPool, IpPoolRange, - PhysicalDisk, Project, Rack, Role, Silo, SiloUtilization, Sled, - Snapshot, SshKey, User, UserBuiltin, Vpc, VpcRouter, VpcSubnet, + PhysicalDisk, Project, Rack, Role, Silo, SiloQuotas, SiloUtilization, + Sled, Snapshot, SshKey, User, UserBuiltin, Utilization, Vpc, VpcRouter, + VpcSubnet, }, }; use crate::external_api::shared; use crate::ServerContext; -use chrono::Utc; -use dropshot::ApiDescription; use dropshot::EmptyScanParams; use dropshot::HttpError; use dropshot::HttpResponseAccepted; @@ -34,6 +33,7 @@ use dropshot::WhichPage; use dropshot::{ channel, endpoint, WebsocketChannelResult, WebsocketConnection, }; +use dropshot::{ApiDescription, StreamingBody}; use ipnetwork::IpNetwork; use nexus_db_queries::authz; use nexus_db_queries::db; @@ -41,9 +41,6 @@ use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup::ImageLookup; use nexus_db_queries::db::lookup::ImageParentLookup; use nexus_db_queries::db::model::Name; -use nexus_types::external_api::views::SiloQuotas; -use nexus_types::external_api::views::Utilization; -use nexus_types::identity::AssetIdentityMetadata; use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_common::api::external::http_pagination::marker_for_name; use omicron_common::api::external::http_pagination::marker_for_name_or_id; @@ -76,6 +73,8 @@ use omicron_common::api::external::RouterRouteKind; use omicron_common::api::external::SwitchPort; use omicron_common::api::external::SwitchPortSettings; use omicron_common::api::external::SwitchPortSettingsView; +use omicron_common::api::external::TufRepoGetResponse; +use omicron_common::api::external::TufRepoInsertResponse; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::external::VpcFirewallRules; use omicron_common::bail_unless; @@ -142,6 +141,8 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(floating_ip_create)?; api.register(floating_ip_view)?; api.register(floating_ip_delete)?; + api.register(floating_ip_attach)?; + api.register(floating_ip_detach)?; api.register(disk_list)?; api.register(disk_create)?; @@ -201,6 +202,8 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(instance_network_interface_delete)?; api.register(instance_external_ip_list)?; + api.register(instance_ephemeral_ip_attach)?; + api.register(instance_ephemeral_ip_detach)?; api.register(vpc_router_list)?; api.register(vpc_router_view)?; @@ -306,16 +309,8 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(system_metric)?; api.register(silo_metric)?; - api.register(system_update_refresh)?; - api.register(system_version)?; - api.register(system_component_version_list)?; - api.register(system_update_list)?; - api.register(system_update_view)?; - api.register(system_update_start)?; - api.register(system_update_stop)?; - api.register(system_update_components_list)?; - api.register(update_deployments_list)?; - api.register(update_deployment_view)?; + api.register(system_update_put_repository)?; + api.register(system_update_get_repository)?; api.register(user_list)?; api.register(silo_user_list)?; @@ -430,12 +425,6 @@ async fn system_policy_view( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// Path parameters for `/by-id/` endpoints -#[derive(Deserialize, JsonSchema)] -struct ByIdPathParams { - id: Uuid, -} - /// Update the top-level IAM policy #[endpoint { method = PUT, @@ -1977,6 +1966,69 @@ async fn floating_ip_view( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Attach a floating IP to an instance or other resource +#[endpoint { + method = POST, + path = "/v1/floating-ips/{floating_ip}/attach", + tags = ["floating-ips"], +}] +async fn floating_ip_attach( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, + target: TypedBody, +) -> 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 ip = nexus + .floating_ip_attach( + &opctx, + floating_ip_selector, + target.into_inner(), + ) + .await?; + Ok(HttpResponseAccepted(ip)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Detach a floating IP from an instance or other resource +#[endpoint { + method = POST, + path = "/v1/floating-ips/{floating_ip}/detach", + tags = ["floating-ips"], +}] +async fn floating_ip_detach( + 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_lookup = + nexus.floating_ip_lookup(&opctx, floating_ip_selector)?; + let ip = nexus.floating_ip_detach(&opctx, fip_lookup).await?; + Ok(HttpResponseAccepted(ip)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Disks /// List disks @@ -3925,6 +3977,79 @@ async fn instance_external_ip_list( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Allocate and attach an ephemeral IP to an instance +#[endpoint { + method = POST, + path = "/v1/instances/{instance}/external-ips/ephemeral", + tags = ["instances"], +}] +async fn instance_ephemeral_ip_attach( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, + ip_to_create: TypedBody, +) -> 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 instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + let ip = nexus + .instance_attach_external_ip( + &opctx, + &instance_lookup, + ¶ms::ExternalIpCreate::Ephemeral { + pool: ip_to_create.into_inner().pool, + }, + ) + .await?; + Ok(HttpResponseAccepted(ip)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Detach and deallocate an ephemeral IP from an instance +#[endpoint { + method = DELETE, + path = "/v1/instances/{instance}/external-ips/ephemeral", + tags = ["instances"], +}] +async fn instance_ephemeral_ip_detach( + 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 instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + nexus + .instance_detach_external_ip( + &opctx, + &instance_lookup, + ¶ms::ExternalIpDetach::Ephemeral, + ) + .await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Snapshots /// List snapshots @@ -5277,320 +5402,56 @@ async fn silo_metric( // Updates -/// Refresh update data +/// Upload a TUF repository #[endpoint { - method = POST, - path = "/v1/system/update/refresh", - tags = ["system/update"], - unpublished = true, -}] -async fn system_update_refresh( - rqctx: RequestContext>, -) -> Result { - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - nexus.updates_refresh_metadata(&opctx).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// View system version and update status -#[endpoint { - method = GET, - path = "/v1/system/update/version", - tags = ["system/update"], - unpublished = true, -}] -async fn system_version( - rqctx: RequestContext>, -) -> Result, HttpError> { - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; - - // The only way we have no latest deployment is if the rack was just set - // up and no system updates have ever been run. In this case there is no - // update running, so we can fall back to steady. - let status = nexus - .latest_update_deployment(&opctx) - .await - .map_or(views::UpdateStatus::Steady, |d| d.status.into()); - - // Updateable components, however, are populated at rack setup before - // the external API is even started, so if we get here and there are no - // components, that's a real issue and the 500 we throw is appropriate. - let low = nexus.lowest_component_system_version(&opctx).await?.into(); - let high = nexus.highest_component_system_version(&opctx).await?.into(); - - Ok(HttpResponseOk(views::SystemVersion { - version_range: views::VersionRange { low, high }, - status, - })) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// View version and update status of component tree -#[endpoint { - method = GET, - path = "/v1/system/update/components", - tags = ["system/update"], - unpublished = true, -}] -async fn system_component_version_list( - rqctx: RequestContext>, - query_params: Query, -) -> Result>, HttpError> -{ - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&rqctx, &query)?; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let components = nexus - .updateable_components_list_by_id(&opctx, &pagparams) - .await? - .into_iter() - .map(|u| u.into()) - .collect(); - Ok(HttpResponseOk(ScanById::results_page( - &query, - components, - &|_, u: &views::UpdateableComponent| u.identity.id, - )?)) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// List all updates -#[endpoint { - method = GET, - path = "/v1/system/update/updates", - tags = ["system/update"], - unpublished = true, -}] -async fn system_update_list( - rqctx: RequestContext>, - query_params: Query, -) -> Result>, HttpError> { - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&rqctx, &query)?; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let updates = nexus - .system_updates_list_by_id(&opctx, &pagparams) - .await? - .into_iter() - .map(|u| u.into()) - .collect(); - Ok(HttpResponseOk(ScanById::results_page( - &query, - updates, - &|_, u: &views::SystemUpdate| u.identity.id, - )?)) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// View system update -#[endpoint { - method = GET, - path = "/v1/system/update/updates/{version}", - tags = ["system/update"], - unpublished = true, -}] -async fn system_update_view( - rqctx: RequestContext>, - path_params: Path, -) -> Result, HttpError> { - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let system_update = - nexus.system_update_fetch_by_version(&opctx, &path.version).await?; - Ok(HttpResponseOk(system_update.into())) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// View system update component tree -#[endpoint { - method = GET, - path = "/v1/system/update/updates/{version}/components", + method = PUT, + path = "/v1/system/update/repository", tags = ["system/update"], unpublished = true, }] -async fn system_update_components_list( +async fn system_update_put_repository( rqctx: RequestContext>, - path_params: Path, -) -> Result>, HttpError> { + query: Query, + body: StreamingBody, +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; - let path = path_params.into_inner(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let components = nexus - .system_update_list_components(&opctx, &path.version) - .await? - .into_iter() - .map(|i| i.into()) - .collect(); - Ok(HttpResponseOk(ResultsPage { items: components, next_page: None })) + let query = query.into_inner(); + let body = body.into_stream(); + let update = + nexus.updates_put_repository(&opctx, body, query.file_name).await?; + Ok(HttpResponseOk(update)) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// Start system update +/// Get the description of a repository by system version. #[endpoint { - method = POST, - path = "/v1/system/update/start", - tags = ["system/update"], - unpublished = true, -}] -async fn system_update_start( - rqctx: RequestContext>, - // The use of the request body here instead of a path param is deliberate. - // Unlike instance start (which uses a path param), update start is about - // modifying the state of the system rather than the state of the resource - // (instance there, system update here) identified by the param. This - // approach also gives us symmetry with the /stop endpoint. - update: TypedBody, -) -> Result, HttpError> { - let apictx = rqctx.context(); - let _nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; - - // inverse situation to stop: we only want to actually start an update - // if there isn't one already in progress. - - // 1. check that there is no update in progress - // a. if there is one, this should probably 409 - // 2. kick off the update start saga, which - // a. tells the update system to get going - // b. creates an update deployment - - // similar question for stop: do we return the deployment directly, or a - // special StartUpdateResult that includes a deployment ID iff an update - // was actually started - - Ok(HttpResponseAccepted(views::UpdateDeployment { - identity: AssetIdentityMetadata { - id: Uuid::new_v4(), - time_created: Utc::now(), - time_modified: Utc::now(), - }, - version: update.into_inner().version, - status: views::UpdateStatus::Updating, - })) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Stop system update -/// -/// If there is no update in progress, do nothing. -#[endpoint { - method = POST, - path = "/v1/system/update/stop", + method = GET, + path = "/v1/system/update/repository/{system_version}", tags = ["system/update"], unpublished = true, }] -async fn system_update_stop( +async fn system_update_get_repository( rqctx: RequestContext>, -) -> Result { - let apictx = rqctx.context(); - let _nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; - - // TODO: Implement stopping an update. Should probably be a saga. - - // Ask update subsystem if it's doing anything. If so, tell it to stop. - // This could be done in a single call to the updater if the latter can - // respond to a stop command differently depending on whether it did - // anything or not. - - // If we did in fact stop a running update, update the status on the - // latest update deployment in the DB to `stopped` and respond with that - // deployment. If we do nothing, what should we return? Maybe instead of - // responding with the deployment, this endpoint gets its own - // `StopUpdateResult` response view that says whether it was a noop, and - // if it wasn't, includes the ID of the stopped deployment, which allows - // the client to fetch it if it actually wants it. - - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// List all update deployments -#[endpoint { - method = GET, - path = "/v1/system/update/deployments", - tags = ["system/update"], - unpublished = true, -}] -async fn update_deployments_list( - rqctx: RequestContext>, - query_params: Query, -) -> Result>, HttpError> { + path_params: Path, +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&rqctx, &query)?; let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let updates = nexus - .update_deployments_list_by_id(&opctx, &pagparams) - .await? - .into_iter() - .map(|u| u.into()) - .collect(); - Ok(HttpResponseOk(ScanById::results_page( - &query, - updates, - &|_, u: &views::UpdateDeployment| u.identity.id, - )?)) + let params = path_params.into_inner(); + let description = + nexus.updates_get_repository(&opctx, params.system_version).await?; + Ok(HttpResponseOk(TufRepoGetResponse { + description: description.into_external(), + })) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// Fetch a system update deployment -#[endpoint { - method = GET, - path = "/v1/system/update/deployments/{id}", - tags = ["system/update"], - unpublished = true, -}] -async fn update_deployment_view( - rqctx: RequestContext>, - path_params: Path, -) -> Result, HttpError> { - let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let id = &path.id; - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let deployment = - nexus.update_deployment_fetch_by_id(&opctx, id).await?; - Ok(HttpResponseOk(deployment.into())) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await -} // Silo users /// List users diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index 63578e360a9..58038cb37a7 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -40,7 +40,7 @@ use omicron_common::api::external::Error; use omicron_common::api::internal::nexus::DiskRuntimeState; use omicron_common::api::internal::nexus::ProducerEndpoint; use omicron_common::api::internal::nexus::SledInstanceState; -use omicron_common::api::internal::nexus::UpdateArtifactId; +use omicron_common::update::ArtifactId; use oximeter::types::ProducerResults; use oximeter_producer::{collect, ProducerIdPathParams}; use schemars::JsonSchema; @@ -438,15 +438,16 @@ async fn cpapi_metrics_collect( }] async fn cpapi_artifact_download( request_context: RequestContext>, - path_params: Path, + path_params: Path, ) -> Result, HttpError> { let context = request_context.context(); let nexus = &context.nexus; let opctx = crate::context::op_context_for_internal_api(&request_context).await; // TODO: return 404 if the error we get here says that the record isn't found - let body = - nexus.download_artifact(&opctx, path_params.into_inner()).await?; + let body = nexus + .updates_download_artifact(&opctx, path_params.into_inner()) + .await?; Ok(HttpResponseOk(Body::from(body).into())) } diff --git a/nexus/src/lib.rs b/nexus/src/lib.rs index 01aca36e1da..e1392440a10 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -20,7 +20,6 @@ pub mod external_api; // Public for testing mod internal_api; mod populate; mod saga_interface; -mod updates; // public for testing pub use app::test_interfaces::TestInterfaces; pub use app::Nexus; diff --git a/nexus/src/updates.rs b/nexus/src/updates.rs deleted file mode 100644 index 2f57868acc6..00000000000 --- a/nexus/src/updates.rs +++ /dev/null @@ -1,74 +0,0 @@ -// 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/. - -use buf_list::BufList; -use futures::TryStreamExt; -use nexus_db_queries::db; -use omicron_common::update::ArtifactsDocument; -use std::convert::TryInto; - -pub(crate) async fn read_artifacts( - trusted_root: &[u8], - mut base_url: String, -) -> Result< - Vec, - Box, -> { - if !base_url.ends_with('/') { - base_url.push('/'); - } - - let repository = tough::RepositoryLoader::new( - &trusted_root, - format!("{}metadata/", base_url).parse()?, - format!("{}targets/", base_url).parse()?, - ) - .load() - .await?; - - let artifact_document = - match repository.read_target(&"artifacts.json".parse()?).await? { - Some(target) => target.try_collect::().await?, - None => return Err("artifacts.json missing".into()), - }; - let artifacts: ArtifactsDocument = - serde_json::from_reader(buf_list::Cursor::new(&artifact_document))?; - - let valid_until = repository - .root() - .signed - .expires - .min(repository.snapshot().signed.expires) - .min(repository.targets().signed.expires) - .min(repository.timestamp().signed.expires); - - let mut v = Vec::new(); - for artifact in artifacts.artifacts { - // Skip any artifacts where we don't recognize its kind or the target - // name isn't in the repository - let target = - repository.targets().signed.targets.get(&artifact.target.parse()?); - let (kind, target) = match (artifact.kind.to_known(), target) { - (Some(kind), Some(target)) => (kind, target), - _ => break, - }; - - v.push(db::model::UpdateArtifact { - name: artifact.name, - version: db::model::SemverVersion(artifact.version), - kind: db::model::KnownArtifactKind(kind), - targets_role_version: repository - .targets() - .signed - .version - .get() - .try_into()?, - valid_until, - target_name: artifact.target, - target_sha256: hex::encode(&target.hashes.sha256), - target_length: target.length.try_into()?, - }); - } - Ok(v) -} diff --git a/nexus/test-utils/Cargo.toml b/nexus/test-utils/Cargo.toml index 4a7924770e8..5605f33f75d 100644 --- a/nexus/test-utils/Cargo.toml +++ b/nexus/test-utils/Cargo.toml @@ -36,6 +36,7 @@ serde_json.workspace = true serde_urlencoded.workspace = true slog.workspace = true tokio.workspace = true +tokio-util.workspace = true trust-dns-resolver.workspace = true uuid.workspace = true omicron-workspace-hack.workspace = true diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index bf5370a925a..ae62218c939 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -7,6 +7,7 @@ use anyhow::anyhow; use anyhow::ensure; use anyhow::Context; +use camino::Utf8Path; use dropshot::test_util::ClientTestContext; use dropshot::ResultsPage; use headers::authorization::Credentials; @@ -147,6 +148,35 @@ impl<'a> RequestBuilder<'a> { self } + /// Set the outgoing request body to the contents of a file. + /// + /// A handle to the file will be kept open until the request is completed. + /// + /// If `path` is `None`, the request body will be empty. + pub fn body_file(mut self, path: Option<&Utf8Path>) -> Self { + match path { + Some(path) => { + // Turn the file into a stream. (Opening the file with + // std::fs::File::open means that this method doesn't have to + // be async.) + let file = std::fs::File::open(path).with_context(|| { + format!("failed to open request body file at {path}") + }); + match file { + Ok(file) => { + let stream = tokio_util::io::ReaderStream::new( + tokio::fs::File::from_std(file), + ); + self.body = hyper::Body::wrap_stream(stream); + } + Err(error) => self.error = Some(error), + } + } + None => self.body = hyper::Body::empty(), + }; + self + } + /// Set the outgoing request body using URL encoding /// and set the content type appropriately /// diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index e1d27bba47a..b4939862130 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -492,6 +492,7 @@ pub async fn create_instance( Vec::::new(), // External IPs= Vec::::new(), + true, ) .await } @@ -504,6 +505,7 @@ pub async fn create_instance_with( nics: ¶ms::InstanceNetworkInterfaceAttachment, disks: Vec, external_ips: Vec, + start: bool, ) -> Instance { let url = format!("/v1/instances?project={}", project_name); object_create( @@ -524,7 +526,7 @@ pub async fn create_instance_with( network_interfaces: nics.clone(), external_ips, disks, - start: true, + start, }, ) .await diff --git a/nexus/tests/integration_tests/disks.rs b/nexus/tests/integration_tests/disks.rs index b9023a82125..379042c8497 100644 --- a/nexus/tests/integration_tests/disks.rs +++ b/nexus/tests/integration_tests/disks.rs @@ -1747,6 +1747,7 @@ async fn create_instance_with_disk(client: &ClientTestContext) { params::InstanceDiskAttach { name: DISK_NAME.parse().unwrap() }, )], Vec::::new(), + true, ) .await; } diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 9448fe6f619..54fa9629fbc 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -32,7 +32,6 @@ use omicron_common::api::external::Name; use omicron_common::api::external::NameOrId; use omicron_common::api::external::RouteDestination; use omicron_common::api::external::RouteTarget; -use omicron_common::api::external::SemverVersion; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_test_utils::certificates::CertificateChain; use once_cell::sync::Lazy; @@ -390,6 +389,12 @@ pub static DEMO_INSTANCE_DISKS_DETACH_URL: Lazy = Lazy::new(|| { *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR ) }); +pub static DEMO_INSTANCE_EPHEMERAL_IP_URL: Lazy = Lazy::new(|| { + format!( + "/v1/instances/{}/external-ips/ephemeral?{}", + *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR + ) +}); pub static DEMO_INSTANCE_NICS_URL: Lazy = Lazy::new(|| { format!( "/v1/network-interfaces?project={}&instance={}", @@ -415,7 +420,7 @@ pub static DEMO_INSTANCE_CREATE: Lazy = ssh_keys: Some(Vec::new()), network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: Some(DEMO_IP_POOL_NAME.clone()), + pool: Some(DEMO_IP_POOL_NAME.clone().into()), }], disks: vec![], start: true, @@ -703,13 +708,6 @@ pub static DEMO_SSHKEY_CREATE: Lazy = pub static DEMO_SPECIFIC_SSHKEY_URL: Lazy = Lazy::new(|| format!("{}/{}", DEMO_SSHKEYS_URL, *DEMO_SSHKEY_NAME)); -// System update - -pub static DEMO_SYSTEM_UPDATE_PARAMS: Lazy = - Lazy::new(|| params::SystemUpdatePath { - version: SemverVersion::new(1, 0, 0), - }); - // Project Floating IPs pub static DEMO_FLOAT_IP_NAME: Lazy = Lazy::new(|| "float-ip".parse().unwrap()); @@ -721,6 +719,19 @@ pub static DEMO_FLOAT_IP_URL: Lazy = Lazy::new(|| { ) }); +pub static DEMO_FLOATING_IP_ATTACH_URL: Lazy = Lazy::new(|| { + format!( + "/v1/floating-ips/{}/attach?{}", + *DEMO_FLOAT_IP_NAME, *DEMO_PROJECT_SELECTOR + ) +}); +pub static DEMO_FLOATING_IP_DETACH_URL: Lazy = Lazy::new(|| { + format!( + "/v1/floating-ips/{}/detach?{}", + *DEMO_FLOAT_IP_NAME, *DEMO_PROJECT_SELECTOR + ) +}); + pub static DEMO_FLOAT_IP_CREATE: Lazy = Lazy::new(|| params::FloatingIpCreate { identity: IdentityMetadataCreateParams { @@ -731,6 +742,13 @@ pub static DEMO_FLOAT_IP_CREATE: Lazy = pool: None, }); +pub static DEMO_FLOAT_IP_ATTACH: Lazy = + Lazy::new(|| params::FloatingIpAttach { + kind: params::FloatingIpParentKind::Instance, + parent: DEMO_FLOAT_IP_NAME.clone().into(), + }); +pub static DEMO_EPHEMERAL_IP_ATTACH: Lazy = + Lazy::new(|| params::EphemeralIpCreate { pool: None }); // Identity providers pub const IDENTITY_PROVIDERS_URL: &'static str = "/v1/system/identity-providers?silo=demo-silo"; @@ -1768,6 +1786,18 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { allowed_methods: vec![AllowedMethod::Get], }, + VerifyEndpoint { + url: &DEMO_INSTANCE_EPHEMERAL_IP_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_EPHEMERAL_IP_ATTACH).unwrap() + ), + AllowedMethod::Delete, + ], + }, + /* IAM */ VerifyEndpoint { @@ -1883,81 +1913,22 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { /* Updates */ VerifyEndpoint { - url: "/v1/system/update/refresh", + url: "/v1/system/update/repository?file_name=demo-repo.zip", visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Post( - serde_json::Value::Null - )], - }, - - VerifyEndpoint { - url: "/v1/system/update/version", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - VerifyEndpoint { - url: "/v1/system/update/components", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - VerifyEndpoint { - url: "/v1/system/update/updates", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - // TODO: make system update endpoints work instead of expecting 404 - - VerifyEndpoint { - url: "/v1/system/update/updates/1.0.0", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - VerifyEndpoint { - url: "/v1/system/update/updates/1.0.0/components", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - VerifyEndpoint { - url: "/v1/system/update/start", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Post( - serde_json::to_value(&*DEMO_SYSTEM_UPDATE_PARAMS).unwrap() - )], - }, - - VerifyEndpoint { - url: "/v1/system/update/stop", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Post( - serde_json::Value::Null + allowed_methods: vec![AllowedMethod::Put( + // In reality this is the contents of a zip file. + serde_json::Value::Null, )], }, VerifyEndpoint { - url: "/v1/system/update/deployments", - visibility: Visibility::Public, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - - VerifyEndpoint { - url: "/v1/system/update/deployments/120bbb6f-660a-440c-8cb7-199be202ddff", + url: "/v1/system/update/repository/1.0.0", visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::GetNonexistent], + // The update system is disabled, which causes a 500 error even for + // privileged users. That is captured by GetUnimplemented. + allowed_methods: vec![AllowedMethod::GetUnimplemented], }, /* Metrics */ @@ -2241,5 +2212,27 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { AllowedMethod::Delete, ], }, + + VerifyEndpoint { + url: &DEMO_FLOATING_IP_ATTACH_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_FLOAT_IP_ATTACH).unwrap(), + ), + ], + }, + + VerifyEndpoint { + url: &DEMO_FLOATING_IP_DETACH_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&()).unwrap(), + ), + ], + }, ] }); diff --git a/nexus/tests/integration_tests/external_ips.rs b/nexus/tests/integration_tests/external_ips.rs index 3b6127ceb16..57f813d505f 100644 --- a/nexus/tests/integration_tests/external_ips.rs +++ b/nexus/tests/integration_tests/external_ips.rs @@ -7,6 +7,7 @@ use std::net::IpAddr; use std::net::Ipv4Addr; +use crate::integration_tests::instances::fetch_instance_external_ips; use crate::integration_tests::instances::instance_simulate; use dropshot::test_util::ClientTestContext; use dropshot::HttpErrorResponseBody; @@ -30,12 +31,14 @@ use nexus_test_utils::resource_helpers::object_delete_error; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::params; use nexus_types::external_api::shared; +use nexus_types::external_api::views; use nexus_types::external_api::views::FloatingIp; use nexus_types::identity::Resource; use omicron_common::address::IpRange; use omicron_common::address::Ipv4Range; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::Instance; +use omicron_common::api::external::Name; use omicron_common::api::external::NameOrId; use uuid::Uuid; @@ -47,10 +50,33 @@ const PROJECT_NAME: &str = "rootbeer-float"; const FIP_NAMES: &[&str] = &["vanilla", "chocolate", "strawberry", "pistachio", "caramel"]; +const INSTANCE_NAMES: &[&str] = &["anonymous-diner", "anonymous-restaurant"]; + pub fn get_floating_ips_url(project_name: &str) -> String { format!("/v1/floating-ips?project={project_name}") } +pub fn instance_ephemeral_ip_url( + instance_name: &str, + project_name: &str, +) -> String { + format!("/v1/instances/{instance_name}/external-ips/ephemeral?project={project_name}") +} + +pub fn attach_floating_ip_url( + floating_ip_name: &str, + project_name: &str, +) -> String { + format!("/v1/floating-ips/{floating_ip_name}/attach?project={project_name}") +} + +pub fn detach_floating_ip_url( + floating_ip_name: &str, + project_name: &str, +) -> String { + format!("/v1/floating-ips/{floating_ip_name}/detach?project={project_name}") +} + pub fn get_floating_ip_by_name_url( fip_name: &str, project_name: &str, @@ -392,7 +418,9 @@ async fn test_floating_ip_delete(cptestctx: &ControlPlaneTestContext) { } #[nexus_test] -async fn test_floating_ip_attachment(cptestctx: &ControlPlaneTestContext) { +async fn test_floating_ip_create_attachment( + cptestctx: &ControlPlaneTestContext, +) { let client = &cptestctx.external_client; let apictx = &cptestctx.server.apictx(); let nexus = &apictx.nexus; @@ -410,16 +438,13 @@ async fn test_floating_ip_attachment(cptestctx: &ControlPlaneTestContext) { .await; // Bind the floating IP to an instance at create time. - let instance_name = "anonymous-diner"; - let instance = create_instance_with( - &client, - PROJECT_NAME, + let instance_name = INSTANCE_NAMES[0]; + let instance = instance_for_external_ips( + client, instance_name, - ¶ms::InstanceNetworkInterfaceAttachment::Default, - vec![], - vec![params::ExternalIpCreate::Floating { - floating_ip_name: FIP_NAMES[0].parse().unwrap(), - }], + true, + false, + &FIP_NAMES[..1], ) .await; @@ -430,20 +455,12 @@ async fn test_floating_ip_attachment(cptestctx: &ControlPlaneTestContext) { 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)), + let error = object_delete_error( + client, + &get_floating_ip_by_id_url(&fip.identity.id), + StatusCode::BAD_REQUEST, ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); + .await; assert_eq!( error.message, format!("Floating IP cannot be deleted while attached to an instance"), @@ -497,6 +514,340 @@ async fn test_floating_ip_attachment(cptestctx: &ControlPlaneTestContext) { .unwrap(); } +#[nexus_test] +async fn test_external_ip_live_attach_detach( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + create_default_ip_pool(&client).await; + let project = create_project(client, PROJECT_NAME).await; + + // Create 2 instances, and a floating IP for each instance. + // One instance will be started, and one will be stopped. + let mut fips = vec![]; + for i in 0..2 { + fips.push( + create_floating_ip( + client, + FIP_NAMES[i], + project.identity.name.as_str(), + None, + None, + ) + .await, + ); + } + + let mut instances = vec![]; + for (i, start) in [false, true].iter().enumerate() { + let instance = instance_for_external_ips( + client, + INSTANCE_NAMES[i], + *start, + false, + &[], + ) + .await; + + if *start { + instance_simulate(nexus, &instance.identity.id).await; + instance_simulate(nexus, &instance.identity.id).await; + } + + // Verify that each instance has no external IPs. + assert_eq!( + fetch_instance_external_ips( + client, + INSTANCE_NAMES[i], + PROJECT_NAME + ) + .await + .len(), + 0 + ); + + instances.push(instance); + } + + // Attach a floating IP and ephemeral IP to each instance. + let mut recorded_ephs = vec![]; + for (instance, fip) in instances.iter().zip(&fips) { + let instance_name = instance.identity.name.as_str(); + let eph_resp = ephemeral_ip_attach(client, instance_name, None).await; + let fip_resp = floating_ip_attach( + client, + instance_name, + fip.identity.name.as_str(), + ) + .await; + + // Verify both appear correctly. + // This implicitly checks FIP parent_id matches the instance, + // and state has fully moved into 'Attached'. + let eip_list = + fetch_instance_external_ips(client, instance_name, PROJECT_NAME) + .await; + + assert_eq!(eip_list.len(), 2); + assert!(eip_list.contains(&eph_resp)); + assert!(eip_list + .iter() + .any(|v| matches!(v, views::ExternalIp::Floating(..)) + && v.ip() == fip_resp.ip)); + assert_eq!(fip.ip, fip_resp.ip); + + // Check for idempotency: repeat requests should return same values. + let eph_resp_2 = ephemeral_ip_attach(client, instance_name, None).await; + let fip_resp_2 = floating_ip_attach( + client, + instance_name, + fip.identity.name.as_str(), + ) + .await; + + assert_eq!(eph_resp, eph_resp_2); + assert_eq!(fip_resp.ip, fip_resp_2.ip); + + recorded_ephs.push(eph_resp); + } + + // Detach a floating IP and ephemeral IP from each instance. + for (instance, fip) in instances.iter().zip(&fips) { + let instance_name = instance.identity.name.as_str(); + ephemeral_ip_detach(client, instance_name).await; + let fip_resp = + floating_ip_detach(client, fip.identity.name.as_str()).await; + + // Verify both are removed, and that their bodies match the known FIP/EIP combo. + let eip_list = + fetch_instance_external_ips(client, instance_name, PROJECT_NAME) + .await; + + assert_eq!(eip_list.len(), 0); + assert_eq!(fip.ip, fip_resp.ip); + + // Check for idempotency: repeat requests should return same values for FIP, + // but in ephemeral case there is no currently known IP so we return an error. + let fip_resp_2 = + floating_ip_detach(client, fip.identity.name.as_str()).await; + assert_eq!(fip_resp.ip, fip_resp_2.ip); + + let url = instance_ephemeral_ip_url(instance_name, PROJECT_NAME); + let error = + object_delete_error(client, &url, StatusCode::BAD_REQUEST).await; + assert_eq!( + error.message, + "instance does not have an ephemeral IP attached".to_string() + ); + } +} + +#[nexus_test] +async fn test_external_ip_attach_detach_fail_if_in_use_by_other( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let apictx = &cptestctx.server.apictx(); + let nexus = &apictx.nexus; + + create_default_ip_pool(&client).await; + let project = create_project(client, PROJECT_NAME).await; + + // Create 2 instances, bind a FIP to each. + let mut instances = vec![]; + let mut fips = vec![]; + for i in 0..2 { + let fip = create_floating_ip( + client, + FIP_NAMES[i], + project.identity.name.as_str(), + None, + None, + ) + .await; + let instance = instance_for_external_ips( + client, + INSTANCE_NAMES[i], + true, + false, + &[FIP_NAMES[i]], + ) + .await; + + instance_simulate(nexus, &instance.identity.id).await; + instance_simulate(nexus, &instance.identity.id).await; + + instances.push(instance); + fips.push(fip); + } + + // Attach in-use FIP to *other* instance should fail. + let url = + attach_floating_ip_url(fips[1].identity.name.as_str(), PROJECT_NAME); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::FloatingIpAttach { + kind: params::FloatingIpParentKind::Instance, + parent: INSTANCE_NAMES[0].parse::().unwrap().into(), + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "floating IP cannot be attached to one instance while still attached to another".to_string()); +} + +#[nexus_test] +async fn test_external_ip_attach_fails_after_maximum( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_default_ip_pool(&client).await; + let project = create_project(client, PROJECT_NAME).await; + + // Create 33 floating IPs, and bind the first 32 to an instance. + let mut fip_names = vec![]; + for i in 0..33 { + let fip_name = format!("fip-{i}"); + create_floating_ip( + client, + &fip_name, + project.identity.name.as_str(), + None, + None, + ) + .await; + fip_names.push(fip_name); + } + + let fip_name_slice = + fip_names.iter().map(String::as_str).collect::>(); + let instance_name = INSTANCE_NAMES[0]; + instance_for_external_ips( + client, + instance_name, + true, + false, + &fip_name_slice[..32], + ) + .await; + + // Attempt to attach the final FIP should fail. + let url = attach_floating_ip_url(fip_name_slice[32], PROJECT_NAME); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::FloatingIpAttach { + kind: params::FloatingIpParentKind::Instance, + parent: instance_name.parse::().unwrap().into(), + })) + .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 32 external IP addresses" + .to_string() + ); + + // Attempt to attach an ephemeral IP should fail. + let url = instance_ephemeral_ip_url(instance_name, PROJECT_NAME); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::EphemeralIpCreate { pool: None })) + .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 32 external IP addresses" + .to_string() + ); +} + +#[nexus_test] +async fn test_external_ip_attach_ephemeral_at_pool_exhaustion( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_default_ip_pool(&client).await; + let other_pool_range = IpRange::V4( + Ipv4Range::new(Ipv4Addr::new(10, 1, 0, 1), Ipv4Addr::new(10, 1, 0, 1)) + .unwrap(), + ); + create_ip_pool(&client, "other-pool", Some(other_pool_range)).await; + let silo_id = DEFAULT_SILO.id(); + link_ip_pool(&client, "other-pool", &silo_id, false).await; + + create_project(client, PROJECT_NAME).await; + + // Create two instances, to which we will later add eph IPs from 'other-pool'. + for name in &INSTANCE_NAMES[..2] { + instance_for_external_ips(client, name, false, false, &[]).await; + } + + let pool_name: Name = "other-pool".parse().unwrap(); + + // Attach a new EIP from other-pool to both instances. + // This should succeed for the first, and fail for the second + // due to pool exhaustion. + let eph_resp = ephemeral_ip_attach( + client, + INSTANCE_NAMES[0], + Some(pool_name.as_str()), + ) + .await; + assert_eq!(eph_resp.ip(), other_pool_range.first_address()); + assert_eq!(eph_resp.ip(), other_pool_range.last_address()); + + let url = instance_ephemeral_ip_url(INSTANCE_NAMES[1], PROJECT_NAME); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::ExternalIpCreate::Ephemeral { + pool: Some(pool_name.clone().into()), + })) + .expect_status(Some(StatusCode::INSUFFICIENT_STORAGE)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + error.message, + "Insufficient capacity: No external IP addresses available".to_string() + ); + + // Idempotent re-add to the first instance should succeed even if + // an internal attempt to alloc a new EIP would fail. + let eph_resp_2 = ephemeral_ip_attach( + client, + INSTANCE_NAMES[0], + Some(pool_name.as_str()), + ) + .await; + assert_eq!(eph_resp_2, eph_resp); +} + pub async fn floating_ip_get( client: &ClientTestContext, fip_url: &str, @@ -521,3 +872,96 @@ async fn floating_ip_get_as( panic!("failed to make \"get\" request to {fip_url}: {e}") }) } + +async fn instance_for_external_ips( + client: &ClientTestContext, + instance_name: &str, + start: bool, + use_ephemeral_ip: bool, + floating_ip_names: &[&str], +) -> Instance { + let mut fips: Vec<_> = floating_ip_names + .iter() + .map(|s| params::ExternalIpCreate::Floating { + floating_ip: s.parse::().unwrap().into(), + }) + .collect(); + if use_ephemeral_ip { + fips.push(params::ExternalIpCreate::Ephemeral { pool: None }) + } + create_instance_with( + &client, + PROJECT_NAME, + instance_name, + ¶ms::InstanceNetworkInterfaceAttachment::Default, + vec![], + fips, + start, + ) + .await +} + +async fn ephemeral_ip_attach( + client: &ClientTestContext, + instance_name: &str, + pool_name: Option<&str>, +) -> views::ExternalIp { + let url = instance_ephemeral_ip_url(instance_name, PROJECT_NAME); + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::EphemeralIpCreate { + pool: pool_name.map(|v| v.parse::().unwrap().into()), + })) + .expect_status(Some(StatusCode::ACCEPTED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap() +} + +async fn ephemeral_ip_detach(client: &ClientTestContext, instance_name: &str) { + let url = instance_ephemeral_ip_url(instance_name, PROJECT_NAME); + object_delete(client, &url).await; +} + +async fn floating_ip_attach( + client: &ClientTestContext, + instance_name: &str, + floating_ip_name: &str, +) -> views::FloatingIp { + let url = attach_floating_ip_url(floating_ip_name, PROJECT_NAME); + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .body(Some(¶ms::FloatingIpAttach { + kind: params::FloatingIpParentKind::Instance, + parent: instance_name.parse::().unwrap().into(), + })) + .expect_status(Some(StatusCode::ACCEPTED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap() +} + +async fn floating_ip_detach( + client: &ClientTestContext, + floating_ip_name: &str, +) -> views::FloatingIp { + let url = detach_floating_ip_url(floating_ip_name, PROJECT_NAME); + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .expect_status(Some(StatusCode::ACCEPTED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap() +} diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 4ed5b73a70f..3be97b1a14e 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -652,6 +652,7 @@ async fn test_instance_migrate(cptestctx: &ControlPlaneTestContext) { ¶ms::InstanceNetworkInterfaceAttachment::Default, Vec::::new(), Vec::::new(), + true, ) .await; let instance_id = instance.identity.id; @@ -755,6 +756,7 @@ async fn test_instance_migrate_v2p(cptestctx: &ControlPlaneTestContext) { // located with their instances. Vec::::new(), Vec::::new(), + true, ) .await; let instance_id = instance.identity.id; @@ -1107,6 +1109,7 @@ async fn test_instance_metrics_with_migration( ¶ms::InstanceNetworkInterfaceAttachment::Default, Vec::::new(), Vec::::new(), + true, ) .await; let instance_id = instance.identity.id; @@ -3852,7 +3855,7 @@ async fn test_instance_ephemeral_ip_from_correct_pool( let ip = fetch_instance_ephemeral_ip(client, "pool1-inst").await; assert!( - ip.ip >= range1.first_address() && ip.ip <= range1.last_address(), + ip.ip() >= range1.first_address() && ip.ip() <= range1.last_address(), "Expected ephemeral IP to come from pool1" ); @@ -3860,7 +3863,7 @@ async fn test_instance_ephemeral_ip_from_correct_pool( create_instance_with_pool(client, "pool2-inst", Some("pool2")).await; let ip = fetch_instance_ephemeral_ip(client, "pool2-inst").await; assert!( - ip.ip >= range2.first_address() && ip.ip <= range2.last_address(), + ip.ip() >= range2.first_address() && ip.ip() <= range2.last_address(), "Expected ephemeral IP to come from pool2" ); @@ -3875,7 +3878,7 @@ async fn test_instance_ephemeral_ip_from_correct_pool( create_instance_with_pool(client, "pool2-inst2", None).await; let ip = fetch_instance_ephemeral_ip(client, "pool2-inst2").await; assert!( - ip.ip >= range2.first_address() && ip.ip <= range2.last_address(), + ip.ip() >= range2.first_address() && ip.ip() <= range2.last_address(), "Expected ephemeral IP to come from pool2" ); @@ -3913,7 +3916,7 @@ async fn test_instance_ephemeral_ip_from_correct_pool( user_data: vec![], network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: Some("pool1".parse().unwrap()), + pool: Some("pool1".parse::().unwrap().into()), }], ssh_keys: None, disks: vec![], @@ -3978,7 +3981,7 @@ async fn test_instance_ephemeral_ip_from_orphan_pool( user_data: vec![], network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: Some("orphan-pool".parse().unwrap()), + pool: Some("orphan-pool".parse::().unwrap().into()), }], ssh_keys: None, disks: vec![], @@ -4039,7 +4042,7 @@ async fn test_instance_ephemeral_ip_no_default_pool_error( user_data: vec![], network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: None, // <--- the only important thing here + pool: None, // <--- the only important thing here }], ssh_keys: None, disks: vec![], @@ -4056,7 +4059,7 @@ async fn test_instance_ephemeral_ip_no_default_pool_error( // same deal if you specify a pool that doesn't exist let body = params::InstanceCreate { external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: Some("nonexistent-pool".parse().unwrap()), + pool: Some("nonexistent-pool".parse::().unwrap().into()), }], ..body }; @@ -4090,7 +4093,7 @@ async fn test_instance_attach_several_external_ips( // Create several floating IPs for the instance, totalling 8 IPs. let mut external_ip_create = - vec![params::ExternalIpCreate::Ephemeral { pool_name: None }]; + vec![params::ExternalIpCreate::Ephemeral { pool: None }]; let mut fips = vec![]; for i in 1..8 { let name = format!("fip-{i}"); @@ -4098,7 +4101,7 @@ async fn test_instance_attach_several_external_ips( create_floating_ip(&client, &name, PROJECT_NAME, None, None).await, ); external_ip_create.push(params::ExternalIpCreate::Floating { - floating_ip_name: name.parse().unwrap(), + floating_ip: name.parse::().unwrap().into(), }); } @@ -4111,30 +4114,31 @@ async fn test_instance_attach_several_external_ips( ¶ms::InstanceNetworkInterfaceAttachment::Default, vec![], external_ip_create, + true, ) .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; + fetch_instance_external_ips(&client, instance_name, PROJECT_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)) + .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); + 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); + assert_eq!(eip.kind(), IpKind::Ephemeral); 2 }; - assert_eq!(eip.ip, Ipv4Addr::new(10, 0, 0, last_octet as u8)); + assert_eq!(eip.ip(), Ipv4Addr::new(10, 0, 0, last_octet as u8)); } // Verify that all floating IPs are bound to their parent instance. @@ -4159,7 +4163,7 @@ async fn test_instance_allow_only_one_ephemeral_ip( // don't need any IP pools because request fails at parse time let ephemeral_create = params::ExternalIpCreate::Ephemeral { - pool_name: Some("default".parse().unwrap()), + pool: Some("default".parse::().unwrap().into()), }; let create_params = params::InstanceCreate { identity: IdentityMetadataCreateParams { @@ -4204,19 +4208,20 @@ async fn create_instance_with_pool( ¶ms::InstanceNetworkInterfaceAttachment::Default, vec![], vec![params::ExternalIpCreate::Ephemeral { - pool_name: pool_name.map(|name| name.parse().unwrap()), + pool: pool_name.map(|name| name.parse::().unwrap().into()), }], + true, ) .await } -async fn fetch_instance_external_ips( +pub async fn fetch_instance_external_ips( client: &ClientTestContext, instance_name: &str, + project_name: &str, ) -> Vec { let ips_url = format!( - "/v1/instances/{}/external-ips?project={}", - instance_name, PROJECT_NAME + "/v1/instances/{instance_name}/external-ips?project={project_name}", ); let ips = NexusRequest::object_get(client, &ips_url) .authn_as(AuthnMode::PrivilegedUser) @@ -4232,10 +4237,10 @@ async fn fetch_instance_ephemeral_ip( client: &ClientTestContext, instance_name: &str, ) -> views::ExternalIp { - fetch_instance_external_ips(client, instance_name) + fetch_instance_external_ips(client, instance_name, PROJECT_NAME) .await .into_iter() - .find(|v| v.kind == IpKind::Ephemeral) + .find(|v| v.kind() == IpKind::Ephemeral) .unwrap() } @@ -4300,7 +4305,7 @@ async fn test_instance_create_in_silo(cptestctx: &ControlPlaneTestContext) { ssh_keys: None, network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, external_ips: vec![params::ExternalIpCreate::Ephemeral { - pool_name: Some(Name::try_from(String::from("default")).unwrap()), + pool: Some("default".parse::().unwrap().into()), }], disks: vec![], start: true, diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 6cb99b9e458..4b68a6c4f2d 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -40,7 +40,6 @@ mod sp_updater; mod ssh_keys; mod subnet_allocation; mod switch_port; -mod system_updates; mod unauthorized; mod unauthorized_coverage; mod updates; diff --git a/nexus/tests/integration_tests/saml.rs b/nexus/tests/integration_tests/saml.rs index fc04bbf9089..b1b0429c2e2 100644 --- a/nexus/tests/integration_tests/saml.rs +++ b/nexus/tests/integration_tests/saml.rs @@ -964,12 +964,33 @@ fn test_reject_unsigned_saml_response() { assert!(result.is_err()); } -// Test rejecting a correct SAML response that contains a XML comment in -// saml:NameID. +// Test accepting a correct SAML response that contains a XML comment in +// saml:NameID, and ensuring that the full text node is extracted (and not a +// substring). // -// See: https://duo.com/blog/duo-finds-saml-vulnerabilities-affecting-multiple-implementations +// This used to be a test that _rejected_ such responses, but a change to an +// upstream dependency (quick-xml) caused the behavior around text nodes with +// embedded comments to change. Specifically, consider: +// +// user@example.com.evil.com +// +// What should the text node for this element be? +// +// * Some XML parsing libraries just return "user@example.com". That leads to a +// vulnerability, where an attacker can get a response signed with a +// different email address than intended. +// * Some XML libraries return "user@example.com.evil.com". This is safe, +// because the text after the comment hasn't been dropped. This is the behavior +// with quick-xml 0.30, and the one that we're testing here. +// * Some XML libraries are unable to deserialize the document. This is also +// safe (and not particularly problematic because typically SAML responses +// aren't going to contain comments), and was the behavior with quick-xml +// 0.23. +// +// See: +// https://duo.com/blog/duo-finds-saml-vulnerabilities-affecting-multiple-implementations #[test] -fn test_reject_saml_response_with_xml_comment() { +fn test_handle_saml_response_with_xml_comment() { let silo_saml_identity_provider = SamlIdentityProvider { idp_metadata_document_string: SAML_RESPONSE_IDP_DESCRIPTOR.to_string(), @@ -1004,7 +1025,9 @@ fn test_reject_saml_response_with_xml_comment() { ), ); - assert!(result.is_err()); + let (authenticated_subject, _) = + result.expect("expected validation to succeed"); + assert_eq!(authenticated_subject.external_id, "some@customer.com"); } // Test receiving a correct SAML response that has group attributes diff --git a/nexus/tests/integration_tests/subnet_allocation.rs b/nexus/tests/integration_tests/subnet_allocation.rs index fa80c90a7c2..9749086d471 100644 --- a/nexus/tests/integration_tests/subnet_allocation.rs +++ b/nexus/tests/integration_tests/subnet_allocation.rs @@ -144,6 +144,7 @@ async fn test_subnet_allocation(cptestctx: &ControlPlaneTestContext) { Vec::::new(), // External IPs= Vec::::new(), + true, ) .await; } diff --git a/nexus/tests/integration_tests/system_updates.rs b/nexus/tests/integration_tests/system_updates.rs deleted file mode 100644 index aa00caac297..00000000000 --- a/nexus/tests/integration_tests/system_updates.rs +++ /dev/null @@ -1,219 +0,0 @@ -// 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/. - -use dropshot::ResultsPage; -use http::{method::Method, StatusCode}; -use nexus_db_queries::context::OpContext; -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; -use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::{ - params, shared::UpdateableComponentType, views, -}; -use omicron_common::api::external::SemverVersion; - -type ControlPlaneTestContext = - nexus_test_utils::ControlPlaneTestContext; - -// This file could be combined with ./updates.rs, but there's a lot going on in -// there that has nothing to do with testing the API endpoints. We could come up -// with more descriptive names. - -/// Because there are no create endpoints for these resources, we need to call -/// the `nexus` functions directly. -async fn populate_db(cptestctx: &ControlPlaneTestContext) { - let nexus = &cptestctx.server.apictx().nexus; - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - cptestctx.server.apictx().nexus.datastore().clone(), - ); - - // system updates have to exist first - let create_su = - params::SystemUpdateCreate { version: SemverVersion::new(0, 2, 0) }; - nexus - .upsert_system_update(&opctx, create_su) - .await - .expect("Failed to create system update"); - let create_su = - params::SystemUpdateCreate { version: SemverVersion::new(1, 0, 1) }; - nexus - .upsert_system_update(&opctx, create_su) - .await - .expect("Failed to create system update"); - - nexus - .create_updateable_component( - &opctx, - params::UpdateableComponentCreate { - version: SemverVersion::new(0, 4, 1), - system_version: SemverVersion::new(0, 2, 0), - component_type: UpdateableComponentType::BootloaderForSp, - device_id: "look-a-device".to_string(), - }, - ) - .await - .expect("failed to create updateable component"); - - nexus - .create_updateable_component( - &opctx, - params::UpdateableComponentCreate { - version: SemverVersion::new(0, 4, 1), - system_version: SemverVersion::new(1, 0, 1), - component_type: UpdateableComponentType::HubrisForGimletSp, - device_id: "another-device".to_string(), - }, - ) - .await - .expect("failed to create updateable component"); -} - -#[nexus_test] -async fn test_system_version(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // Initially the endpoint 500s because there are no updateable components. - // This is the desired behavior because those are populated by rack startup - // before the external API starts, so it really is a problem if we can hit - // this endpoint without any data backing it. - // - // Because this data is now populated at rack init, this doesn't work as a - // test. If we really wanted to test it, we would have to run the tests - // without that bit of setup. - // - // NexusRequest::expect_failure( - // &client, - // StatusCode::INTERNAL_SERVER_ERROR, - // Method::GET, - // "/v1/system/update/version", - // ) - // .authn_as(AuthnMode::PrivilegedUser) - // .execute() - // .await - // .expect("Failed to 500 with no system version data"); - - // create two updateable components - populate_db(&cptestctx).await; - - let version = - NexusRequest::object_get(&client, "/v1/system/update/version") - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::() - .await; - - assert_eq!( - version, - views::SystemVersion { - version_range: views::VersionRange { - low: SemverVersion::new(0, 2, 0), - high: SemverVersion::new(2, 0, 0), - }, - status: views::UpdateStatus::Updating, - } - ); -} - -#[nexus_test] -async fn test_list_updates(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let updates = - NexusRequest::object_get(&client, &"/v1/system/update/updates") - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::>() - .await; - - assert_eq!(updates.items.len(), 3); -} - -#[nexus_test] -async fn test_list_components(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let component_updates = - NexusRequest::object_get(&client, &"/v1/system/update/components") - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::>() - .await; - - assert_eq!(component_updates.items.len(), 9); -} - -#[nexus_test] -async fn test_get_update(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // existing update works - let update = - NexusRequest::object_get(&client, &"/v1/system/update/updates/1.0.0") - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::() - .await; - - assert_eq!(update.version, SemverVersion::new(1, 0, 0)); - - // non-existent update 404s - NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - Method::GET, - "/v1/system/update/updates/1.0.1", - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to 404 on non-existent update"); -} - -#[nexus_test] -async fn test_list_update_components(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // listing components of an existing update works - let components = NexusRequest::object_get( - &client, - &"/v1/system/update/updates/1.0.0/components", - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::>() - .await; - - assert_eq!(components.items.len(), 9); - - // non existent 404s - NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - Method::GET, - "/v1/system/update/updates/1.0.1/components", - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to 404 on components of nonexistent system update"); -} - -#[nexus_test] -async fn test_update_deployments(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let deployments = - NexusRequest::object_get(&client, &"/v1/system/update/deployments") - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::>() - .await; - - assert_eq!(deployments.items.len(), 2); - - let first_dep = deployments.items.get(0).unwrap(); - - let dep_id = first_dep.identity.id.to_string(); - let dep_url = format!("/v1/system/update/deployments/{}", dep_id); - let deployment = NexusRequest::object_get(&client, &dep_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::() - .await; - - assert_eq!(deployment.version, first_dep.version); -} diff --git a/nexus/tests/integration_tests/updates.rs b/nexus/tests/integration_tests/updates.rs index 418e12e001f..e8303481037 100644 --- a/nexus/tests/integration_tests/updates.rs +++ b/nexus/tests/integration_tests/updates.rs @@ -7,69 +7,49 @@ // - test that an unknown artifact returns 404, not 500 // - tests around target names and artifact names that contain dangerous paths like `../` -use async_trait::async_trait; -use camino_tempfile::Utf8TempDir; -use chrono::{Duration, Utc}; +use anyhow::{ensure, Context, Result}; +use camino::Utf8Path; +use camino_tempfile::{Builder, Utf8TempDir, Utf8TempPath}; +use clap::Parser; use dropshot::test_util::LogContext; -use dropshot::{ - endpoint, ApiDescription, HttpError, HttpServerStarter, Path, - RequestContext, -}; -use http::{Method, Response, StatusCode}; -use hyper::Body; +use http::{Method, StatusCode}; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use nexus_test_utils::{load_test_config, test_setup, test_setup_with_config}; +use omicron_common::api::external::{ + SemverVersion, TufRepoGetResponse, TufRepoInsertResponse, + TufRepoInsertStatus, +}; use omicron_common::api::internal::nexus::KnownArtifactKind; use omicron_common::nexus_config::UpdatesConfig; -use omicron_common::update::{Artifact, ArtifactKind, ArtifactsDocument}; use omicron_sled_agent::sim; -use ring::pkcs8::Document; -use ring::rand::{SecureRandom, SystemRandom}; -use ring::signature::Ed25519KeyPair; -use schemars::JsonSchema; +use pretty_assertions::assert_eq; use serde::Deserialize; -use std::collections::HashMap; -use std::convert::TryInto; -use std::fmt::{self, Debug}; +use std::fmt::Debug; use std::fs::File; use std::io::Write; -use std::num::NonZeroU64; -use std::path::PathBuf; -use tempfile::{NamedTempFile, TempDir}; -use tough::editor::signed::{PathExists, SignedRole}; -use tough::editor::RepositoryEditor; -use tough::key_source::KeySource; -use tough::schema::{KeyHolder, RoleKeys, RoleType, Root}; -use tough::sign::Sign; +use tufaceous_lib::assemble::{DeserializedManifest, ManifestTweak}; -const UPDATE_COMPONENT: &'static str = "omicron-test-component"; +const FAKE_MANIFEST_PATH: &'static str = "../tufaceous/manifests/fake.toml"; -#[tokio::test] -async fn test_update_end_to_end() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_update_uninitialized() -> Result<()> { let mut config = load_test_config(); - let logctx = LogContext::new("test_update_end_to_end", &config.pkg.log); + let logctx = LogContext::new("test_update_uninitialized", &config.pkg.log); + + // Build a fake TUF repo + let temp_dir = Utf8TempDir::new()?; + let archive_path = temp_dir.path().join("archive.zip"); + + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + FAKE_MANIFEST_PATH, + archive_path.as_str(), + ]) + .context("error parsing args")?; + + args.exec(&logctx.log).await.context("error executing assemble command")?; - // build the TUF repo - let rng = SystemRandom::new(); - let tuf_repo = new_tuf_repo(&rng).await; - slog::info!(logctx.log, "TUF repo created at {}", tuf_repo.path()); - - // serve it over HTTP - let dropshot_config = Default::default(); - let mut api = ApiDescription::new(); - api.register(static_content).unwrap(); - let context = FileServerContext { base: tuf_repo.path().to_owned().into() }; - let server = - HttpServerStarter::new(&dropshot_config, api, context, &logctx.log) - .unwrap() - .start(); - let local_addr = server.local_addr(); - - // stand up the test environment - config.pkg.updates = Some(UpdatesConfig { - trusted_root: tuf_repo.path().join("metadata").join("1.root.json"), - default_base_url: format!("http://{}/", local_addr), - }); let cptestctx = test_setup_with_config::( "test_update_end_to_end", &mut config, @@ -79,212 +59,304 @@ async fn test_update_end_to_end() { .await; let client = &cptestctx.external_client; - // call /v1/system/update/refresh on nexus - // - download and verify the repo - // - return 204 Non Content - // - tells sled agent to do the thing - NexusRequest::new( - RequestBuilder::new(client, Method::POST, "/v1/system/update/refresh") - .expect_status(Some(StatusCode::NO_CONTENT)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap(); + // Attempt to upload the repository to Nexus. This should fail with a 500 + // error because the updates system is not configured. + { + make_upload_request( + client, + &archive_path, + StatusCode::INTERNAL_SERVER_ERROR, + ) + .execute() + .await + .context("repository upload should have failed with 500 error")?; + } - let artifact_path = cptestctx.sled_agent_storage.path(); - let component_path = artifact_path.join(UPDATE_COMPONENT); - // check sled agent did the thing - assert_eq!(tokio::fs::read(component_path).await.unwrap(), TARGET_CONTENTS); + // Attempt to fetch a repository description from Nexus. This should also + // fail with a 500 error. + { + make_get_request( + client, + "1.0.0".parse().unwrap(), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .execute() + .await + .context("repository fetch should have failed with 500 error")?; + } - server.close().await.expect("failed to shut down dropshot server"); cptestctx.teardown().await; logctx.cleanup_successful(); + + Ok(()) } -// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_update_end_to_end() -> Result<()> { + let mut config = load_test_config(); + config.pkg.updates = Some(UpdatesConfig { + // XXX: This is currently not used by the update system, but + // trusted_root will become meaningful in the future. + trusted_root: "does-not-exist.json".into(), + }); + let logctx = LogContext::new("test_update_end_to_end", &config.pkg.log); -struct FileServerContext { - base: PathBuf, -} + // Build a fake TUF repo + let temp_dir = Utf8TempDir::new()?; + let archive_path = temp_dir.path().join("archive.zip"); -#[derive(Deserialize, JsonSchema)] -struct AllPath { - path: Vec, -} + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + FAKE_MANIFEST_PATH, + archive_path.as_str(), + ]) + .context("error parsing args")?; -#[endpoint(method = GET, path = "/{path:.*}", unpublished = true)] -async fn static_content( - rqctx: RequestContext, - path: Path, -) -> Result, HttpError> { - // NOTE: this is a particularly brief and bad implementation of this to keep the test shorter. - // see https://github.com/oxidecomputer/dropshot/blob/main/dropshot/examples/file_server.rs for - // something more robust! - let mut fs_path = rqctx.context().base.clone(); - for component in path.into_inner().path { - fs_path.push(component); - } - let body = tokio::fs::read(fs_path).await.map_err(|e| { - // tough 0.15+ depend on ENOENT being translated into 404. - if e.kind() == std::io::ErrorKind::NotFound { - HttpError::for_not_found(None, e.to_string()) - } else { - HttpError::for_bad_request(None, e.to_string()) - } - })?; - Ok(Response::builder().status(StatusCode::OK).body(body.into())?) -} + args.exec(&logctx.log).await.context("error executing assemble command")?; -// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + let cptestctx = test_setup_with_config::( + "test_update_end_to_end", + &mut config, + sim::SimMode::Explicit, + None, + ) + .await; + let client = &cptestctx.external_client; -const TARGET_CONTENTS: &[u8] = b"hello world".as_slice(); - -async fn new_tuf_repo(rng: &(dyn SecureRandom + Sync)) -> Utf8TempDir { - let version = - NonZeroU64::new(Utc::now().timestamp().try_into().unwrap()).unwrap(); - let expires = Utc::now() + Duration::minutes(5); - - // create the key - let key_data = Ed25519KeyPair::generate_pkcs8(rng).unwrap(); - let key = Ed25519KeyPair::from_pkcs8(key_data.as_ref()).unwrap(); - let tuf_key = key.tuf_key(); - let key_id = tuf_key.key_id().unwrap(); - - // create the root role - let mut root = Root { - spec_version: "1.0.0".to_string(), - consistent_snapshot: true, - version: NonZeroU64::new(1).unwrap(), - expires, - keys: HashMap::new(), - roles: HashMap::new(), - _extra: HashMap::new(), + // Upload the repository to Nexus. + let mut initial_description = { + let response = + make_upload_request(client, &archive_path, StatusCode::OK) + .execute() + .await + .context("error uploading repository")?; + + let response = + serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoInsertStatus::Inserted); + response.recorded }; - root.keys.insert(key_id.clone(), tuf_key); - for role in [ - RoleType::Root, - RoleType::Snapshot, - RoleType::Targets, - RoleType::Timestamp, - ] { - root.roles.insert( - role, - RoleKeys { - keyids: vec![key_id.clone()], - threshold: NonZeroU64::new(1).unwrap(), - _extra: HashMap::new(), - }, - ); - } - - let signing_keys = - vec![Box::new(KeyKeySource(key_data)) as Box]; - // self-sign the root role - let signed_root = SignedRole::new( - root.clone(), - &KeyHolder::Root(root), - &signing_keys, - rng, - ) - .await - .unwrap(); + // Upload the repository to Nexus again. This should return a 200 with an + // `AlreadyExists` status. + let mut reupload_description = { + let response = + make_upload_request(client, &archive_path, StatusCode::OK) + .execute() + .await + .context("error uploading repository a second time")?; + + let response = + serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoInsertStatus::AlreadyExists); + response.recorded + }; - // TODO(iliana): there's no way to create a `RepositoryEditor` without having the root.json on - // disk. this is really unergonomic. write and upstream a fix - let mut root_tmp = NamedTempFile::new().unwrap(); - root_tmp.as_file_mut().write_all(signed_root.buffer()).unwrap(); - let mut editor = RepositoryEditor::new(&root_tmp).await.unwrap(); - root_tmp.close().unwrap(); - - editor - .targets_version(version) - .unwrap() - .targets_expires(expires) - .unwrap() - .snapshot_version(version) - .snapshot_expires(expires) - .timestamp_version(version) - .timestamp_expires(expires); - let (targets_dir, target_names) = generate_targets(); - for target in target_names { - editor.add_target_path(targets_dir.path().join(target)).await.unwrap(); - } + initial_description.sort_artifacts(); + reupload_description.sort_artifacts(); - let signed_repo = editor.sign(&signing_keys).await.unwrap(); + assert_eq!( + initial_description, reupload_description, + "initial description matches reupload" + ); - let repo = Utf8TempDir::new().unwrap(); - signed_repo.write(repo.path().join("metadata")).await.unwrap(); - signed_repo - .copy_targets( - targets_dir, - repo.path().join("targets"), - PathExists::Fail, + // Now get the repository that was just uploaded. + let mut get_description = { + let response = make_get_request( + client, + "1.0.0".parse().unwrap(), // this is the system version of the fake manifest + StatusCode::OK, ) + .execute() .await - .unwrap(); - - repo -} + .context("error fetching repository")?; -// Returns a temporary directory of targets and the list of filenames in it. -fn generate_targets() -> (TempDir, Vec<&'static str>) { - let dir = TempDir::new().unwrap(); - - // The update artifact. This will someday be a tarball of some variety. - std::fs::write( - dir.path().join(format!("{UPDATE_COMPONENT}-1")), - TARGET_CONTENTS, - ) - .unwrap(); - - // artifacts.json, which describes all available artifacts. - let artifacts = ArtifactsDocument { - system_version: "1.0.0".parse().unwrap(), - artifacts: vec![Artifact { - name: UPDATE_COMPONENT.into(), - version: "0.0.0".parse().unwrap(), - kind: ArtifactKind::from_known(KnownArtifactKind::ControlPlane), - target: format!("{UPDATE_COMPONENT}-1"), - }], + let response = + serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + response.description }; - let f = File::create(dir.path().join("artifacts.json")).unwrap(); - serde_json::to_writer_pretty(f, &artifacts).unwrap(); - (dir, vec!["omicron-test-component-1", "artifacts.json"]) -} + get_description.sort_artifacts(); -// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + assert_eq!( + initial_description, get_description, + "initial description matches fetched description" + ); -// Wrapper struct so that we can use an in-memory key as a key source. -// TODO(iliana): this should just be in tough with a lot less hacks -struct KeyKeySource(Document); + // TODO: attempt to download extracted artifacts. -impl Debug for KeyKeySource { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("KeyKeySource").finish() + // Upload a new repository with the same system version but a different + // version for one of the components. This will produce a different hash, + // which should return an error. + { + let tweaks = &[ManifestTweak::ArtifactVersion { + kind: KnownArtifactKind::GimletSp, + version: "2.0.0".parse().unwrap(), + }]; + let archive_path = + make_tweaked_archive(&logctx.log, &temp_dir, tweaks).await?; + + let response = make_upload_request( + client, + &archive_path, + StatusCode::CONFLICT, + ) + .execute() + .await + .context( + "error uploading repository with different artifact version \ + but same system version", + )?; + assert_error_message_contains( + &response.body, + "Uploaded repository with system version 1.0.0 has SHA256 hash", + )?; } -} -#[async_trait] -impl KeySource for KeyKeySource { - async fn as_sign( - &self, - ) -> Result, Box> + // Upload a new repository with a different system version and different + // contents (but same version) for an artifact. { - // this is a really ugly hack, because tough doesn't `impl Sign for &'a T where T: Sign`. - // awslabs/tough#446 - Ok(Box::new(Ed25519KeyPair::from_pkcs8(self.0.as_ref()).unwrap())) + let tweaks = &[ + ManifestTweak::SystemVersion("2.0.0".parse().unwrap()), + ManifestTweak::ArtifactContents { + kind: KnownArtifactKind::ControlPlane, + size_delta: 1024, + }, + ]; + let archive_path = + make_tweaked_archive(&logctx.log, &temp_dir, tweaks).await?; + + let response = + make_upload_request(client, &archive_path, StatusCode::CONFLICT) + .execute() + .await + .context( + "error uploading repository with artifact \ + containing different hash for same version", + )?; + assert_error_message_contains( + &response.body, + "Uploaded artifacts don't match existing artifacts with same IDs:", + )?; } - async fn write( - &self, - _value: &str, - _key_id_hex: &str, - ) -> Result<(), Box> { - unimplemented!(); + // Upload a new repository with a different system version but no other + // changes. This should be accepted. + { + let tweaks = &[ManifestTweak::SystemVersion("2.0.0".parse().unwrap())]; + let archive_path = + make_tweaked_archive(&logctx.log, &temp_dir, tweaks).await?; + + let response = + make_upload_request(client, &archive_path, StatusCode::OK) + .execute() + .await + .context("error uploading repository with different system version (should succeed)")?; + + let response = + serde_json::from_slice::(&response.body) + .context("error deserializing response body")?; + assert_eq!(response.status, TufRepoInsertStatus::Inserted); } + + cptestctx.teardown().await; + logctx.cleanup_successful(); + + Ok(()) +} + +async fn make_tweaked_archive( + log: &slog::Logger, + temp_dir: &Utf8TempDir, + tweaks: &[ManifestTweak], +) -> anyhow::Result { + let manifest = DeserializedManifest::tweaked_fake(tweaks); + let manifest_path = temp_dir.path().join("fake2.toml"); + let mut manifest_file = + File::create(&manifest_path).context("error creating manifest file")?; + let manifest_to_toml = manifest.to_toml()?; + manifest_file.write_all(manifest_to_toml.as_bytes())?; + + let archive_path = Builder::new() + .prefix("archive") + .suffix(".zip") + .tempfile_in(temp_dir.path()) + .context("error creating temp file for tweaked archive")? + .into_temp_path(); + + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + manifest_path.as_str(), + archive_path.as_str(), + ]) + .context("error parsing args")?; + + args.exec(log).await.context("error executing assemble command")?; + + Ok(archive_path) +} + +fn make_upload_request<'a>( + client: &'a dropshot::test_util::ClientTestContext, + archive_path: &'a Utf8Path, + expected_status: StatusCode, +) -> NexusRequest<'a> { + let file_name = + archive_path.file_name().expect("archive_path must have a file name"); + let request = NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &format!("/v1/system/update/repository?file_name={}", file_name), + ) + .body_file(Some(archive_path)) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser); + request +} + +fn make_get_request( + client: &dropshot::test_util::ClientTestContext, + system_version: SemverVersion, + expected_status: StatusCode, +) -> NexusRequest<'_> { + let request = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!("/v1/system/update/repository/{system_version}"), + ) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::PrivilegedUser); + request +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + message: String, +} + +// XXX: maybe replace this with a more detailed error code +fn assert_error_message_contains( + body: &[u8], + needle: &str, +) -> anyhow::Result<()> { + let body: ErrorBody = + serde_json::from_slice(body).context("body is not valid JSON")?; + ensure!( + body.message.contains(needle), + "expected body to contain {:?}, but it was {:?}", + needle, + body + ); + Ok(()) } // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index bd79a9c3e94..8bd2f34de5c 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -12,8 +12,10 @@ disk_view GET /v1/disks/{disk} API operations found with tag "floating-ips" OPERATION ID METHOD URL PATH +floating_ip_attach POST /v1/floating-ips/{floating_ip}/attach floating_ip_create POST /v1/floating-ips floating_ip_delete DELETE /v1/floating-ips/{floating_ip} +floating_ip_detach POST /v1/floating-ips/{floating_ip}/detach floating_ip_list GET /v1/floating-ips floating_ip_view GET /v1/floating-ips/{floating_ip} @@ -40,6 +42,8 @@ instance_delete DELETE /v1/instances/{instance} instance_disk_attach POST /v1/instances/{instance}/disks/attach instance_disk_detach POST /v1/instances/{instance}/disks/detach instance_disk_list GET /v1/instances/{instance}/disks +instance_ephemeral_ip_attach POST /v1/instances/{instance}/external-ips/ephemeral +instance_ephemeral_ip_detach DELETE /v1/instances/{instance}/external-ips/ephemeral instance_external_ip_list GET /v1/instances/{instance}/external-ips instance_list GET /v1/instances instance_migrate POST /v1/instances/{instance}/migrate diff --git a/nexus/tests/output/unexpected-authz-endpoints.txt b/nexus/tests/output/unexpected-authz-endpoints.txt index 1cd87a75e52..e8bb60224a6 100644 --- a/nexus/tests/output/unexpected-authz-endpoints.txt +++ b/nexus/tests/output/unexpected-authz-endpoints.txt @@ -9,13 +9,5 @@ POST "/v1/vpc-router-routes?project=demo-project&vpc=demo-vpc&router=demo-vpc- GET "/v1/vpc-router-routes/demo-router-route?project=demo-project&vpc=demo-vpc&router=demo-vpc-router" PUT "/v1/vpc-router-routes/demo-router-route?project=demo-project&vpc=demo-vpc&router=demo-vpc-router" DELETE "/v1/vpc-router-routes/demo-router-route?project=demo-project&vpc=demo-vpc&router=demo-vpc-router" -POST "/v1/system/update/refresh" -GET "/v1/system/update/version" -GET "/v1/system/update/components" -GET "/v1/system/update/updates" -GET "/v1/system/update/updates/1.0.0" -GET "/v1/system/update/updates/1.0.0/components" -POST "/v1/system/update/start" -POST "/v1/system/update/stop" -GET "/v1/system/update/deployments" -GET "/v1/system/update/deployments/120bbb6f-660a-440c-8cb7-199be202ddff" +PUT "/v1/system/update/repository?file_name=demo-repo.zip" +GET "/v1/system/update/repository/1.0.0" diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 2cace78370b..a20633c704d 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -71,7 +71,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!(FloatingIpPath, floating_ip, "floating IP"); path_param!(DiskPath, disk, "disk"); path_param!(SnapshotPath, snapshot, "snapshot"); path_param!(ImagePath, image, "image"); @@ -890,6 +890,23 @@ pub struct FloatingIpCreate { pub pool: Option, } +/// The type of resource that a floating IP is attached to +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FloatingIpParentKind { + Instance, +} + +/// Parameters for attaching a floating IP address to another resource +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct FloatingIpAttach { + /// Name or ID of the resource that this IP address should be attached to + pub parent: NameOrId, + + /// The type of `parent`'s resource + pub kind: FloatingIpParentKind, +} + // INSTANCES /// Describes an attachment of an `InstanceNetworkInterface` to an `Instance`, @@ -954,14 +971,30 @@ pub struct InstanceDiskAttach { #[serde(tag = "type", rename_all = "snake_case")] pub enum ExternalIpCreate { /// An IP address providing both inbound and outbound access. The address is - /// automatically-assigned from the provided IP Pool, or all available pools - /// if not specified. - Ephemeral { pool_name: Option }, + /// automatically-assigned from the provided IP Pool, or the current silo's + /// default pool if not specified. + Ephemeral { pool: Option }, /// An IP address providing both inbound and outbound access. The address is - /// an existing Floating IP object assigned to the current project. + /// 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 }, + Floating { floating_ip: NameOrId }, +} + +/// Parameters for creating an ephemeral IP address for an instance. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub struct EphemeralIpCreate { + /// Name or ID of the IP pool used to allocate an address + pub pool: Option, +} + +/// Parameters for detaching an external IP from an instance. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExternalIpDetach { + Ephemeral, + Floating { floating_ip: NameOrId }, } /// Create-time parameters for an `Instance` @@ -1932,32 +1965,17 @@ pub struct ResourceMetrics { // SYSTEM UPDATE +/// Parameters for PUT requests for `/v1/system/update/repository`. #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SystemUpdatePath { - pub version: SemverVersion, +pub struct UpdatesPutRepositoryParams { + /// The name of the uploaded file. + pub file_name: String, } -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SystemUpdateStart { - pub version: SemverVersion, -} +/// Parameters for GET requests for `/v1/system/update/repository`. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SystemUpdateCreate { - pub version: SemverVersion, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct ComponentUpdateCreate { - pub version: SemverVersion, - pub component_type: shared::UpdateableComponentType, - pub system_update_id: Uuid, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateableComponentCreate { - pub version: SemverVersion, +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct UpdatesGetRepositoryParams { + /// The version to get. pub system_version: SemverVersion, - pub component_type: shared::UpdateableComponentType, - pub device_id: String, } diff --git a/nexus/types/src/external_api/shared.rs b/nexus/types/src/external_api/shared.rs index a4c5ae1e629..f6b4db18a37 100644 --- a/nexus/types/src/external_api/shared.rs +++ b/nexus/types/src/external_api/shared.rs @@ -221,7 +221,9 @@ pub enum ServiceUsingCertificate { } /// The kind of an external IP address for an instance -#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)] +#[derive( + Debug, Clone, Copy, Deserialize, Eq, Serialize, JsonSchema, PartialEq, +)] #[serde(rename_all = "snake_case")] pub enum IpKind { Ephemeral, diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 38a2171c7c7..84648f109f3 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -12,8 +12,8 @@ use api_identity::ObjectIdentity; use chrono::DateTime; use chrono::Utc; use omicron_common::api::external::{ - ByteCount, Digest, IdentityMetadata, InstanceState, Ipv4Net, Ipv6Net, Name, - ObjectIdentity, RoleName, SemverVersion, SimpleIdentity, + ByteCount, Digest, Error, IdentityMetadata, InstanceState, Ipv4Net, + Ipv6Net, Name, ObjectIdentity, RoleName, SimpleIdentity, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -337,16 +337,34 @@ pub struct IpPoolRange { // INSTANCE EXTERNAL IP ADDRESSES -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct ExternalIp { - pub ip: IpAddr, - pub kind: IpKind, +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExternalIp { + Ephemeral { ip: IpAddr }, + Floating(FloatingIp), +} + +impl ExternalIp { + pub fn ip(&self) -> IpAddr { + match self { + Self::Ephemeral { ip } => *ip, + Self::Floating(float) => float.ip, + } + } + + pub fn kind(&self) -> IpKind { + match self { + Self::Ephemeral { .. } => IpKind::Ephemeral, + Self::Floating(_) => IpKind::Floating, + } + } } /// A Floating IP is a well-known IP address which can be attached /// and detached from instances. -#[derive(ObjectIdentity, Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive( + ObjectIdentity, Debug, PartialEq, Clone, Deserialize, Serialize, JsonSchema, +)] #[serde(rename_all = "snake_case")] pub struct FloatingIp { #[serde(flatten)] @@ -360,6 +378,25 @@ pub struct FloatingIp { pub instance_id: Option, } +impl From for ExternalIp { + fn from(value: FloatingIp) -> Self { + ExternalIp::Floating(value) + } +} + +impl TryFrom for FloatingIp { + type Error = Error; + + fn try_from(value: ExternalIp) -> Result { + match value { + ExternalIp::Ephemeral { .. } => Err(Error::internal_error( + "tried to convert an ephemeral IP into a floating IP", + )), + ExternalIp::Floating(v) => Ok(v), + } + } +} + // RACKS /// View of an Rack @@ -573,65 +610,6 @@ pub enum DeviceAccessTokenType { Bearer, } -// SYSTEM UPDATES - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -pub struct VersionRange { - pub low: SemverVersion, - pub high: SemverVersion, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum UpdateStatus { - Updating, - Steady, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -pub struct SystemVersion { - pub version_range: VersionRange, - pub status: UpdateStatus, - // TODO: time_released? time_last_applied? I got a fever and the only - // prescription is more timestamps -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SystemUpdate { - #[serde(flatten)] - pub identity: AssetIdentityMetadata, - pub version: SemverVersion, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct ComponentUpdate { - #[serde(flatten)] - pub identity: AssetIdentityMetadata, - - pub component_type: shared::UpdateableComponentType, - pub version: SemverVersion, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateableComponent { - #[serde(flatten)] - pub identity: AssetIdentityMetadata, - - pub device_id: String, - pub component_type: shared::UpdateableComponentType, - pub version: SemverVersion, - pub system_version: SemverVersion, - pub status: UpdateStatus, -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateDeployment { - #[serde(flatten)] - pub identity: AssetIdentityMetadata, - pub version: SemverVersion, - pub status: UpdateStatus, -} - // SYSTEM HEALTH #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/openapi/bootstrap-agent.json b/openapi/bootstrap-agent.json index 2a7ff432024..6fd83cef477 100644 --- a/openapi/bootstrap-agent.json +++ b/openapi/bootstrap-agent.json @@ -355,6 +355,7 @@ ] }, "Certificate": { + "description": "Certificate\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"cert\", \"key\" ], \"properties\": { \"cert\": { \"type\": \"string\" }, \"key\": { \"type\": \"string\" } } } ```
", "type": "object", "properties": { "cert": { @@ -903,6 +904,7 @@ "format": "uuid" }, "RecoverySiloConfig": { + "description": "RecoverySiloConfig\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"silo_name\", \"user_name\", \"user_password_hash\" ], \"properties\": { \"silo_name\": { \"$ref\": \"#/components/schemas/Name\" }, \"user_name\": { \"$ref\": \"#/components/schemas/UserId\" }, \"user_password_hash\": { \"$ref\": \"#/components/schemas/NewPasswordHash\" } } } ```
", "type": "object", "properties": { "silo_name": { @@ -967,7 +969,7 @@ ] }, "UserId": { - "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.", + "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.\n\n
JSON schema\n\n```json { \"title\": \"A name unique within the parent collection\", \"description\": \"Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.\", \"type\": \"string\", \"maxLength\": 63, \"minLength\": 1, \"pattern\": \"^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z]([a-zA-Z0-9-]*[a-zA-Z0-9]+)?$\" } ```
", "type": "string" } }, diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index b5cbb25c662..8b0807d52c6 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -18,10 +18,10 @@ { "in": "path", "name": "kind", - "description": "The kind of update artifact this is.", + "description": "The kind of artifact this is.", "required": true, "schema": { - "$ref": "#/components/schemas/KnownArtifactKind" + "type": "string" } }, { @@ -3218,6 +3218,7 @@ ] }, "DnsConfigParams": { + "description": "DnsConfigParams\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"generation\", \"time_created\", \"zones\" ], \"properties\": { \"generation\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"time_created\": { \"type\": \"string\", \"format\": \"date-time\" }, \"zones\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/DnsConfigZone\" } } } } ```
", "type": "object", "properties": { "generation": { @@ -3243,6 +3244,7 @@ ] }, "DnsConfigZone": { + "description": "DnsConfigZone\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"records\", \"zone_name\" ], \"properties\": { \"records\": { \"type\": \"object\", \"additionalProperties\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/DnsRecord\" } } }, \"zone_name\": { \"type\": \"string\" } } } ```
", "type": "object", "properties": { "records": { @@ -3264,6 +3266,7 @@ ] }, "DnsRecord": { + "description": "DnsRecord\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"type\": \"string\", \"format\": \"ipv4\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"A\" ] } } }, { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"type\": \"string\", \"format\": \"ipv6\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"AAAA\" ] } } }, { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"$ref\": \"#/components/schemas/Srv\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"SRV\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -4189,6 +4192,7 @@ ] }, "IpNet": { + "description": "IpNet\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"title\": \"v4\", \"allOf\": [ { \"$ref\": \"#/components/schemas/Ipv4Net\" } ] }, { \"title\": \"v6\", \"allOf\": [ { \"$ref\": \"#/components/schemas/Ipv6Net\" } ] } ] } ```
", "anyOf": [ { "$ref": "#/components/schemas/Ipv4Net" @@ -4286,7 +4290,7 @@ ] }, "Ipv4Net": { - "description": "An IPv4 subnet, including prefix and subnet mask", + "description": "An IPv4 subnet, including prefix and subnet mask\n\n
JSON schema\n\n```json { \"title\": \"An IPv4 subnet\", \"description\": \"An IPv4 subnet, including prefix and subnet mask\", \"examples\": [ \"192.168.1.0/24\" ], \"type\": \"string\", \"pattern\": \"^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$\" } ```
", "type": "string" }, "Ipv4Network": { @@ -4312,7 +4316,7 @@ ] }, "Ipv6Net": { - "description": "An IPv6 subnet, including prefix and subnet mask", + "description": "An IPv6 subnet, including prefix and subnet mask\n\n
JSON schema\n\n```json { \"title\": \"An IPv6 subnet\", \"description\": \"An IPv6 subnet, including prefix and subnet mask\", \"examples\": [ \"fd12:3456::/64\" ], \"type\": \"string\", \"pattern\": \"^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$\" } ```
", "type": "string" }, "Ipv6Network": { @@ -4654,7 +4658,7 @@ "maxLength": 63 }, "NetworkInterface": { - "description": "Information required to construct a virtual network interface", + "description": "Information required to construct a virtual network interface\n\n
JSON schema\n\n```json { \"description\": \"Information required to construct a virtual network interface\", \"type\": \"object\", \"required\": [ \"id\", \"ip\", \"kind\", \"mac\", \"name\", \"primary\", \"slot\", \"subnet\", \"vni\" ], \"properties\": { \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"ip\": { \"type\": \"string\", \"format\": \"ip\" }, \"kind\": { \"$ref\": \"#/components/schemas/NetworkInterfaceKind\" }, \"mac\": { \"$ref\": \"#/components/schemas/MacAddr\" }, \"name\": { \"$ref\": \"#/components/schemas/Name\" }, \"primary\": { \"type\": \"boolean\" }, \"slot\": { \"type\": \"integer\", \"format\": \"uint8\", \"minimum\": 0.0 }, \"subnet\": { \"$ref\": \"#/components/schemas/IpNet\" }, \"vni\": { \"$ref\": \"#/components/schemas/Vni\" } } } ```
", "type": "object", "properties": { "id": { @@ -4702,7 +4706,7 @@ ] }, "NetworkInterfaceKind": { - "description": "The type of network interface", + "description": "The type of network interface\n\n
JSON schema\n\n```json { \"description\": \"The type of network interface\", \"oneOf\": [ { \"description\": \"A vNIC attached to a guest instance\", \"type\": \"object\", \"required\": [ \"id\", \"type\" ], \"properties\": { \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"instance\" ] } } }, { \"description\": \"A vNIC associated with an internal service\", \"type\": \"object\", \"required\": [ \"id\", \"type\" ], \"properties\": { \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"service\" ] } } } ] } ```
", "oneOf": [ { "description": "A vNIC attached to a guest instance", @@ -4756,7 +4760,7 @@ "type": "string" }, "OmicronZoneConfig": { - "description": "Describes one Omicron-managed zone running on a sled", + "description": "Describes one Omicron-managed zone running on a sled\n\n
JSON schema\n\n```json { \"description\": \"Describes one Omicron-managed zone running on a sled\", \"type\": \"object\", \"required\": [ \"id\", \"underlay_address\", \"zone_type\" ], \"properties\": { \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"underlay_address\": { \"type\": \"string\", \"format\": \"ipv6\" }, \"zone_type\": { \"$ref\": \"#/components/schemas/OmicronZoneType\" } } } ```
", "type": "object", "properties": { "id": { @@ -4778,7 +4782,7 @@ ] }, "OmicronZoneDataset": { - "description": "Describes a persistent ZFS dataset associated with an Omicron zone", + "description": "Describes a persistent ZFS dataset associated with an Omicron zone\n\n
JSON schema\n\n```json { \"description\": \"Describes a persistent ZFS dataset associated with an Omicron zone\", \"type\": \"object\", \"required\": [ \"pool_name\" ], \"properties\": { \"pool_name\": { \"$ref\": \"#/components/schemas/ZpoolName\" } } } ```
", "type": "object", "properties": { "pool_name": { @@ -4790,7 +4794,7 @@ ] }, "OmicronZoneType": { - "description": "Describes what kind of zone this is (i.e., what component is running in it) as well as any type-specific configuration", + "description": "Describes what kind of zone this is (i.e., what component is running in it) as well as any type-specific configuration\n\n
JSON schema\n\n```json { \"description\": \"Describes what kind of zone this is (i.e., what component is running in it) as well as any type-specific configuration\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"address\", \"dns_servers\", \"nic\", \"ntp_servers\", \"snat_cfg\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dns_servers\": { \"type\": \"array\", \"items\": { \"type\": \"string\", \"format\": \"ip\" } }, \"domain\": { \"type\": [ \"string\", \"null\" ] }, \"nic\": { \"description\": \"The service vNIC providing outbound connectivity using OPTE.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/NetworkInterface\" } ] }, \"ntp_servers\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }, \"snat_cfg\": { \"description\": \"The SNAT configuration for outbound connections.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/SourceNatConfig\" } ] }, \"type\": { \"type\": \"string\", \"enum\": [ \"boundary_ntp\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"dataset\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"clickhouse\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"dataset\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"clickhouse_keeper\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"dataset\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"cockroach_db\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"dataset\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"crucible\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"crucible_pantry\" ] } } }, { \"type\": \"object\", \"required\": [ \"dataset\", \"dns_address\", \"http_address\", \"nic\", \"type\" ], \"properties\": { \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"dns_address\": { \"description\": \"The address at which the external DNS server is reachable.\", \"type\": \"string\" }, \"http_address\": { \"description\": \"The address at which the external DNS server API is reachable.\", \"type\": \"string\" }, \"nic\": { \"description\": \"The service vNIC providing external connectivity using OPTE.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/NetworkInterface\" } ] }, \"type\": { \"type\": \"string\", \"enum\": [ \"external_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"dataset\", \"dns_address\", \"gz_address\", \"gz_address_index\", \"http_address\", \"type\" ], \"properties\": { \"dataset\": { \"$ref\": \"#/components/schemas/OmicronZoneDataset\" }, \"dns_address\": { \"type\": \"string\" }, \"gz_address\": { \"description\": \"The addresses in the global zone which should be created\\n\\nFor the DNS service, which exists outside the sleds's typical subnet - adding an address in the GZ is necessary to allow inter-zone traffic routing.\", \"type\": \"string\", \"format\": \"ipv6\" }, \"gz_address_index\": { \"description\": \"The address is also identified with an auxiliary bit of information to ensure that the created global zone address can have a unique name.\", \"type\": \"integer\", \"format\": \"uint32\", \"minimum\": 0.0 }, \"http_address\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"internal_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"dns_servers\", \"ntp_servers\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"dns_servers\": { \"type\": \"array\", \"items\": { \"type\": \"string\", \"format\": \"ip\" } }, \"domain\": { \"type\": [ \"string\", \"null\" ] }, \"ntp_servers\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }, \"type\": { \"type\": \"string\", \"enum\": [ \"internal_ntp\" ] } } }, { \"type\": \"object\", \"required\": [ \"external_dns_servers\", \"external_ip\", \"external_tls\", \"internal_address\", \"nic\", \"type\" ], \"properties\": { \"external_dns_servers\": { \"description\": \"External DNS servers Nexus can use to resolve external hosts.\", \"type\": \"array\", \"items\": { \"type\": \"string\", \"format\": \"ip\" } }, \"external_ip\": { \"description\": \"The address at which the external nexus server is reachable.\", \"type\": \"string\", \"format\": \"ip\" }, \"external_tls\": { \"description\": \"Whether Nexus's external endpoint should use TLS\", \"type\": \"boolean\" }, \"internal_address\": { \"description\": \"The address at which the internal nexus server is reachable.\", \"type\": \"string\" }, \"nic\": { \"description\": \"The service vNIC providing external connectivity using OPTE.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/NetworkInterface\" } ] }, \"type\": { \"type\": \"string\", \"enum\": [ \"nexus\" ] } } }, { \"type\": \"object\", \"required\": [ \"address\", \"type\" ], \"properties\": { \"address\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"oximeter\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -5135,7 +5139,7 @@ ] }, "OmicronZonesConfig": { - "description": "Describes the set of Omicron-managed zones running on a sled", + "description": "Describes the set of Omicron-managed zones running on a sled\n\n
JSON schema\n\n```json { \"description\": \"Describes the set of Omicron-managed zones running on a sled\", \"type\": \"object\", \"required\": [ \"generation\", \"zones\" ], \"properties\": { \"generation\": { \"description\": \"generation number of this configuration\\n\\nThis generation number is owned by the control plane (i.e., RSS or Nexus, depending on whether RSS-to-Nexus handoff has happened). It should not be bumped within Sled Agent.\\n\\nSled Agent rejects attempts to set the configuration to a generation older than the one it's currently running.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/Generation\" } ] }, \"zones\": { \"description\": \"list of running zones\", \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/OmicronZoneConfig\" } } } } ```
", "type": "object", "properties": { "generation": { @@ -6386,6 +6390,7 @@ ] }, "Srv": { + "description": "Srv\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"port\", \"prio\", \"target\", \"weight\" ], \"properties\": { \"port\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 }, \"prio\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 }, \"target\": { \"type\": \"string\" }, \"weight\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 } } } ```
", "type": "object", "properties": { "port": { @@ -6499,7 +6504,7 @@ "minimum": 0 }, "ZpoolName": { - "description": "Zpool names are of the format ox{i,p}_. They are either Internal or External, and should be unique", + "description": "Zpool names are of the format ox{i,p}_. They are either Internal or External, and should be unique\n\n
JSON schema\n\n```json { \"title\": \"The name of a Zpool\", \"description\": \"Zpool names are of the format ox{i,p}_. They are either Internal or External, and should be unique\", \"type\": \"string\", \"pattern\": \"^ox[ip]_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$\" } ```
", "type": "string" }, "ZpoolPutRequest": { @@ -6534,21 +6539,6 @@ "ZpoolPutResponse": { "type": "object" }, - "KnownArtifactKind": { - "description": "Kinds of update artifacts, as used by Nexus to determine what updates are available and by sled-agent to determine how to apply an update when asked.", - "type": "string", - "enum": [ - "gimlet_sp", - "gimlet_rot", - "host", - "trampoline", - "control_plane", - "psc_sp", - "psc_rot", - "switch_sp", - "switch_rot" - ] - }, "SemverVersion": { "type": "string", "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" diff --git a/openapi/nexus.json b/openapi/nexus.json index e86dd1c1c2a..aadb38c3d86 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -930,7 +930,7 @@ { "in": "path", "name": "floating_ip", - "description": "Name or ID of the Floating IP", + "description": "Name or ID of the floating IP", "required": true, "schema": { "$ref": "#/components/schemas/NameOrId" @@ -974,7 +974,7 @@ { "in": "path", "name": "floating_ip", - "description": "Name or ID of the Floating IP", + "description": "Name or ID of the floating IP", "required": true, "schema": { "$ref": "#/components/schemas/NameOrId" @@ -1002,6 +1002,108 @@ } } }, + "/v1/floating-ips/{floating_ip}/attach": { + "post": { + "tags": [ + "floating-ips" + ], + "summary": "Attach a floating IP to an instance or other resource", + "operationId": "floating_ip_attach", + "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" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIpAttach" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/floating-ips/{floating_ip}/detach": { + "post": { + "tags": [ + "floating-ips" + ], + "summary": "Detach a floating IP from an instance or other resource", + "operationId": "floating_ip_detach", + "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": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/groups": { "get": { "tags": [ @@ -1826,6 +1928,99 @@ } } }, + "/v1/instances/{instance}/external-ips/ephemeral": { + "post": { + "tags": [ + "instances" + ], + "summary": "Allocate and attach an ephemeral IP to an instance", + "operationId": "instance_ephemeral_ip_attach", + "parameters": [ + { + "in": "path", + "name": "instance", + "description": "Name or ID of the instance", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EphemeralIpCreate" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "instances" + ], + "summary": "Detach and deallocate an ephemeral IP from an instance", + "operationId": "instance_ephemeral_ip_detach", + "parameters": [ + { + "in": "path", + "name": "instance", + "description": "Name or ID of the instance", + "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/instances/{instance}/migrate": { "post": { "tags": [ @@ -11005,6 +11200,21 @@ } ] }, + "EphemeralIpCreate": { + "description": "Parameters for creating an ephemeral IP address for an instance.", + "type": "object", + "properties": { + "pool": { + "nullable": true, + "description": "Name or ID of the IP pool used to allocate an address", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + } + } + }, "Error": { "description": "Error information from a response.", "type": "object", @@ -11025,33 +11235,105 @@ ] }, "ExternalIp": { - "type": "object", - "properties": { - "ip": { - "type": "string", - "format": "ip" + "oneOf": [ + { + "type": "object", + "properties": { + "ip": { + "type": "string", + "format": "ip" + }, + "kind": { + "type": "string", + "enum": [ + "ephemeral" + ] + } + }, + "required": [ + "ip", + "kind" + ] }, - "kind": { - "$ref": "#/components/schemas/IpKind" + { + "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" + }, + "kind": { + "type": "string", + "enum": [ + "floating" + ] + }, + "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", + "kind", + "name", + "project_id", + "time_created", + "time_modified" + ] } - }, - "required": [ - "ip", - "kind" ] }, "ExternalIpCreate": { "description": "Parameters for creating an external IP address for instances.", "oneOf": [ { - "description": "An IP address providing both inbound and outbound access. The address is automatically-assigned from the provided IP Pool, or all available pools if not specified.", + "description": "An IP address providing both inbound and outbound access. The address is automatically-assigned from the provided IP Pool, or the current silo's default pool if not specified.", "type": "object", "properties": { - "pool_name": { + "pool": { "nullable": true, "allOf": [ { - "$ref": "#/components/schemas/Name" + "$ref": "#/components/schemas/NameOrId" } ] }, @@ -11067,11 +11349,11 @@ ] }, { - "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.", + "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" + "floating_ip": { + "$ref": "#/components/schemas/NameOrId" }, "type": { "type": "string", @@ -11081,7 +11363,7 @@ } }, "required": [ - "floating_ip_name", + "floating_ip", "type" ] } @@ -11226,6 +11508,32 @@ "time_modified" ] }, + "FloatingIpAttach": { + "description": "Parameters for attaching a floating IP address to another resource", + "type": "object", + "properties": { + "kind": { + "description": "The type of `parent`'s resource", + "allOf": [ + { + "$ref": "#/components/schemas/FloatingIpParentKind" + } + ] + }, + "parent": { + "description": "Name or ID of the resource that this IP address should be attached to", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + } + }, + "required": [ + "kind", + "parent" + ] + }, "FloatingIpCreate": { "description": "Parameters for creating a new floating IP address for instances.", "type": "object", @@ -11257,6 +11565,13 @@ "name" ] }, + "FloatingIpParentKind": { + "description": "The type of resource that a floating IP is attached to", + "type": "string", + "enum": [ + "instance" + ] + }, "FloatingIpResultsPage": { "description": "A single page of results", "type": "object", @@ -12489,14 +12804,6 @@ } ] }, - "IpKind": { - "description": "The kind of an external IP address for an instance", - "type": "string", - "enum": [ - "ephemeral", - "floating" - ] - }, "IpNet": { "oneOf": [ { diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index b5b9d3fd5b2..7b9a3efcdad 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -327,6 +327,78 @@ } } }, + "/instances/{instance_id}/external-ip": { + "put": { + "operationId": "instance_put_external_ip", + "parameters": [ + { + "in": "path", + "name": "instance_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceExternalIpBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "instance_delete_external_ip", + "parameters": [ + { + "in": "path", + "name": "instance_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceExternalIpBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/instances/{instance_id}/migration-ids": { "put": { "operationId": "instance_put_migration_ids", @@ -2573,6 +2645,7 @@ ] }, "CrucibleOpts": { + "description": "CrucibleOpts\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"id\", \"lossy\", \"read_only\", \"target\" ], \"properties\": { \"cert_pem\": { \"type\": [ \"string\", \"null\" ] }, \"control\": { \"type\": [ \"string\", \"null\" ] }, \"flush_timeout\": { \"type\": [ \"number\", \"null\" ], \"format\": \"float\" }, \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"key\": { \"type\": [ \"string\", \"null\" ] }, \"key_pem\": { \"type\": [ \"string\", \"null\" ] }, \"lossy\": { \"type\": \"boolean\" }, \"read_only\": { \"type\": \"boolean\" }, \"root_cert_pem\": { \"type\": [ \"string\", \"null\" ] }, \"target\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } } } } ```
", "type": "object", "properties": { "cert_pem": { @@ -3338,6 +3411,7 @@ ] }, "DiskRequest": { + "description": "DiskRequest\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"device\", \"name\", \"read_only\", \"slot\", \"volume_construction_request\" ], \"properties\": { \"device\": { \"type\": \"string\" }, \"name\": { \"type\": \"string\" }, \"read_only\": { \"type\": \"boolean\" }, \"slot\": { \"$ref\": \"#/components/schemas/Slot\" }, \"volume_construction_request\": { \"$ref\": \"#/components/schemas/VolumeConstructionRequest\" } } } ```
", "type": "object", "properties": { "device": { @@ -4541,6 +4615,49 @@ "vmm_runtime" ] }, + "InstanceExternalIpBody": { + "description": "Used to dynamically update external IPs attached to an instance.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ephemeral" + ] + }, + "value": { + "type": "string", + "format": "ip" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "floating" + ] + }, + "value": { + "type": "string", + "format": "ip" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, "InstanceHardware": { "description": "Describes the instance hardware.", "type": "object", @@ -6217,7 +6334,7 @@ ] }, "SledRole": { - "description": "Describes the role of the sled within the rack.\n\nNote that this may change if the sled is physically moved within the rack.", + "description": "Describes the role of the sled within the rack.\n\nNote that this may change if the sled is physically moved within the rack.\n\n
JSON schema\n\n```json { \"description\": \"Describes the role of the sled within the rack.\\n\\nNote that this may change if the sled is physically moved within the rack.\", \"oneOf\": [ { \"description\": \"The sled is a general compute sled.\", \"type\": \"string\", \"enum\": [ \"gimlet\" ] }, { \"description\": \"The sled is attached to the network switch, and has additional responsibilities.\", \"type\": \"string\", \"enum\": [ \"scrimlet\" ] } ] } ```
", "oneOf": [ { "description": "The sled is a general compute sled.", @@ -6236,7 +6353,7 @@ ] }, "Slot": { - "description": "A stable index which is translated by Propolis into a PCI BDF, visible to the guest.", + "description": "A stable index which is translated by Propolis into a PCI BDF, visible to the guest.\n\n
JSON schema\n\n```json { \"description\": \"A stable index which is translated by Propolis into a PCI BDF, visible to the guest.\", \"type\": \"integer\", \"format\": \"uint8\", \"minimum\": 0.0 } ```
", "type": "integer", "format": "uint8", "minimum": 0 @@ -6487,6 +6604,7 @@ "minimum": 0 }, "VolumeConstructionRequest": { + "description": "VolumeConstructionRequest\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"block_size\", \"id\", \"sub_volumes\", \"type\" ], \"properties\": { \"block_size\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"read_only_parent\": { \"allOf\": [ { \"$ref\": \"#/components/schemas/VolumeConstructionRequest\" } ] }, \"sub_volumes\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/VolumeConstructionRequest\" } }, \"type\": { \"type\": \"string\", \"enum\": [ \"volume\" ] } } }, { \"type\": \"object\", \"required\": [ \"block_size\", \"id\", \"type\", \"url\" ], \"properties\": { \"block_size\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"url\" ] }, \"url\": { \"type\": \"string\" } } }, { \"type\": \"object\", \"required\": [ \"block_size\", \"blocks_per_extent\", \"extent_count\", \"gen\", \"opts\", \"type\" ], \"properties\": { \"block_size\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"blocks_per_extent\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"extent_count\": { \"type\": \"integer\", \"format\": \"uint32\", \"minimum\": 0.0 }, \"gen\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"opts\": { \"$ref\": \"#/components/schemas/CrucibleOpts\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"region\" ] } } }, { \"type\": \"object\", \"required\": [ \"block_size\", \"id\", \"path\", \"type\" ], \"properties\": { \"block_size\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"id\": { \"type\": \"string\", \"format\": \"uuid\" }, \"path\": { \"type\": \"string\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"file\" ] } } } ] } ```
", "oneOf": [ { "type": "object", diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 804b2029c61..300e8412c38 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -1628,7 +1628,7 @@ ] }, "PowerState": { - "description": "See RFD 81.\n\nThis enum only lists power states the SP is able to control; higher power states are controlled by ignition.", + "description": "See RFD 81.\n\nThis enum only lists power states the SP is able to control; higher power states are controlled by ignition.\n\n
JSON schema\n\n```json { \"description\": \"See RFD 81.\\n\\nThis enum only lists power states the SP is able to control; higher power states are controlled by ignition.\", \"type\": \"string\", \"enum\": [ \"A0\", \"A1\", \"A2\" ] } ```
", "type": "string", "enum": [ "A0", @@ -2186,6 +2186,7 @@ ] }, "RackInitId": { + "description": "RackInitId\n\n
JSON schema\n\n```json { \"type\": \"string\", \"format\": \"uuid\" } ```
", "type": "string", "format": "uuid" }, @@ -2230,7 +2231,7 @@ ] }, "RackOperationStatus": { - "description": "Current status of any rack-level operation being performed by this bootstrap agent.", + "description": "Current status of any rack-level operation being performed by this bootstrap agent.\n\n
JSON schema\n\n```json { \"description\": \"Current status of any rack-level operation being performed by this bootstrap agent.\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"id\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackInitId\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"initializing\" ] } } }, { \"description\": \"`id` will be none if the rack was already initialized on startup.\", \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"id\": { \"allOf\": [ { \"$ref\": \"#/components/schemas/RackInitId\" } ] }, \"status\": { \"type\": \"string\", \"enum\": [ \"initialized\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"message\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackInitId\" }, \"message\": { \"type\": \"string\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"initialization_failed\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackInitId\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"initialization_panicked\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackResetId\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"resetting\" ] } } }, { \"description\": \"`reset_id` will be None if the rack is in an uninitialized-on-startup, or Some if it is in an uninitialized state due to a reset operation completing.\", \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"reset_id\": { \"allOf\": [ { \"$ref\": \"#/components/schemas/RackResetId\" } ] }, \"status\": { \"type\": \"string\", \"enum\": [ \"uninitialized\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"message\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackResetId\" }, \"message\": { \"type\": \"string\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"reset_failed\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"status\" ], \"properties\": { \"id\": { \"$ref\": \"#/components/schemas/RackResetId\" }, \"status\": { \"type\": \"string\", \"enum\": [ \"reset_panicked\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -2397,6 +2398,7 @@ ] }, "RackResetId": { + "description": "RackResetId\n\n
JSON schema\n\n```json { \"type\": \"string\", \"format\": \"uuid\" } ```
", "type": "string", "format": "uuid" }, @@ -2444,6 +2446,7 @@ ] }, "RotSlot": { + "description": "RotSlot\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"slot\" ], \"properties\": { \"slot\": { \"type\": \"string\", \"enum\": [ \"a\" ] } } }, { \"type\": \"object\", \"required\": [ \"slot\" ], \"properties\": { \"slot\": { \"type\": \"string\", \"enum\": [ \"b\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -2476,6 +2479,7 @@ ] }, "RotState": { + "description": "RotState\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"active\", \"persistent_boot_preference\", \"state\" ], \"properties\": { \"active\": { \"$ref\": \"#/components/schemas/RotSlot\" }, \"pending_persistent_boot_preference\": { \"allOf\": [ { \"$ref\": \"#/components/schemas/RotSlot\" } ] }, \"persistent_boot_preference\": { \"$ref\": \"#/components/schemas/RotSlot\" }, \"slot_a_sha3_256_digest\": { \"type\": [ \"string\", \"null\" ] }, \"slot_b_sha3_256_digest\": { \"type\": [ \"string\", \"null\" ] }, \"state\": { \"type\": \"string\", \"enum\": [ \"enabled\" ] }, \"transient_boot_preference\": { \"allOf\": [ { \"$ref\": \"#/components/schemas/RotSlot\" } ] } } }, { \"type\": \"object\", \"required\": [ \"message\", \"state\" ], \"properties\": { \"message\": { \"type\": \"string\" }, \"state\": { \"type\": \"string\", \"enum\": [ \"communication_failed\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -2570,6 +2574,7 @@ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" }, "SpComponentCaboose": { + "description": "SpComponentCaboose\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"board\", \"git_commit\", \"name\", \"version\" ], \"properties\": { \"board\": { \"type\": \"string\" }, \"git_commit\": { \"type\": \"string\" }, \"name\": { \"type\": \"string\" }, \"version\": { \"type\": \"string\" } } } ```
", "type": "object", "properties": { "board": { @@ -2593,7 +2598,7 @@ ] }, "SpComponentInfo": { - "description": "Overview of a single SP component.", + "description": "Overview of a single SP component.\n\n
JSON schema\n\n```json { \"description\": \"Overview of a single SP component.\", \"type\": \"object\", \"required\": [ \"capabilities\", \"component\", \"description\", \"device\", \"presence\" ], \"properties\": { \"capabilities\": { \"description\": \"`capabilities` is a bitmask; interpret it via [`gateway_messages::DeviceCapabilities`].\", \"type\": \"integer\", \"format\": \"uint32\", \"minimum\": 0.0 }, \"component\": { \"description\": \"The unique identifier for this component.\", \"type\": \"string\" }, \"description\": { \"description\": \"A human-readable description of the component.\", \"type\": \"string\" }, \"device\": { \"description\": \"The name of the physical device.\", \"type\": \"string\" }, \"presence\": { \"description\": \"Whether or not the component is present, to the best of the SP's ability to judge.\", \"allOf\": [ { \"$ref\": \"#/components/schemas/SpComponentPresence\" } ] }, \"serial_number\": { \"description\": \"The component's serial number, if it has one.\", \"type\": [ \"string\", \"null\" ] } } } ```
", "type": "object", "properties": { "capabilities": { @@ -2637,7 +2642,7 @@ ] }, "SpComponentPresence": { - "description": "Description of the presence or absence of a component.\n\nThe presence of some components may vary based on the power state of the sled (e.g., components that time out or appear unavailable if the sled is in A2 may become present when the sled moves to A0).", + "description": "Description of the presence or absence of a component.\n\nThe presence of some components may vary based on the power state of the sled (e.g., components that time out or appear unavailable if the sled is in A2 may become present when the sled moves to A0).\n\n
JSON schema\n\n```json { \"description\": \"Description of the presence or absence of a component.\\n\\nThe presence of some components may vary based on the power state of the sled (e.g., components that time out or appear unavailable if the sled is in A2 may become present when the sled moves to A0).\", \"oneOf\": [ { \"description\": \"The component is present.\", \"type\": \"string\", \"enum\": [ \"present\" ] }, { \"description\": \"The component is not present.\", \"type\": \"string\", \"enum\": [ \"not_present\" ] }, { \"description\": \"The component is present but in a failed or faulty state.\", \"type\": \"string\", \"enum\": [ \"failed\" ] }, { \"description\": \"The SP is unable to determine the presence of the component.\", \"type\": \"string\", \"enum\": [ \"unavailable\" ] }, { \"description\": \"The SP's attempt to determine the presence of the component timed out.\", \"type\": \"string\", \"enum\": [ \"timeout\" ] }, { \"description\": \"The SP's attempt to determine the presence of the component failed.\", \"type\": \"string\", \"enum\": [ \"error\" ] } ] } ```
", "oneOf": [ { "description": "The component is present.", @@ -2684,6 +2689,7 @@ ] }, "SpIdentifier": { + "description": "SpIdentifier\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"slot\", \"type\" ], \"properties\": { \"slot\": { \"type\": \"integer\", \"format\": \"uint32\", \"minimum\": 0.0 }, \"type\": { \"$ref\": \"#/components/schemas/SpType\" } } } ```
", "type": "object", "properties": { "slot": { @@ -2701,7 +2707,7 @@ ] }, "SpIgnition": { - "description": "State of an ignition target.\n\nTODO: Ignition returns much more information than we're reporting here: do we want to expand this?", + "description": "State of an ignition target.\n\nTODO: Ignition returns much more information than we're reporting here: do we want to expand this?\n\n
JSON schema\n\n```json { \"description\": \"State of an ignition target.\\n\\nTODO: Ignition returns much more information than we're reporting here: do we want to expand this?\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"present\" ], \"properties\": { \"present\": { \"type\": \"string\", \"enum\": [ \"no\" ] } } }, { \"type\": \"object\", \"required\": [ \"ctrl_detect_0\", \"ctrl_detect_1\", \"flt_a2\", \"flt_a3\", \"flt_rot\", \"flt_sp\", \"id\", \"power\", \"present\" ], \"properties\": { \"ctrl_detect_0\": { \"type\": \"boolean\" }, \"ctrl_detect_1\": { \"type\": \"boolean\" }, \"flt_a2\": { \"type\": \"boolean\" }, \"flt_a3\": { \"type\": \"boolean\" }, \"flt_rot\": { \"type\": \"boolean\" }, \"flt_sp\": { \"type\": \"boolean\" }, \"id\": { \"$ref\": \"#/components/schemas/SpIgnitionSystemType\" }, \"power\": { \"type\": \"boolean\" }, \"present\": { \"type\": \"string\", \"enum\": [ \"yes\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -2766,7 +2772,7 @@ ] }, "SpIgnitionSystemType": { - "description": "TODO: Do we want to bake in specific board names, or use raw u16 ID numbers?", + "description": "TODO: Do we want to bake in specific board names, or use raw u16 ID numbers?\n\n
JSON schema\n\n```json { \"description\": \"TODO: Do we want to bake in specific board names, or use raw u16 ID numbers?\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"system_type\" ], \"properties\": { \"system_type\": { \"type\": \"string\", \"enum\": [ \"gimlet\" ] } } }, { \"type\": \"object\", \"required\": [ \"system_type\" ], \"properties\": { \"system_type\": { \"type\": \"string\", \"enum\": [ \"sidecar\" ] } } }, { \"type\": \"object\", \"required\": [ \"system_type\" ], \"properties\": { \"system_type\": { \"type\": \"string\", \"enum\": [ \"psc\" ] } } }, { \"type\": \"object\", \"required\": [ \"id\", \"system_type\" ], \"properties\": { \"id\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 }, \"system_type\": { \"type\": \"string\", \"enum\": [ \"unknown\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -2892,6 +2898,7 @@ ] }, "SpState": { + "description": "SpState\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"base_mac_address\", \"hubris_archive_id\", \"model\", \"power_state\", \"revision\", \"rot\", \"serial_number\" ], \"properties\": { \"base_mac_address\": { \"type\": \"array\", \"items\": { \"type\": \"integer\", \"format\": \"uint8\", \"minimum\": 0.0 }, \"maxItems\": 6, \"minItems\": 6 }, \"hubris_archive_id\": { \"type\": \"string\" }, \"model\": { \"type\": \"string\" }, \"power_state\": { \"$ref\": \"#/components/schemas/PowerState\" }, \"revision\": { \"type\": \"integer\", \"format\": \"uint32\", \"minimum\": 0.0 }, \"rot\": { \"$ref\": \"#/components/schemas/RotState\" }, \"serial_number\": { \"type\": \"string\" } } } ```
", "type": "object", "properties": { "base_mac_address": { @@ -2936,6 +2943,7 @@ ] }, "SpType": { + "description": "SpType\n\n
JSON schema\n\n```json { \"type\": \"string\", \"enum\": [ \"sled\", \"power\", \"switch\" ] } ```
", "type": "string", "enum": [ "sled", @@ -4691,7 +4699,7 @@ ] }, "IgnitionCommand": { - "description": "Ignition command.", + "description": "Ignition command.\n\n
JSON schema\n\n```json { \"description\": \"Ignition command.\", \"type\": \"string\", \"enum\": [ \"power_on\", \"power_off\", \"power_reset\" ] } ```
", "type": "string", "enum": [ "power_on", diff --git a/oximeter/db/schema/README.md b/oximeter/db/schema/README.md index 2f1633138d2..929144bccf4 100644 --- a/oximeter/db/schema/README.md +++ b/oximeter/db/schema/README.md @@ -32,7 +32,7 @@ To run this program: - Run this tool, pointing it at the desired schema directory, e.g.: ```bash -# /opt/oxide/oximeter/bin/clickhouse-schema-updater \ +# /opt/oxide/oximeter-collector/bin/clickhouse-schema-updater \ --host \ --schema-dir /opt/oxide/oximeter/sql up VERSION diff --git a/package-manifest.toml b/package-manifest.toml index fa6bba7a96a..36e43157f99 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -116,9 +116,16 @@ setup_hint = """ - Run `pkg install library/postgresql-13` to download Postgres libraries """ -[package.oximeter-collector] +[package.oximeter] service_name = "oximeter" only_for_targets.image = "standard" +source.type = "composite" +source.packages = [ "oximeter-collector.tar.gz", "zone-network-setup.tar.gz" ] +output.type = "zone" + +[package.oximeter-collector] +service_name = "oximeter-collector" +only_for_targets.image = "standard" source.type = "local" source.rust.binary_names = ["oximeter", "clickhouse-schema-updater"] source.rust.release = true @@ -127,6 +134,7 @@ source.paths = [ { from = "oximeter/db/schema", to = "/opt/oxide/oximeter/schema" }, ] output.type = "zone" +output.intermediate_only = true [package.clickhouse] service_name = "clickhouse" @@ -438,10 +446,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "2fd39b75df696961e5ea190c7d74dd91f4849cd3" +source.commit = "869cf802efcbd2f0edbb614ed92caa3e3164c1fc" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//maghemite.sha256.txt -source.sha256 = "38851c79c85d53e997db748520fb27c82299ce7e58a550e35646a548498f1271" +source.sha256 = "1cf9cb514d11275d93c4e4760500539a778f23039374508ca07528fcaf0ba3f8" output.type = "tarball" [package.mg-ddm] @@ -454,10 +462,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "2fd39b75df696961e5ea190c7d74dd91f4849cd3" +source.commit = "869cf802efcbd2f0edbb614ed92caa3e3164c1fc" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "8cd94e9a6f6175081ce78f0281085a08a5306cde453d8e21deb28050945b1d88" +source.sha256 = "a9b959b4287ac2ec7b45ed99ccd00e1f134b8e3d501099cd669cee5de9525ae3" output.type = "zone" output.intermediate_only = true @@ -469,10 +477,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "2fd39b75df696961e5ea190c7d74dd91f4849cd3" +source.commit = "869cf802efcbd2f0edbb614ed92caa3e3164c1fc" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "802636775fa77dc6eec193e65fde87e403f6a11531745d47ef5e7ff13b242890" +source.sha256 = "ab882fbeab54987645492872e67f3351f8d14629a041465cc845ac8583a7002b" output.type = "zone" output.intermediate_only = true diff --git a/schema/crdb/26.0.0/up01.sql b/schema/crdb/26.0.0/up01.sql new file mode 100644 index 00000000000..0cb511fb91c --- /dev/null +++ b/schema/crdb/26.0.0/up01.sql @@ -0,0 +1,6 @@ +CREATE TYPE IF NOT EXISTS omicron.public.ip_attach_state AS ENUM ( + 'detached', + 'attached', + 'detaching', + 'attaching' +); diff --git a/schema/crdb/26.0.0/up02.sql b/schema/crdb/26.0.0/up02.sql new file mode 100644 index 00000000000..324a907dd43 --- /dev/null +++ b/schema/crdb/26.0.0/up02.sql @@ -0,0 +1,4 @@ +-- Intentionally nullable for now as we need to backfill using the current +-- value of parent_id. +ALTER TABLE omicron.public.external_ip +ADD COLUMN IF NOT EXISTS state omicron.public.ip_attach_state; diff --git a/schema/crdb/26.0.0/up03.sql b/schema/crdb/26.0.0/up03.sql new file mode 100644 index 00000000000..ea1d4612502 --- /dev/null +++ b/schema/crdb/26.0.0/up03.sql @@ -0,0 +1,7 @@ +-- initialise external ip state for detached IPs. +set + local disallow_full_table_scans = off; + +UPDATE omicron.public.external_ip +SET state = 'detached' +WHERE parent_id IS NULL; diff --git a/schema/crdb/26.0.0/up04.sql b/schema/crdb/26.0.0/up04.sql new file mode 100644 index 00000000000..7bf89d66266 --- /dev/null +++ b/schema/crdb/26.0.0/up04.sql @@ -0,0 +1,7 @@ +-- initialise external ip state for attached IPs. +set + local disallow_full_table_scans = off; + +UPDATE omicron.public.external_ip +SET state = 'attached' +WHERE parent_id IS NOT NULL; diff --git a/schema/crdb/26.0.0/up05.sql b/schema/crdb/26.0.0/up05.sql new file mode 100644 index 00000000000..894806a3dce --- /dev/null +++ b/schema/crdb/26.0.0/up05.sql @@ -0,0 +1,2 @@ +-- Now move the new column to its intended state of non-nullable. +ALTER TABLE omicron.public.external_ip ALTER COLUMN state SET NOT NULL; diff --git a/schema/crdb/26.0.0/up06.sql b/schema/crdb/26.0.0/up06.sql new file mode 100644 index 00000000000..ca19081e370 --- /dev/null +++ b/schema/crdb/26.0.0/up06.sql @@ -0,0 +1,4 @@ +ALTER TABLE omicron.public.external_ip +ADD CONSTRAINT IF NOT EXISTS detached_null_parent_id CHECK ( + (state = 'detached') OR (parent_id IS NOT NULL) +); diff --git a/schema/crdb/26.0.0/up07.sql b/schema/crdb/26.0.0/up07.sql new file mode 100644 index 00000000000..00f9310c2ec --- /dev/null +++ b/schema/crdb/26.0.0/up07.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX IF NOT EXISTS one_ephemeral_ip_per_instance ON omicron.public.external_ip ( + parent_id +) + WHERE kind = 'ephemeral' AND parent_id IS NOT NULL AND time_deleted IS NULL; diff --git a/schema/crdb/26.0.0/up08.sql b/schema/crdb/26.0.0/up08.sql new file mode 100644 index 00000000000..3d85aaad057 --- /dev/null +++ b/schema/crdb/26.0.0/up08.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS omicron.public.external_ip +DROP CONSTRAINT IF EXISTS null_non_fip_parent_id; diff --git a/schema/crdb/26.0.0/up09.sql b/schema/crdb/26.0.0/up09.sql new file mode 100644 index 00000000000..bac963cce53 --- /dev/null +++ b/schema/crdb/26.0.0/up09.sql @@ -0,0 +1,4 @@ +ALTER TABLE IF EXISTS omicron.public.external_ip +ADD CONSTRAINT IF NOT EXISTS null_snat_parent_id CHECK ( + (kind != 'snat') OR (parent_id IS NOT NULL) +); diff --git a/schema/crdb/27.0.0/up01.sql b/schema/crdb/27.0.0/up01.sql new file mode 100644 index 00000000000..5b7fb4df939 --- /dev/null +++ b/schema/crdb/27.0.0/up01.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.update_deployment; diff --git a/schema/crdb/27.0.0/up02.sql b/schema/crdb/27.0.0/up02.sql new file mode 100644 index 00000000000..a6ab82583da --- /dev/null +++ b/schema/crdb/27.0.0/up02.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.updateable_component; diff --git a/schema/crdb/27.0.0/up03.sql b/schema/crdb/27.0.0/up03.sql new file mode 100644 index 00000000000..8a9b89bd5c5 --- /dev/null +++ b/schema/crdb/27.0.0/up03.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.system_update_component_update; diff --git a/schema/crdb/27.0.0/up04.sql b/schema/crdb/27.0.0/up04.sql new file mode 100644 index 00000000000..9fb8d61a1e5 --- /dev/null +++ b/schema/crdb/27.0.0/up04.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.component_update; diff --git a/schema/crdb/27.0.0/up05.sql b/schema/crdb/27.0.0/up05.sql new file mode 100644 index 00000000000..bb76e717ab2 --- /dev/null +++ b/schema/crdb/27.0.0/up05.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS omicron.public.updateable_component_type; diff --git a/schema/crdb/27.0.0/up06.sql b/schema/crdb/27.0.0/up06.sql new file mode 100644 index 00000000000..a68d6595bba --- /dev/null +++ b/schema/crdb/27.0.0/up06.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.system_update; diff --git a/schema/crdb/27.0.0/up07.sql b/schema/crdb/27.0.0/up07.sql new file mode 100644 index 00000000000..ddcbbbb8fde --- /dev/null +++ b/schema/crdb/27.0.0/up07.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS omicron.public.update_artifact; diff --git a/schema/crdb/27.0.0/up08.sql b/schema/crdb/27.0.0/up08.sql new file mode 100644 index 00000000000..75a15dc8178 --- /dev/null +++ b/schema/crdb/27.0.0/up08.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS omicron.public.update_artifact_kind; diff --git a/schema/crdb/27.0.0/up09.sql b/schema/crdb/27.0.0/up09.sql new file mode 100644 index 00000000000..984aff57def --- /dev/null +++ b/schema/crdb/27.0.0/up09.sql @@ -0,0 +1 @@ +DROP TYPE IF EXISTS omicron.public.update_status; diff --git a/schema/crdb/27.0.0/up10.sql b/schema/crdb/27.0.0/up10.sql new file mode 100644 index 00000000000..ddb13ca1c0a --- /dev/null +++ b/schema/crdb/27.0.0/up10.sql @@ -0,0 +1,33 @@ +-- Describes a single uploaded TUF repo. +-- +-- Identified by both a random uuid and its SHA256 hash. The hash could be the +-- primary key, but it seems unnecessarily large and unwieldy. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_repo ( + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + + sha256 STRING(64) NOT NULL, + + -- The version of the targets.json role that was used to generate the repo. + targets_role_version INT NOT NULL, + + -- The valid_until time for the repo. + valid_until TIMESTAMPTZ NOT NULL, + + -- The system version described in the TUF repo. + -- + -- This is the "true" primary key, but is not treated as such in the + -- database because we may want to change this format in the future. + -- Re-doing primary keys is annoying. + -- + -- Because the system version is embedded in the repo's artifacts.json, + -- each system version is associated with exactly one checksum. + system_version STRING(64) NOT NULL, + + -- For debugging only: + -- Filename provided by the user. + file_name TEXT NOT NULL, + + CONSTRAINT unique_checksum UNIQUE (sha256), + CONSTRAINT unique_system_version UNIQUE (system_version) +); diff --git a/schema/crdb/27.0.0/up11.sql b/schema/crdb/27.0.0/up11.sql new file mode 100644 index 00000000000..e0e36a51d7f --- /dev/null +++ b/schema/crdb/27.0.0/up11.sql @@ -0,0 +1,23 @@ +-- Describes an individual artifact from an uploaded TUF repo. +-- +-- In the future, this may also be used to describe artifacts that are fetched +-- from a remote TUF repo, but that requires some additional design work. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_artifact ( + name STRING(63) NOT NULL, + version STRING(63) NOT NULL, + -- This used to be an enum but is now a string, because it can represent + -- artifact kinds currently unknown to a particular version of Nexus as + -- well. + kind STRING(63) NOT NULL, + + -- The time this artifact was first recorded. + time_created TIMESTAMPTZ NOT NULL, + + -- The SHA256 hash of the artifact, typically obtained from the TUF + -- targets.json (and validated at extract time). + sha256 STRING(64) NOT NULL, + -- The length of the artifact, in bytes. + artifact_size INT8 NOT NULL, + + PRIMARY KEY (name, version, kind) +); diff --git a/schema/crdb/27.0.0/up12.sql b/schema/crdb/27.0.0/up12.sql new file mode 100644 index 00000000000..9c1ffb0de4f --- /dev/null +++ b/schema/crdb/27.0.0/up12.sql @@ -0,0 +1,21 @@ +-- Reflects that a particular artifact was provided by a particular TUF repo. +-- This is a many-many mapping. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_repo_artifact ( + tuf_repo_id UUID NOT NULL, + tuf_artifact_name STRING(63) NOT NULL, + tuf_artifact_version STRING(63) NOT NULL, + tuf_artifact_kind STRING(63) NOT NULL, + + /* + For the primary key, this definition uses the natural key rather than a + smaller surrogate key (UUID). That's because with CockroachDB the most + important factor in selecting a primary key is the ability to distribute + well. In this case, the first element of the primary key is the tuf_repo_id, + which is a random UUID. + + For more, see https://www.cockroachlabs.com/blog/how-to-choose-a-primary-key/. + */ + PRIMARY KEY ( + tuf_repo_id, tuf_artifact_name, tuf_artifact_version, tuf_artifact_kind + ) +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 4727fc26862..437f6f0ae28 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -1682,6 +1682,13 @@ CREATE TYPE IF NOT EXISTS omicron.public.ip_kind AS ENUM ( 'floating' ); +CREATE TYPE IF NOT EXISTS omicron.public.ip_attach_state AS ENUM ( + 'detached', + 'attached', + 'detaching', + 'attaching' +); + /* * External IP addresses used for guest instances and externally-facing * services. @@ -1727,6 +1734,12 @@ CREATE TABLE IF NOT EXISTS omicron.public.external_ip ( /* FK to the `project` table. */ project_id UUID, + /* State of this IP with regard to instance attach/detach + * operations. This is mainly used to prevent concurrent use + * across sagas and allow rollback to correct state. + */ + state omicron.public.ip_attach_state NOT NULL, + /* The name must be non-NULL iff this is a floating IP. */ CONSTRAINT null_fip_name CHECK ( (kind != 'floating' AND name IS NULL) OR @@ -1748,16 +1761,27 @@ CREATE TABLE IF NOT EXISTS omicron.public.external_ip ( ), /* - * Only nullable if this is a floating IP, which may exist not - * attached to any instance or service yet. + * Only nullable if this is a floating/ephemeral IP, which may exist not + * attached to any instance or service yet. Ephemeral IPs should not generally + * exist without parent instances/services, but need to temporarily exist in + * this state for live attachment. */ - CONSTRAINT null_non_fip_parent_id CHECK ( - (kind != 'floating' AND parent_id is NOT NULL) OR (kind = 'floating') + CONSTRAINT null_snat_parent_id CHECK ( + (kind != 'snat') OR (parent_id IS NOT NULL) ), /* Ephemeral IPs are not supported for services. */ CONSTRAINT ephemeral_kind_service CHECK ( (kind = 'ephemeral' AND is_service = FALSE) OR (kind != 'ephemeral') + ), + + /* + * (Not detached) => non-null parent_id. + * This is not a two-way implication because SNAT IPs + * cannot have a null parent_id. + */ + CONSTRAINT detached_null_parent_id CHECK ( + (state = 'detached') OR (parent_id IS NOT NULL) ) ); @@ -1790,6 +1814,12 @@ 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 a limit of one Ephemeral IP per instance */ +CREATE UNIQUE INDEX IF NOT EXISTS one_ephemeral_ip_per_instance ON omicron.public.external_ip ( + parent_id +) + WHERE kind = 'ephemeral' AND 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 @@ -1938,184 +1968,84 @@ CREATE INDEX IF NOT EXISTS lookup_console_by_silo_user ON omicron.public.console /*******************************************************************/ -CREATE TYPE IF NOT EXISTS omicron.public.update_artifact_kind AS ENUM ( - -- Sled artifacts - 'gimlet_sp', - 'gimlet_rot', - 'host', - 'trampoline', - 'control_plane', - - -- PSC artifacts - 'psc_sp', - 'psc_rot', - - -- Switch artifacts - 'switch_sp', - 'switch_rot' -); - -CREATE TABLE IF NOT EXISTS omicron.public.update_artifact ( - name STRING(63) NOT NULL, - version STRING(63) NOT NULL, - kind omicron.public.update_artifact_kind NOT NULL, - - /* the version of the targets.json role this came from */ - targets_role_version INT NOT NULL, - - /* when the metadata this artifact was cached from expires */ - valid_until TIMESTAMPTZ NOT NULL, - - /* data about the target from the targets.json role */ - target_name STRING(512) NOT NULL, - target_sha256 STRING(64) NOT NULL, - target_length INT NOT NULL, - - PRIMARY KEY (name, version, kind) -); - -/* This index is used to quickly find outdated artifacts. */ -CREATE INDEX IF NOT EXISTS lookup_artifact_by_targets_role_version ON omicron.public.update_artifact ( - targets_role_version -); - -/* - * System updates - */ -CREATE TABLE IF NOT EXISTS omicron.public.system_update ( - /* Identity metadata (asset) */ - id UUID PRIMARY KEY, - time_created TIMESTAMPTZ NOT NULL, - time_modified TIMESTAMPTZ NOT NULL, - - -- Because the version is unique, it could be the PK, but that would make - -- this resource different from every other resource for little benefit. - - -- Unique semver version - version STRING(64) NOT NULL -- TODO: length -); - -CREATE UNIQUE INDEX IF NOT EXISTS lookup_update_by_version ON omicron.public.system_update ( - version -); - - -CREATE TYPE IF NOT EXISTS omicron.public.updateable_component_type AS ENUM ( - 'bootloader_for_rot', - 'bootloader_for_sp', - 'bootloader_for_host_proc', - 'hubris_for_psc_rot', - 'hubris_for_psc_sp', - 'hubris_for_sidecar_rot', - 'hubris_for_sidecar_sp', - 'hubris_for_gimlet_rot', - 'hubris_for_gimlet_sp', - 'helios_host_phase_1', - 'helios_host_phase_2', - 'host_omicron' -); - -/* - * Component updates. Associated with at least one system_update through - * system_update_component_update. - */ -CREATE TABLE IF NOT EXISTS omicron.public.component_update ( - /* Identity metadata (asset) */ +-- Describes a single uploaded TUF repo. +-- +-- Identified by both a random uuid and its SHA256 hash. The hash could be the +-- primary key, but it seems unnecessarily large and unwieldy. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_repo ( id UUID PRIMARY KEY, time_created TIMESTAMPTZ NOT NULL, - time_modified TIMESTAMPTZ NOT NULL, - -- On component updates there's no device ID because the update can apply to - -- multiple instances of a given device kind + sha256 STRING(64) NOT NULL, - -- The *system* update version associated with this version (this is confusing, will rename) - version STRING(64) NOT NULL, -- TODO: length - -- TODO: add component update version to component_update + -- The version of the targets.json role that was used to generate the repo. + targets_role_version INT NOT NULL, - component_type omicron.public.updateable_component_type NOT NULL -); + -- The valid_until time for the repo. + valid_until TIMESTAMPTZ NOT NULL, --- version is unique per component type -CREATE UNIQUE INDEX IF NOT EXISTS lookup_component_by_type_and_version ON omicron.public.component_update ( - component_type, version -); + -- The system version described in the TUF repo. + -- + -- This is the "true" primary key, but is not treated as such in the + -- database because we may want to change this format in the future. + -- Re-doing primary keys is annoying. + -- + -- Because the system version is embedded in the repo's artifacts.json, + -- each system version is associated with exactly one checksum. + system_version STRING(64) NOT NULL, -/* - * Associate system updates with component updates. Not done with a - * system_update_id field on component_update because the same component update - * may be part of more than one system update. - */ -CREATE TABLE IF NOT EXISTS omicron.public.system_update_component_update ( - system_update_id UUID NOT NULL, - component_update_id UUID NOT NULL, + -- For debugging only: + -- Filename provided by the user. + file_name TEXT NOT NULL, - PRIMARY KEY (system_update_id, component_update_id) + CONSTRAINT unique_checksum UNIQUE (sha256), + CONSTRAINT unique_system_version UNIQUE (system_version) ); --- For now, the plan is to treat stopped, failed, completed as sub-cases of --- "steady" described by a "reason". But reason is not implemented yet. --- Obviously this could be a boolean, but boolean status fields never stay --- boolean for long. -CREATE TYPE IF NOT EXISTS omicron.public.update_status AS ENUM ( - 'updating', - 'steady' -); +-- Describes an individual artifact from an uploaded TUF repo. +-- +-- In the future, this may also be used to describe artifacts that are fetched +-- from a remote TUF repo, but that requires some additional design work. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_artifact ( + name STRING(63) NOT NULL, + version STRING(63) NOT NULL, + -- This used to be an enum but is now a string, because it can represent + -- artifact kinds currently unknown to a particular version of Nexus as + -- well. + kind STRING(63) NOT NULL, -/* - * Updateable components and their update status - */ -CREATE TABLE IF NOT EXISTS omicron.public.updateable_component ( - /* Identity metadata (asset) */ - id UUID PRIMARY KEY, + -- The time this artifact was first recorded. time_created TIMESTAMPTZ NOT NULL, - time_modified TIMESTAMPTZ NOT NULL, - -- Free-form string that comes from the device - device_id STRING(40) NOT NULL, + -- The SHA256 hash of the artifact, typically obtained from the TUF + -- targets.json (and validated at extract time). + sha256 STRING(64) NOT NULL, + -- The length of the artifact, in bytes. + artifact_size INT8 NOT NULL, - component_type omicron.public.updateable_component_type NOT NULL, - - -- The semver version of this component's own software - version STRING(64) NOT NULL, -- TODO: length - - -- The version of the system update this component's software came from. - -- This may need to be nullable if we are registering components before we - -- know about system versions at all - system_version STRING(64) NOT NULL, -- TODO: length - - status omicron.public.update_status NOT NULL - -- TODO: status reason for updateable_component + PRIMARY KEY (name, version, kind) ); --- can't have two components of the same type with the same device ID -CREATE UNIQUE INDEX IF NOT EXISTS lookup_component_by_type_and_device ON omicron.public.updateable_component ( - component_type, device_id -); +-- Reflects that a particular artifact was provided by a particular TUF repo. +-- This is a many-many mapping. +CREATE TABLE IF NOT EXISTS omicron.public.tuf_repo_artifact ( + tuf_repo_id UUID NOT NULL, + tuf_artifact_name STRING(63) NOT NULL, + tuf_artifact_version STRING(63) NOT NULL, + tuf_artifact_kind STRING(63) NOT NULL, -CREATE INDEX IF NOT EXISTS lookup_component_by_system_version ON omicron.public.updateable_component ( - system_version -); - -/* - * System updates - */ -CREATE TABLE IF NOT EXISTS omicron.public.update_deployment ( - /* Identity metadata (asset) */ - id UUID PRIMARY KEY, - time_created TIMESTAMPTZ NOT NULL, - time_modified TIMESTAMPTZ NOT NULL, - - -- semver version of corresponding system update - -- TODO: this makes sense while version is the PK of system_update, but - -- if/when I change that back to ID, this needs to be the ID too - version STRING(64) NOT NULL, - - status omicron.public.update_status NOT NULL - -- TODO: status reason for update_deployment -); - -CREATE INDEX IF NOT EXISTS lookup_deployment_by_creation on omicron.public.update_deployment ( - time_created + /* + For the primary key, this definition uses the natural key rather than a + smaller surrogate key (UUID). That's because with CockroachDB the most + important factor in selecting a primary key is the ability to distribute + well. In this case, the first element of the primary key is the tuf_repo_id, + which is a random UUID. + + For more, see https://www.cockroachlabs.com/blog/how-to-choose-a-primary-key/. + */ + PRIMARY KEY ( + tuf_repo_id, tuf_artifact_name, tuf_artifact_version, tuf_artifact_kind + ) ); /*******************************************************************/ @@ -3279,7 +3209,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - ( TRUE, NOW(), NOW(), '26.0.0', NULL) + ( TRUE, NOW(), NOW(), '28.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/rss-service-plan-v2.json b/schema/rss-service-plan-v2.json index 62ce358938a..10d8f8ab95e 100644 --- a/schema/rss-service-plan-v2.json +++ b/schema/rss-service-plan-v2.json @@ -19,6 +19,7 @@ }, "definitions": { "DnsConfigParams": { + "description": "DnsConfigParams\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"generation\", \"time_created\", \"zones\" ], \"properties\": { \"generation\": { \"type\": \"integer\", \"format\": \"uint64\", \"minimum\": 0.0 }, \"time_created\": { \"type\": \"string\", \"format\": \"date-time\" }, \"zones\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/DnsConfigZone\" } } } } ```
", "type": "object", "required": [ "generation", @@ -44,6 +45,7 @@ } }, "DnsConfigZone": { + "description": "DnsConfigZone\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"records\", \"zone_name\" ], \"properties\": { \"records\": { \"type\": \"object\", \"additionalProperties\": { \"type\": \"array\", \"items\": { \"$ref\": \"#/components/schemas/DnsRecord\" } } }, \"zone_name\": { \"type\": \"string\" } } } ```
", "type": "object", "required": [ "records", @@ -65,6 +67,7 @@ } }, "DnsRecord": { + "description": "DnsRecord\n\n
JSON schema\n\n```json { \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"type\": \"string\", \"format\": \"ipv4\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"A\" ] } } }, { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"type\": \"string\", \"format\": \"ipv6\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"AAAA\" ] } } }, { \"type\": \"object\", \"required\": [ \"data\", \"type\" ], \"properties\": { \"data\": { \"$ref\": \"#/components/schemas/Srv\" }, \"type\": { \"type\": \"string\", \"enum\": [ \"SRV\" ] } } } ] } ```
", "oneOf": [ { "type": "object", @@ -701,6 +704,7 @@ } }, "Srv": { + "description": "Srv\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"port\", \"prio\", \"target\", \"weight\" ], \"properties\": { \"port\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 }, \"prio\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 }, \"target\": { \"type\": \"string\" }, \"weight\": { \"type\": \"integer\", \"format\": \"uint16\", \"minimum\": 0.0 } } } ```
", "type": "object", "required": [ "port", diff --git a/schema/rss-sled-plan.json b/schema/rss-sled-plan.json index 0396ccc685a..cbd73ed0663 100644 --- a/schema/rss-sled-plan.json +++ b/schema/rss-sled-plan.json @@ -227,6 +227,7 @@ ] }, "Certificate": { + "description": "Certificate\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"cert\", \"key\" ], \"properties\": { \"cert\": { \"type\": \"string\" }, \"key\": { \"type\": \"string\" } } } ```
", "type": "object", "required": [ "cert", @@ -594,6 +595,7 @@ } }, "RecoverySiloConfig": { + "description": "RecoverySiloConfig\n\n
JSON schema\n\n```json { \"type\": \"object\", \"required\": [ \"silo_name\", \"user_name\", \"user_password_hash\" ], \"properties\": { \"silo_name\": { \"$ref\": \"#/components/schemas/Name\" }, \"user_name\": { \"$ref\": \"#/components/schemas/UserId\" }, \"user_password_hash\": { \"$ref\": \"#/components/schemas/NewPasswordHash\" } } } ```
", "type": "object", "required": [ "silo_name", @@ -718,7 +720,7 @@ ] }, "UserId": { - "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.", + "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.\n\n
JSON schema\n\n```json { \"title\": \"A name unique within the parent collection\", \"description\": \"Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID.\", \"type\": \"string\", \"maxLength\": 63, \"minLength\": 1, \"pattern\": \"^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z]([a-zA-Z0-9-]*[a-zA-Z0-9]+)?$\" } ```
", "type": "string" } } diff --git a/sled-agent/src/bootstrap/early_networking.rs b/sled-agent/src/bootstrap/early_networking.rs index 75958a2f379..acad2b8d3c4 100644 --- a/sled-agent/src/bootstrap/early_networking.rs +++ b/sled-agent/src/bootstrap/early_networking.rs @@ -6,21 +6,23 @@ use anyhow::{anyhow, Context}; use bootstore::schemes::v0 as bootstore; -use ddm_admin_client::{Client as DdmAdminClient, DdmError}; -use dpd_client::types::{Ipv6Entry, RouteSettingsV6}; +use ddm_admin_client::DdmError; use dpd_client::types::{ - LinkCreate, LinkId, LinkSettings, PortId, PortSettings, RouteSettingsV4, + LinkCreate, LinkId, LinkSettings, PortId, PortSettings, }; use dpd_client::Client as DpdClient; use futures::future; use gateway_client::Client as MgsClient; use internal_dns::resolver::{ResolveError, Resolver as DnsResolver}; use internal_dns::ServiceName; -use ipnetwork::{IpNetwork, Ipv6Network}; -use mg_admin_client::types::{ApplyRequest, BgpPeerConfig, Prefix4}; +use ipnetwork::Ipv6Network; +use mg_admin_client::types::{ + AddStaticRoute4Request, ApplyRequest, BgpPeerConfig, Prefix4, StaticRoute4, + StaticRoute4List, +}; use mg_admin_client::Client as MgdClient; -use omicron_common::address::{Ipv6Subnet, MGD_PORT, MGS_PORT}; -use omicron_common::address::{DDMD_PORT, DENDRITE_PORT}; +use omicron_common::address::DENDRITE_PORT; +use omicron_common::address::{MGD_PORT, MGS_PORT}; use omicron_common::api::internal::shared::{ BgpConfig, PortConfigV1, PortFec, PortSpeed, RackNetworkConfig, RackNetworkConfigV1, SwitchLocation, UplinkConfig, @@ -38,7 +40,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV6}; use std::time::{Duration, Instant}; use thiserror::Error; -static BOUNDARY_SERVICES_ADDR: &str = "fd00:99::1"; const BGP_SESSION_RESOLUTION: u64 = 100; /// Errors that can occur during early network setup @@ -421,22 +422,11 @@ impl<'a> EarlyNetworkSetup<'a> { // configure uplink for each requested uplink in configuration that // matches our switch_location for port_config in &our_ports { - let (ipv6_entry, dpd_port_settings, port_id) = + let (dpd_port_settings, port_id) = self.build_port_config(port_config)?; self.wait_for_dendrite(&dpd).await; - info!( - self.log, - "Configuring boundary services loopback address on switch"; - "config" => #?ipv6_entry - ); - dpd.loopback_ipv6_create(&ipv6_entry).await.map_err(|e| { - EarlyNetworkSetupError::Dendrite(format!( - "unable to create inital switch loopback address: {e}" - )) - })?; - info!( self.log, "Configuring default uplink on switch"; @@ -453,13 +443,6 @@ impl<'a> EarlyNetworkSetup<'a> { "unable to apply uplink port configuration: {e}" )) })?; - - info!(self.log, "advertising boundary services loopback address"); - - let ddmd_addr = - SocketAddrV6::new(switch_zone_underlay_ip, DDMD_PORT, 0, 0); - let ddmd_client = DdmAdminClient::new(&self.log, ddmd_addr)?; - ddmd_client.advertise_prefix(Ipv6Subnet::new(ipv6_entry.addr)); } let mgd = MgdClient::new( @@ -548,22 +531,40 @@ impl<'a> EarlyNetworkSetup<'a> { } } + // Iterate through ports and apply static routing config. + let mut rq = AddStaticRoute4Request { + routes: StaticRoute4List { list: Vec::new() }, + }; + for port in &our_ports { + for r in &port.routes { + let nexthop = match r.nexthop { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => continue, + }; + let prefix = match r.destination.ip() { + IpAddr::V4(v4) => { + Prefix4 { value: v4, length: r.destination.prefix() } + } + IpAddr::V6(_) => continue, + }; + let sr = StaticRoute4 { nexthop, prefix }; + rq.routes.list.push(sr); + } + } + mgd.inner.static_add_v4_route(&rq).await.map_err(|e| { + EarlyNetworkSetupError::BgpConfigurationError(format!( + "static routing configuration failed: {e}", + )) + })?; + Ok(our_ports) } fn build_port_config( &self, port_config: &PortConfigV1, - ) -> Result<(Ipv6Entry, PortSettings, PortId), EarlyNetworkSetupError> { + ) -> Result<(PortSettings, PortId), EarlyNetworkSetupError> { info!(self.log, "Building Port Configuration"); - let ipv6_entry = Ipv6Entry { - addr: BOUNDARY_SERVICES_ADDR.parse().map_err(|e| { - EarlyNetworkSetupError::BadConfig(format!( - "failed to parse `BOUNDARY_SERVICES_ADDR` as `Ipv6Addr`: {e}" - )) - })?, - tag: OMICRON_DPD_TAG.into(), - }; let mut dpd_port_settings = PortSettings { links: HashMap::new(), v4_routes: HashMap::new(), @@ -600,26 +601,7 @@ impl<'a> EarlyNetworkSetup<'a> { )) })?; - for r in &port_config.routes { - if let (IpNetwork::V4(dst), IpAddr::V4(nexthop)) = - (r.destination, r.nexthop) - { - dpd_port_settings.v4_routes.insert( - dst.to_string(), - vec![RouteSettingsV4 { link_id: link_id.0, nexthop }], - ); - } - if let (IpNetwork::V6(dst), IpAddr::V6(nexthop)) = - (r.destination, r.nexthop) - { - dpd_port_settings.v6_routes.insert( - dst.to_string(), - vec![RouteSettingsV6 { link_id: link_id.0, nexthop }], - ); - } - } - - Ok((ipv6_entry, dpd_port_settings, port_id)) + Ok((dpd_port_settings, port_id)) } async fn wait_for_dendrite(&self, dpd: &DpdClient) { diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 39d1ae26a05..0798aed664c 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -9,7 +9,7 @@ use crate::bootstrap::early_networking::EarlyNetworkConfig; use crate::bootstrap::params::AddSledRequest; use crate::params::{ CleanupContextUpdate, DiskEnsureBody, InstanceEnsureBody, - InstancePutMigrationIdsBody, InstancePutStateBody, + InstanceExternalIpBody, InstancePutMigrationIdsBody, InstancePutStateBody, InstancePutStateResponse, InstanceUnregisterResponse, Inventory, OmicronZonesConfig, SledRole, TimeSync, VpcFirewallRulesEnsureBody, ZoneBundleId, ZoneBundleMetadata, Zpool, @@ -53,6 +53,8 @@ pub fn api() -> SledApiDescription { api.register(instance_issue_disk_snapshot_request)?; api.register(instance_put_migration_ids)?; api.register(instance_put_state)?; + api.register(instance_put_external_ip)?; + api.register(instance_delete_external_ip)?; api.register(instance_register)?; api.register(instance_unregister)?; api.register(omicron_zones_get)?; @@ -467,6 +469,38 @@ async fn instance_put_migration_ids( )) } +#[endpoint { + method = PUT, + path = "/instances/{instance_id}/external-ip", +}] +async fn instance_put_external_ip( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, +) -> Result { + let sa = rqctx.context(); + let instance_id = path_params.into_inner().instance_id; + let body_args = body.into_inner(); + sa.instance_put_external_ip(instance_id, &body_args).await?; + Ok(HttpResponseUpdatedNoContent()) +} + +#[endpoint { + method = DELETE, + path = "/instances/{instance_id}/external-ip", +}] +async fn instance_delete_external_ip( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, +) -> Result { + let sa = rqctx.context(); + let instance_id = path_params.into_inner().instance_id; + let body_args = body.into_inner(); + sa.instance_delete_external_ip(instance_id, &body_args).await?; + Ok(HttpResponseUpdatedNoContent()) +} + /// Path parameters for Disk requests (sled agent API) #[derive(Deserialize, JsonSchema)] struct DiskPathParam { diff --git a/sled-agent/src/instance.rs b/sled-agent/src/instance.rs index 057402c57a9..47e61cfe719 100644 --- a/sled-agent/src/instance.rs +++ b/sled-agent/src/instance.rs @@ -10,8 +10,8 @@ use crate::common::instance::{ }; use crate::instance_manager::{InstanceManagerServices, InstanceTicket}; use crate::nexus::NexusClientWithResolver; -use crate::params::ZoneBundleCause; use crate::params::ZoneBundleMetadata; +use crate::params::{InstanceExternalIpBody, ZoneBundleCause}; use crate::params::{ InstanceHardware, InstanceMigrationSourceParams, InstanceMigrationTargetParams, InstanceStateRequested, VpcFirewallRule, @@ -274,8 +274,10 @@ impl InstanceInner { )) } nexus_client::Error::InvalidRequest(_) - | nexus_client::Error::InvalidResponsePayload(_) - | nexus_client::Error::UnexpectedResponse(_) => { + | nexus_client::Error::InvalidResponsePayload(..) + | nexus_client::Error::UnexpectedResponse(_) + | nexus_client::Error::InvalidUpgrade(_) + | nexus_client::Error::ResponseBodyError(_) => { BackoffError::permanent(Error::Notification( err, )) @@ -558,6 +560,110 @@ impl InstanceInner { Ok(()) } + + pub async fn add_external_ip( + &mut self, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + // v4 + v6 handling is delegated to `external_ips_ensure`. + // If OPTE is unhappy, we undo at `Instance` level. + + match ip { + // For idempotency of add/delete, we want to return + // success on 'already done'. + InstanceExternalIpBody::Ephemeral(ip) + if Some(ip) == self.ephemeral_ip.as_ref() => + { + return Ok(()); + } + InstanceExternalIpBody::Floating(ip) + if self.floating_ips.contains(ip) => + { + return Ok(()); + } + // New Ephemeral IP while current exists -- error without + // explicit delete. + InstanceExternalIpBody::Ephemeral(ip) + if self.ephemeral_ip.is_some() => + { + return Err(Error::Opte( + illumos_utils::opte::Error::ImplicitEphemeralIpDetach( + *ip, + self.ephemeral_ip.unwrap(), + ), + )); + } + // Not found, proceed with OPTE update. + InstanceExternalIpBody::Ephemeral(ip) => { + self.ephemeral_ip = Some(*ip); + } + InstanceExternalIpBody::Floating(ip) => { + self.floating_ips.push(*ip); + } + } + + let Some(primary_nic) = self.requested_nics.get(0) else { + return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); + }; + + self.port_manager.external_ips_ensure( + primary_nic.id, + primary_nic.kind, + Some(self.source_nat), + self.ephemeral_ip, + &self.floating_ips, + )?; + + Ok(()) + } + + pub async fn delete_external_ip( + &mut self, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + // v4 + v6 handling is delegated to `external_ips_ensure`. + // If OPTE is unhappy, we undo at `Instance` level. + + match ip { + // For idempotency of add/delete, we want to return + // success on 'already done'. + // IP Mismatch and 'deleted in past' can't really be + // disambiguated here. + InstanceExternalIpBody::Ephemeral(ip) + if self.ephemeral_ip != Some(*ip) => + { + return Ok(()); + } + InstanceExternalIpBody::Ephemeral(_) => { + self.ephemeral_ip = None; + } + InstanceExternalIpBody::Floating(ip) => { + let floating_index = + self.floating_ips.iter().position(|v| v == ip); + if let Some(pos) = floating_index { + // Swap remove is valid here, OPTE is not sensitive + // to Floating Ip ordering. + self.floating_ips.swap_remove(pos); + } else { + return Ok(()); + } + } + } + + let Some(primary_nic) = self.requested_nics.get(0) else { + return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); + }; + + self.port_manager.external_ips_ensure( + primary_nic.id, + primary_nic.kind, + Some(self.source_nat), + self.ephemeral_ip, + &self.floating_ips, + )?; + + Ok(()) + } } /// A reference to a single instance running a running Propolis server. @@ -1094,4 +1200,52 @@ impl Instance { Err(Error::InstanceNotRunning(inner.properties.id)) } } + + pub async fn add_external_ip( + &self, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + let mut inner = self.inner.lock().await; + + // The internal call can either fail on adding the IP + // to the list, or on the OPTE step. + // Be cautious and reset state if either fails. + // Note we don't need to re-ensure port manager/OPTE state + // since that's the last call we make internally. + let old_eph = inner.ephemeral_ip; + let out = inner.add_external_ip(ip).await; + + if out.is_err() { + inner.ephemeral_ip = old_eph; + if let InstanceExternalIpBody::Floating(ip) = ip { + inner.floating_ips.retain(|v| v != ip); + } + } + + out + } + + pub async fn delete_external_ip( + &self, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + let mut inner = self.inner.lock().await; + + // Similar logic to `add_external_ip`, except here we + // need to readd the floating IP if it was removed. + // OPTE doesn't care about the order of floating IPs. + let old_eph = inner.ephemeral_ip; + let out = inner.delete_external_ip(ip).await; + + if out.is_err() { + inner.ephemeral_ip = old_eph; + if let InstanceExternalIpBody::Floating(ip) = ip { + if !inner.floating_ips.contains(ip) { + inner.floating_ips.push(*ip); + } + } + } + + out + } } diff --git a/sled-agent/src/instance_manager.rs b/sled-agent/src/instance_manager.rs index c1b7e402a49..b66b0400e1c 100644 --- a/sled-agent/src/instance_manager.rs +++ b/sled-agent/src/instance_manager.rs @@ -7,6 +7,7 @@ use crate::instance::propolis_zone_name; use crate::instance::Instance; use crate::nexus::NexusClientWithResolver; +use crate::params::InstanceExternalIpBody; use crate::params::ZoneBundleMetadata; use crate::params::{ InstanceHardware, InstanceMigrationSourceParams, InstancePutStateResponse, @@ -434,6 +435,42 @@ impl InstanceManager { }; instance.request_zone_bundle().await } + + pub async fn add_external_ip( + &self, + instance_id: Uuid, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + let instance = { + let instances = self.inner.instances.lock().unwrap(); + instances.get(&instance_id).map(|(_id, v)| v.clone()) + }; + + let Some(instance) = instance else { + return Err(Error::NoSuchInstance(instance_id)); + }; + + instance.add_external_ip(ip).await?; + Ok(()) + } + + pub async fn delete_external_ip( + &self, + instance_id: Uuid, + ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + let instance = { + let instances = self.inner.instances.lock().unwrap(); + instances.get(&instance_id).map(|(_id, v)| v.clone()) + }; + + let Some(instance) = instance else { + return Err(Error::NoSuchInstance(instance_id)); + }; + + instance.delete_external_ip(ip).await?; + Ok(()) + } } /// Represents membership of an instance in the [`InstanceManager`]. diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index 9120bafa9ad..f14a13aa411 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -818,6 +818,16 @@ pub struct CleanupContextUpdate { pub storage_limit: Option, } +/// Used to dynamically update external IPs attached to an instance. +#[derive( + Copy, Clone, Debug, Eq, PartialEq, Hash, Deserialize, JsonSchema, Serialize, +)] +#[serde(rename_all = "snake_case", tag = "type", content = "value")] +pub enum InstanceExternalIpBody { + Ephemeral(IpAddr), + Floating(IpAddr), +} + // Our SledRole and Baseboard types do not have to be identical to the Nexus // ones, but they generally should be, and this avoids duplication. If it // becomes easier to maintain a separate copy, we should do that. diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index 211e602bbfe..77b6bcbed4f 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -61,7 +61,6 @@ use illumos_utils::zone::Zones; use illumos_utils::{execute, PFEXEC}; use internal_dns::resolver::Resolver; use itertools::Itertools; -use omicron_common::address::AZ_PREFIX; use omicron_common::address::BOOTSTRAP_ARTIFACT_PORT; use omicron_common::address::CLICKHOUSE_KEEPER_PORT; use omicron_common::address::CLICKHOUSE_PORT; @@ -75,6 +74,7 @@ use omicron_common::address::SLED_PREFIX; use omicron_common::address::WICKETD_NEXUS_PROXY_PORT; use omicron_common::address::WICKETD_PORT; use omicron_common::address::{Ipv6Subnet, NEXUS_TECHPORT_EXTERNAL_PORT}; +use omicron_common::address::{AZ_PREFIX, OXIMETER_PORT}; use omicron_common::api::external::Generation; use omicron_common::api::internal::shared::{ HostPortConfig, RackNetworkConfig, @@ -1798,7 +1798,55 @@ impl ServiceManager { let running_zone = RunningZone::boot(installed_zone).await?; return Ok(running_zone); } + ZoneArgs::Omicron(OmicronZoneConfigLocal { + zone: + OmicronZoneConfig { + id, + zone_type: OmicronZoneType::Oximeter { .. }, + underlay_address, + .. + }, + .. + }) => { + let Some(info) = self.inner.sled_info.get() else { + return Err(Error::SledAgentNotReady); + }; + + // Configure the Oximeter service. + let address = SocketAddr::new( + IpAddr::V6(*underlay_address), + OXIMETER_PORT, + ); + + let listen_addr = &address.ip().to_string(); + + let nw_setup_service = Self::zone_network_setup_install( + info, + &installed_zone, + listen_addr, + )?; + + let oximeter_config = PropertyGroupBuilder::new("config") + .add_property("id", "astring", &id.to_string()) + .add_property("address", "astring", &address.to_string()); + let oximeter_service = ServiceBuilder::new("oxide/oximeter") + .add_instance( + ServiceInstanceBuilder::new("default") + .add_property_group(oximeter_config), + ); + let profile = ProfileBuilder::new("omicron") + .add_service(nw_setup_service) + .add_service(disabled_ssh_service) + .add_service(oximeter_service); + profile + .add_to_zone(&self.inner.log, &installed_zone) + .await + .map_err(|err| { + Error::io("Failed to setup Oximeter profile", err) + })?; + return Ok(RunningZone::boot(installed_zone).await?); + } _ => {} } @@ -2154,14 +2202,6 @@ impl ServiceManager { // service is enabled. smfh.refresh()?; } - - OmicronZoneType::Oximeter { address } => { - info!(self.inner.log, "Setting up oximeter service"); - smfh.setprop("config/id", zone_config.zone.id)?; - smfh.setprop("config/address", address.to_string())?; - smfh.refresh()?; - } - OmicronZoneType::BoundaryNtp { ntp_servers, dns_servers, @@ -2227,7 +2267,8 @@ impl ServiceManager { | OmicronZoneType::ClickhouseKeeper { .. } | OmicronZoneType::CockroachDb { .. } | OmicronZoneType::Crucible { .. } - | OmicronZoneType::CruciblePantry { .. } => { + | OmicronZoneType::CruciblePantry { .. } + | OmicronZoneType::Oximeter { .. } => { panic!( "{} is a service which exists as part of a \ self-assembling zone", @@ -3729,7 +3770,7 @@ mod test { const GLOBAL_ZONE_BOOTSTRAP_IP: Ipv6Addr = Ipv6Addr::LOCALHOST; const SWITCH_ZONE_BOOTSTRAP_IP: Ipv6Addr = Ipv6Addr::LOCALHOST; - const EXPECTED_ZONE_NAME_PREFIX: &str = "oxz_oximeter"; + const EXPECTED_ZONE_NAME_PREFIX: &str = "oxz_ntp"; const EXPECTED_PORT: u16 = 12223; fn make_bootstrap_networking_config() -> BootstrapNetworking { @@ -3906,7 +3947,12 @@ mod test { mgr, id, generation, - OmicronZoneType::Oximeter { address }, + OmicronZoneType::InternalNtp { + address, + ntp_servers: vec![], + dns_servers: vec![], + domain: None, + }, ) .await .expect("Could not create service"); @@ -3945,7 +3991,12 @@ mod test { zones: vec![OmicronZoneConfig { id, underlay_address: Ipv6Addr::LOCALHOST, - zone_type: OmicronZoneType::Oximeter { address }, + zone_type: OmicronZoneType::InternalNtp { + address, + ntp_servers: vec![], + dns_servers: vec![], + domain: None, + }, }], }) .await @@ -4314,7 +4365,12 @@ mod test { let mut zones = vec![OmicronZoneConfig { id: id1, underlay_address: Ipv6Addr::LOCALHOST, - zone_type: OmicronZoneType::Oximeter { address }, + zone_type: OmicronZoneType::InternalNtp { + address, + ntp_servers: vec![], + dns_servers: vec![], + domain: None, + }, }]; mgr.ensure_all_omicron_zones_persistent(OmicronZonesConfig { generation: v2, @@ -4335,7 +4391,12 @@ mod test { zones.push(OmicronZoneConfig { id: id2, underlay_address: Ipv6Addr::LOCALHOST, - zone_type: OmicronZoneType::Oximeter { address }, + zone_type: OmicronZoneType::InternalNtp { + address, + ntp_servers: vec![], + dns_servers: vec![], + domain: None, + }, }); // Now try to apply that list with an older generation number. This @@ -4508,7 +4569,12 @@ mod test { zones.push(OmicronZoneConfig { id, underlay_address: Ipv6Addr::LOCALHOST, - zone_type: OmicronZoneType::Oximeter { address }, + zone_type: OmicronZoneType::InternalNtp { + address, + ntp_servers: vec![], + dns_servers: vec![], + domain: None, + }, }); mgr.ensure_all_omicron_zones_persistent(OmicronZonesConfig { generation: vv, diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index e5d7752511c..09ffdf5dc40 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -8,9 +8,10 @@ use crate::bootstrap::early_networking::{ EarlyNetworkConfig, EarlyNetworkConfigBody, }; use crate::params::{ - DiskEnsureBody, InstanceEnsureBody, InstancePutMigrationIdsBody, - InstancePutStateBody, InstancePutStateResponse, InstanceUnregisterResponse, - Inventory, OmicronZonesConfig, VpcFirewallRulesEnsureBody, + DiskEnsureBody, InstanceEnsureBody, InstanceExternalIpBody, + InstancePutMigrationIdsBody, InstancePutStateBody, + InstancePutStateResponse, InstanceUnregisterResponse, Inventory, + OmicronZonesConfig, VpcFirewallRulesEnsureBody, }; use dropshot::endpoint; use dropshot::ApiDescription; @@ -45,6 +46,8 @@ pub fn api() -> SledApiDescription { api.register(instance_put_state)?; api.register(instance_register)?; api.register(instance_unregister)?; + api.register(instance_put_external_ip)?; + api.register(instance_delete_external_ip)?; api.register(instance_poke_post)?; api.register(disk_put)?; api.register(disk_poke_post)?; @@ -152,6 +155,38 @@ async fn instance_put_migration_ids( )) } +#[endpoint { + method = PUT, + path = "/instances/{instance_id}/external-ip", +}] +async fn instance_put_external_ip( + rqctx: RequestContext>, + path_params: Path, + body: TypedBody, +) -> Result { + let sa = rqctx.context(); + let instance_id = path_params.into_inner().instance_id; + let body_args = body.into_inner(); + sa.instance_put_external_ip(instance_id, &body_args).await?; + Ok(HttpResponseUpdatedNoContent()) +} + +#[endpoint { + method = DELETE, + path = "/instances/{instance_id}/external-ip", +}] +async fn instance_delete_external_ip( + rqctx: RequestContext>, + path_params: Path, + body: TypedBody, +) -> Result { + let sa = rqctx.context(); + let instance_id = path_params.into_inner().instance_id; + let body_args = body.into_inner(); + sa.instance_delete_external_ip(instance_id, &body_args).await?; + Ok(HttpResponseUpdatedNoContent()) +} + #[endpoint { method = POST, path = "/instances/{instance_id}/poke", diff --git a/sled-agent/src/sim/http_entrypoints_pantry.rs b/sled-agent/src/sim/http_entrypoints_pantry.rs index 8f572b46a04..49368f363a0 100644 --- a/sled-agent/src/sim/http_entrypoints_pantry.rs +++ b/sled-agent/src/sim/http_entrypoints_pantry.rs @@ -365,6 +365,15 @@ mod tests { ); }; for (key, value) in map.iter() { + // We intentionally skip the "description" key, provided + // that the value is also a true String. This is mostly a + // one-off for the udpate to Progenitor 0.5.0, which caused + // this key to be added. But it's also pretty harmless, + // since it's not possible to get this key-value combination + // in a real JSON schema. + if key == "description" && value.is_string() { + continue; + } let new_path = format!("{path}/{key}"); let rhs_value = rhs_map.get(key).unwrap_or_else(|| { panic!("Real API JSON missing key: \"{new_path}\"") diff --git a/sled-agent/src/sim/sled_agent.rs b/sled-agent/src/sim/sled_agent.rs index 8a76bf6abc1..56cfaf57c86 100644 --- a/sled-agent/src/sim/sled_agent.rs +++ b/sled-agent/src/sim/sled_agent.rs @@ -12,9 +12,10 @@ use super::storage::CrucibleData; use super::storage::Storage; use crate::nexus::NexusClient; use crate::params::{ - DiskStateRequested, InstanceHardware, InstanceMigrationSourceParams, - InstancePutStateResponse, InstanceStateRequested, - InstanceUnregisterResponse, Inventory, OmicronZonesConfig, SledRole, + DiskStateRequested, InstanceExternalIpBody, InstanceHardware, + InstanceMigrationSourceParams, InstancePutStateResponse, + InstanceStateRequested, InstanceUnregisterResponse, Inventory, + OmicronZonesConfig, SledRole, }; use crate::sim::simulatable::Simulatable; use crate::updates::UpdateManager; @@ -41,7 +42,7 @@ use propolis_client::{ }; use propolis_mock_server::Context as PropolisContext; use slog::Logger; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::str::FromStr; use std::sync::Arc; @@ -69,6 +70,8 @@ pub struct SledAgent { pub v2p_mappings: Mutex>>, mock_propolis: Mutex>, PropolisClient)>>, + /// lists of external IPs assigned to instances + pub external_ips: Mutex>>, config: Config, fake_zones: Mutex, instance_ensure_state_error: Mutex>, @@ -162,6 +165,7 @@ impl SledAgent { nexus_client, disk_id_to_region_ids: Mutex::new(HashMap::new()), v2p_mappings: Mutex::new(HashMap::new()), + external_ips: Mutex::new(HashMap::new()), mock_propolis: Mutex::new(None), config: config.clone(), fake_zones: Mutex::new(OmicronZonesConfig { @@ -627,6 +631,58 @@ impl SledAgent { Ok(()) } + pub async fn instance_put_external_ip( + &self, + instance_id: Uuid, + body_args: &InstanceExternalIpBody, + ) -> Result<(), Error> { + if !self.instances.contains_key(&instance_id).await { + return Err(Error::internal_error( + "can't alter IP state for nonexistent instance", + )); + } + + let mut eips = self.external_ips.lock().await; + let my_eips = eips.entry(instance_id).or_default(); + + // High-level behaviour: this should always succeed UNLESS + // trying to add a double ephemeral. + if let InstanceExternalIpBody::Ephemeral(curr_ip) = &body_args { + if my_eips.iter().any(|v| { + if let InstanceExternalIpBody::Ephemeral(other_ip) = v { + curr_ip != other_ip + } else { + false + } + }) { + return Err(Error::invalid_request("cannot replace existing ephemeral IP without explicit removal")); + } + } + + my_eips.insert(*body_args); + + Ok(()) + } + + pub async fn instance_delete_external_ip( + &self, + instance_id: Uuid, + body_args: &InstanceExternalIpBody, + ) -> Result<(), Error> { + if !self.instances.contains_key(&instance_id).await { + return Err(Error::internal_error( + "can't alter IP state for nonexistent instance", + )); + } + + let mut eips = self.external_ips.lock().await; + let my_eips = eips.entry(instance_id).or_default(); + + my_eips.remove(&body_args); + + Ok(()) + } + /// Used for integration tests that require a component to talk to a /// mocked propolis-server API. // TODO: fix schemas so propolis-server's port isn't hardcoded in nexus diff --git a/sled-agent/src/sled_agent.rs b/sled-agent/src/sled_agent.rs index 71fe3584f0c..eaf354db26c 100644 --- a/sled-agent/src/sled_agent.rs +++ b/sled-agent/src/sled_agent.rs @@ -16,10 +16,11 @@ use crate::long_running_tasks::LongRunningTaskHandles; use crate::metrics::MetricsManager; use crate::nexus::{ConvertInto, NexusClientWithResolver, NexusRequestQueue}; use crate::params::{ - DiskStateRequested, InstanceHardware, InstanceMigrationSourceParams, - InstancePutStateResponse, InstanceStateRequested, - InstanceUnregisterResponse, Inventory, OmicronZonesConfig, SledRole, - TimeSync, VpcFirewallRule, ZoneBundleMetadata, Zpool, + DiskStateRequested, InstanceExternalIpBody, InstanceHardware, + InstanceMigrationSourceParams, InstancePutStateResponse, + InstanceStateRequested, InstanceUnregisterResponse, Inventory, + OmicronZonesConfig, SledRole, TimeSync, VpcFirewallRule, + ZoneBundleMetadata, Zpool, }; use crate::services::{self, ServiceManager}; use crate::storage_monitor::UnderlayAccess; @@ -979,6 +980,37 @@ impl SledAgent { .map_err(|e| Error::Instance(e)) } + /// Idempotently ensures that an instance's OPTE/port state includes the + /// specified external IP address. + /// + /// This method will return an error when trying to register an ephemeral IP which + /// does not match the current ephemeral IP. + pub async fn instance_put_external_ip( + &self, + instance_id: Uuid, + external_ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + self.inner + .instances + .add_external_ip(instance_id, external_ip) + .await + .map_err(|e| Error::Instance(e)) + } + + /// Idempotently ensures that an instance's OPTE/port state does not include the + /// specified external IP address in either its ephemeral or floating IP set. + pub async fn instance_delete_external_ip( + &self, + instance_id: Uuid, + external_ip: &InstanceExternalIpBody, + ) -> Result<(), Error> { + self.inner + .instances + .delete_external_ip(instance_id, external_ip) + .await + .map_err(|e| Error::Instance(e)) + } + /// Idempotently ensures that the given virtual disk is attached (or not) as /// specified. /// diff --git a/sled-agent/src/updates.rs b/sled-agent/src/updates.rs index 6144fd9171e..13a1ec7623e 100644 --- a/sled-agent/src/updates.rs +++ b/sled-agent/src/updates.rs @@ -127,7 +127,7 @@ impl UpdateManager { let response = nexus .cpapi_artifact_download( - nexus_client::types::KnownArtifactKind::ControlPlane, + &KnownArtifactKind::ControlPlane.to_string(), &artifact.name, &artifact.version.clone().into(), ) diff --git a/smf/oximeter/manifest.xml b/smf/oximeter/manifest.xml index 9c8b30f1f48..fe6c9ac23ad 100644 --- a/smf/oximeter/manifest.xml +++ b/smf/oximeter/manifest.xml @@ -4,21 +4,28 @@ - + + + + + + exec='ctrun -l child -o noorphan,regent /opt/oxide/oximeter-collector/bin/oximeter run /var/svc/manifest/site/oximeter/config.toml --address %{config/address} --id %{config/id} &' + timeout_seconds='0'> + + diff --git a/smf/sled-agent/non-gimlet/config-rss.toml b/smf/sled-agent/non-gimlet/config-rss.toml index fdc81c0f8fc..12cb2afd241 100644 --- a/smf/sled-agent/non-gimlet/config-rss.toml +++ b/smf/sled-agent/non-gimlet/config-rss.toml @@ -103,7 +103,7 @@ bgp = [] # Routes associated with this port. routes = [{nexthop = "192.168.1.199", destination = "0.0.0.0/0"}] # Addresses associated with this port. -addresses = ["192.168.1.30/32"] +addresses = ["192.168.1.30/24"] # Name of the uplink port. This should always be "qsfp0" when using softnpu. port = "qsfp0" # The speed of this port. diff --git a/tools/ci_check_opte_ver.sh b/tools/ci_check_opte_ver.sh index 26382690e1a..7f05ec1f363 100755 --- a/tools/ci_check_opte_ver.sh +++ b/tools/ci_check_opte_ver.sh @@ -1,6 +1,11 @@ #!/bin/bash set -euo pipefail +source tools/opte_version_override +if [[ "x$OPTE_COMMIT" != "x" ]]; then + exit 0 +fi + # Grab all the oxidecomputer/opte dependencies' revisions readarray -t opte_deps_revs < <(toml get Cargo.toml workspace.dependencies | jq -r 'to_entries | .[] | select(.value.git? | contains("oxidecomputer/opte")?) | .value.rev') OPTE_REV="${opte_deps_revs[0]}" diff --git a/tools/install_opte.sh b/tools/install_opte.sh index 20a33b05a5b..b572c305a73 100755 --- a/tools/install_opte.sh +++ b/tools/install_opte.sh @@ -97,3 +97,13 @@ if [[ "$RC" -ne 0 ]]; then echo "The \`opteadm\` administration tool is not on your path." echo "You may add \"/opt/oxide/opte/bin\" to your path to access it." fi + +source $OMICRON_TOP/tools/opte_version_override + +if [[ "x$OPTE_COMMIT" != "x" ]]; then + set +x + curl -fOL https://buildomat.eng.oxide.computer/public/file/oxidecomputer/opte/module/$OPTE_COMMIT/xde + pfexec rem_drv xde || true + pfexec mv xde /kernel/drv/amd64/xde + pfexec add_drv xde || true +fi diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index 37c099d7f5e..be8772b7e61 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1,2 +1,2 @@ -COMMIT="2fd39b75df696961e5ea190c7d74dd91f4849cd3" -SHA2="9737906555a60911636532f00f1dc2866dc7cd6553beb106e9e57beabad41cdf" +COMMIT="869cf802efcbd2f0edbb614ed92caa3e3164c1fc" +SHA2="0b0dbc2f8bbc5d2d9be92d64c4865f8f9335355aae62f7de9f67f81dfb3f1803" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index 329c05fc424..6bf1999c61b 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1,2 +1,2 @@ -COMMIT="2fd39b75df696961e5ea190c7d74dd91f4849cd3" -SHA2="931efa310d972b1f8afba2308751fc6a2035afbaebba77b3a40a8358c123ba3c" +COMMIT="869cf802efcbd2f0edbb614ed92caa3e3164c1fc" +SHA2="7618511f905d26394ef7c552339dd78835ce36a6def0d85b05b6d1e363a5e7b4" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index 1d3cf98f94b..b5fe84b6621 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,2 +1,2 @@ -CIDL_SHA256="802636775fa77dc6eec193e65fde87e403f6a11531745d47ef5e7ff13b242890" -MGD_LINUX_SHA256="1bcadfd700902e3640843e0bb53d3defdbcd8d86c3279efa0953ae8d6437e2b0" \ No newline at end of file +CIDL_SHA256="ab882fbeab54987645492872e67f3351f8d14629a041465cc845ac8583a7002b" +MGD_LINUX_SHA256="93331c1001e3aa506a8c1b83346abba1995e489910bff2c94a86730b96617a34" \ No newline at end of file diff --git a/tools/opte_version b/tools/opte_version index 82d79dcf282..0a04873e110 100644 --- a/tools/opte_version +++ b/tools/opte_version @@ -1 +1 @@ -0.27.214 +0.28.215 diff --git a/tools/opte_version_override b/tools/opte_version_override new file mode 100644 index 00000000000..80a6529b241 --- /dev/null +++ b/tools/opte_version_override @@ -0,0 +1,5 @@ +#!/bin/bash + +# only set this if you want to override the version of opte/xde installed by the +# install_opte.sh script +OPTE_COMMIT="" diff --git a/tufaceous-lib/src/assemble/manifest.rs b/tufaceous-lib/src/assemble/manifest.rs index 3974aa76b26..8825327c1dc 100644 --- a/tufaceous-lib/src/assemble/manifest.rs +++ b/tufaceous-lib/src/assemble/manifest.rs @@ -343,10 +343,66 @@ impl DeserializedManifest { .context("error deserializing manifest") } + pub fn to_toml(&self) -> Result { + toml::to_string(self).context("error serializing manifest to TOML") + } + + /// For fake manifests, applies a set of changes to them. + /// + /// Intended for testing. + pub fn apply_tweaks(&mut self, tweaks: &[ManifestTweak]) -> Result<()> { + for tweak in tweaks { + match tweak { + ManifestTweak::SystemVersion(version) => { + self.system_version = version.clone(); + } + ManifestTweak::ArtifactVersion { kind, version } => { + let entries = + self.artifacts.get_mut(kind).with_context(|| { + format!( + "manifest does not have artifact kind \ + {kind}", + ) + })?; + for entry in entries { + entry.version = version.clone(); + } + } + ManifestTweak::ArtifactContents { kind, size_delta } => { + let entries = + self.artifacts.get_mut(kind).with_context(|| { + format!( + "manifest does not have artifact kind \ + {kind}", + ) + })?; + + for entry in entries { + entry.source.apply_size_delta(*size_delta)?; + } + } + } + } + + Ok(()) + } + /// Returns the fake manifest. pub fn fake() -> Self { Self::from_str(FAKE_MANIFEST_TOML).unwrap() } + + /// Returns a version of the fake manifest with a set of changes applied. + /// + /// This is primarily intended for testing. + pub fn tweaked_fake(tweaks: &[ManifestTweak]) -> Self { + let mut manifest = Self::fake(); + manifest + .apply_tweaks(tweaks) + .expect("builtin fake manifest should accept all tweaks"); + + manifest + } } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] @@ -380,6 +436,39 @@ pub enum DeserializedArtifactSource { }, } +impl DeserializedArtifactSource { + fn apply_size_delta(&mut self, size_delta: i64) -> Result<()> { + match self { + DeserializedArtifactSource::File { .. } => { + bail!("cannot apply size delta to `file` source") + } + DeserializedArtifactSource::Fake { size } => { + *size = (*size).saturating_add_signed(size_delta); + Ok(()) + } + DeserializedArtifactSource::CompositeHost { phase_1, phase_2 } => { + phase_1.apply_size_delta(size_delta)?; + phase_2.apply_size_delta(size_delta)?; + Ok(()) + } + DeserializedArtifactSource::CompositeRot { + archive_a, + archive_b, + } => { + archive_a.apply_size_delta(size_delta)?; + archive_b.apply_size_delta(size_delta)?; + Ok(()) + } + DeserializedArtifactSource::CompositeControlPlane { zones } => { + for zone in zones { + zone.apply_size_delta(size_delta)?; + } + Ok(()) + } + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum DeserializedFileArtifactSource { @@ -416,6 +505,18 @@ impl DeserializedFileArtifactSource { let entry = CompositeEntry { data: &data, mtime_source }; f(entry) } + + fn apply_size_delta(&mut self, size_delta: i64) -> Result<()> { + match self { + DeserializedFileArtifactSource::File { .. } => { + bail!("cannot apply size delta to `file` source") + } + DeserializedFileArtifactSource::Fake { size } => { + *size = (*size).saturating_add_signed(size_delta); + Ok(()) + } + } + } } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] @@ -459,6 +560,30 @@ impl DeserializedControlPlaneZoneSource { let entry = CompositeEntry { data: &data, mtime_source }; f(name, entry) } + + fn apply_size_delta(&mut self, size_delta: i64) -> Result<()> { + match self { + DeserializedControlPlaneZoneSource::File { .. } => { + bail!("cannot apply size delta to `file` source") + } + DeserializedControlPlaneZoneSource::Fake { size, .. } => { + (*size) = (*size).saturating_add_signed(size_delta); + Ok(()) + } + } + } +} +/// A change to apply to a manifest. +#[derive(Clone, Debug)] +pub enum ManifestTweak { + /// Update the system version. + SystemVersion(SemverVersion), + + /// Update the versions for this artifact. + ArtifactVersion { kind: KnownArtifactKind, version: SemverVersion }, + + /// Update the contents of this artifact (only support changing the size). + ArtifactContents { kind: KnownArtifactKind, size_delta: i64 }, } fn deserialize_byte_size<'de, D>(deserializer: D) -> Result diff --git a/update-common/Cargo.toml b/update-common/Cargo.toml index cc2ee86232d..37542baa8f4 100644 --- a/update-common/Cargo.toml +++ b/update-common/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true bytes.workspace = true camino.workspace = true camino-tempfile.workspace = true +chrono.workspace = true debug-ignore.workspace = true display-error-chain.workspace = true dropshot.workspace = true diff --git a/update-common/src/artifacts/artifacts_with_plan.rs b/update-common/src/artifacts/artifacts_with_plan.rs index 9b579af29a7..c2be69e82e9 100644 --- a/update-common/src/artifacts/artifacts_with_plan.rs +++ b/update-common/src/artifacts/artifacts_with_plan.rs @@ -4,19 +4,28 @@ use super::ExtractedArtifactDataHandle; use super::UpdatePlan; +use super::UpdatePlanBuildOutput; use super::UpdatePlanBuilder; use crate::errors::RepositoryError; use anyhow::anyhow; +use bytes::Bytes; use camino_tempfile::Utf8TempDir; use debug_ignore::DebugIgnore; +use dropshot::HttpError; +use futures::Stream; +use futures::TryStreamExt; +use omicron_common::api::external::TufRepoDescription; +use omicron_common::api::external::TufRepoMeta; use omicron_common::update::ArtifactHash; use omicron_common::update::ArtifactHashId; use omicron_common::update::ArtifactId; +use sha2::{Digest, Sha256}; use slog::info; use slog::Logger; use std::collections::BTreeMap; use std::collections::HashMap; use std::io; +use tokio::io::AsyncWriteExt; use tough::TargetName; use tufaceous_lib::ArchiveExtractor; use tufaceous_lib::OmicronRepo; @@ -24,6 +33,9 @@ use tufaceous_lib::OmicronRepo; /// A collection of artifacts along with an update plan using those artifacts. #[derive(Debug)] pub struct ArtifactsWithPlan { + // A description of this repository. + description: TufRepoDescription, + // Map of top-level artifact IDs (present in the TUF repo) to the actual // artifacts we're serving (e.g., a top-level RoT artifact will map to two // artifact hashes: one for each of the A and B images). @@ -51,8 +63,65 @@ pub struct ArtifactsWithPlan { } impl ArtifactsWithPlan { + /// Creates a new `ArtifactsWithPlan` from the given stream of `Bytes`. + /// + /// This method reads the stream representing a TUF repo, and writes it to + /// a temporary file. Afterwards, it builds an `ArtifactsWithPlan` from the + /// contents of that file. + pub async fn from_stream( + body: impl Stream> + Send, + file_name: Option, + log: &Logger, + ) -> Result { + // Create a temporary file to store the incoming archive.`` + let tempfile = tokio::task::spawn_blocking(|| { + camino_tempfile::tempfile().map_err(RepositoryError::TempFileCreate) + }) + .await + .unwrap()?; + let mut tempfile = + tokio::io::BufWriter::new(tokio::fs::File::from_std(tempfile)); + + let mut body = std::pin::pin!(body); + + // Stream the uploaded body into our tempfile. + let mut hasher = Sha256::new(); + while let Some(bytes) = body + .try_next() + .await + .map_err(RepositoryError::ReadChunkFromStream)? + { + hasher.update(&bytes); + tempfile + .write_all(&bytes) + .await + .map_err(RepositoryError::TempFileWrite)?; + } + + let repo_hash = ArtifactHash(hasher.finalize().into()); + + // Flush writes. We don't need to seek back to the beginning of the file + // because extracting the repository will do its own seeking as a part of + // unzipping this repo. + tempfile.flush().await.map_err(RepositoryError::TempFileFlush)?; + + let tempfile = tempfile.into_inner().into_std().await; + + let artifacts_with_plan = Self::from_zip( + io::BufReader::new(tempfile), + file_name, + repo_hash, + log, + ) + .await?; + + Ok(artifacts_with_plan) + } + pub async fn from_zip( zip_data: T, + file_name: Option, + repo_hash: ArtifactHash, log: &Logger, ) -> Result where @@ -102,7 +171,7 @@ impl ArtifactsWithPlan { // `dir`, but we'll also unpack nested artifacts like the RoT dual A/B // archives. let mut builder = - UpdatePlanBuilder::new(artifacts.system_version, log)?; + UpdatePlanBuilder::new(artifacts.system_version.clone(), log)?; // Make a pass through each artifact in the repo. For each artifact, we // do one of the following: @@ -124,9 +193,7 @@ impl ArtifactsWithPlan { // priority - copying small SP artifacts is neglible compared to the // work we do to unpack host OS images. - let mut by_id = BTreeMap::new(); - let mut by_hash = HashMap::new(); - for artifact in artifacts.artifacts { + for artifact in &artifacts.artifacts { let target_name = TargetName::try_from(artifact.target.as_str()) .map_err(|error| RepositoryError::LocateTarget { target: artifact.target.clone(), @@ -167,21 +234,44 @@ impl ArtifactsWithPlan { })?; builder - .add_artifact( - artifact.into_id(), - artifact_hash, - stream, - &mut by_id, - &mut by_hash, - ) + .add_artifact(artifact.clone().into_id(), artifact_hash, stream) .await?; } // Ensure we know how to apply updates from this set of artifacts; we'll // remember the plan we create. - let artifacts = builder.build()?; + let UpdatePlanBuildOutput { plan, by_id, by_hash, artifacts_meta } = + builder.build()?; - Ok(Self { by_id, by_hash: by_hash.into(), plan: artifacts }) + let tuf_repository = repository.repo(); + + let file_name = file_name.unwrap_or_else(|| { + // Just pick a reasonable-sounding file name if we don't have one. + format!("system-update-v{}.zip", artifacts.system_version) + }); + + let repo_meta = TufRepoMeta { + hash: repo_hash, + targets_role_version: tuf_repository.targets().signed.version.get(), + valid_until: tuf_repository + .root() + .signed + .expires + .min(tuf_repository.snapshot().signed.expires) + .min(tuf_repository.targets().signed.expires) + .min(tuf_repository.timestamp().signed.expires), + system_version: artifacts.system_version, + file_name, + }; + let description = + TufRepoDescription { repo: repo_meta, artifacts: artifacts_meta }; + + Ok(Self { description, by_id, by_hash: by_hash.into(), plan }) + } + + /// Returns the `ArtifactsDocument` corresponding to this TUF repo. + pub fn description(&self) -> &TufRepoDescription { + &self.description } pub fn by_id(&self) -> &BTreeMap> { @@ -233,13 +323,14 @@ where mod tests { use super::*; use anyhow::{Context, Result}; + use camino::Utf8Path; use camino_tempfile::Utf8TempDir; use clap::Parser; use omicron_common::{ api::internal::nexus::KnownArtifactKind, update::ArtifactKind, }; use omicron_test_utils::dev::test_setup_log; - use std::collections::BTreeSet; + use std::{collections::BTreeSet, time::Duration}; /// Test that `ArtifactsWithPlan` can extract the fake repository generated /// by tufaceous. @@ -253,29 +344,22 @@ mod tests { let archive_path = temp_dir.path().join("archive.zip"); // Create the archive. - let args = tufaceous::Args::try_parse_from([ - "tufaceous", - "assemble", - "../tufaceous/manifests/fake.toml", - archive_path.as_str(), - ]) - .context("error parsing args")?; - - args.exec(&logctx.log) - .await - .context("error executing assemble command")?; + create_fake_archive(&logctx.log, &archive_path).await?; // Now check that it can be read by the archive extractor. - let zip_bytes = std::fs::File::open(&archive_path) - .context("error opening archive.zip")?; - let plan = ArtifactsWithPlan::from_zip(zip_bytes, &logctx.log) - .await - .context("error reading archive.zip")?; + let plan = + build_artifacts_with_plan(&logctx.log, &archive_path).await?; // Check that all known artifact kinds are present in the map. let by_id_kinds: BTreeSet<_> = plan.by_id().keys().map(|id| id.kind.clone()).collect(); let by_hash_kinds: BTreeSet<_> = plan.by_hash().keys().map(|id| id.kind.clone()).collect(); + let artifact_meta_kinds: BTreeSet<_> = plan + .description + .artifacts + .iter() + .map(|meta| meta.id.kind.clone()) + .collect(); // `by_id` should contain one entry for every `KnownArtifactKind`... let mut expected_kinds: BTreeSet<_> = @@ -315,6 +399,10 @@ mod tests { expected_kinds, by_hash_kinds, "expected kinds match by_hash kinds" ); + assert_eq!( + expected_kinds, artifact_meta_kinds, + "expected kinds match artifact_meta kinds" + ); // Every value present in `by_id` should also be a key in `by_hash`. for (id, hash_ids) in plan.by_id() { @@ -327,8 +415,81 @@ mod tests { } } + // + + logctx.cleanup_successful(); + + Ok(()) + } + + /// Test that the archive generated by running `tufaceous assemble` twice + /// has the same artifacts and hashes. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_fake_archive_idempotent() -> Result<()> { + let logctx = test_setup_log("test_fake_archive_idempotent"); + let temp_dir = Utf8TempDir::new()?; + let archive_path = temp_dir.path().join("archive1.zip"); + + // Create the archive and build a plan from it. + create_fake_archive(&logctx.log, &archive_path).await?; + let mut plan1 = + build_artifacts_with_plan(&logctx.log, &archive_path).await?; + + // Add a 2 second delay to ensure that if we bake any second-based + // timestamps in, that they end up being different from those in the + // first archive. + tokio::time::sleep(Duration::from_secs(2)).await; + + let archive2_path = temp_dir.path().join("archive2.zip"); + create_fake_archive(&logctx.log, &archive2_path).await?; + let mut plan2 = + build_artifacts_with_plan(&logctx.log, &archive2_path).await?; + + // At the moment, the repo .zip itself doesn't match because it bakes + // in timestamps. However, the artifacts inside should match exactly. + plan1.description.sort_artifacts(); + plan2.description.sort_artifacts(); + + assert_eq!( + plan1.description.artifacts, plan2.description.artifacts, + "artifacts match" + ); + logctx.cleanup_successful(); Ok(()) } + + async fn create_fake_archive( + log: &slog::Logger, + archive_path: &Utf8Path, + ) -> Result<()> { + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + "../tufaceous/manifests/fake.toml", + archive_path.as_str(), + ]) + .context("error parsing args")?; + + args.exec(log).await.context("error executing assemble command")?; + + Ok(()) + } + + async fn build_artifacts_with_plan( + log: &slog::Logger, + archive_path: &Utf8Path, + ) -> Result { + let zip_bytes = std::fs::File::open(&archive_path) + .context("error opening archive.zip")?; + // We could also compute the hash from the file here, but the repo hash + // doesn't matter for the test. + let repo_hash = ArtifactHash([0u8; 32]); + let plan = ArtifactsWithPlan::from_zip(zip_bytes, None, repo_hash, log) + .await + .with_context(|| format!("error reading {archive_path}"))?; + + Ok(plan) + } } diff --git a/update-common/src/artifacts/extracted_artifacts.rs b/update-common/src/artifacts/extracted_artifacts.rs index 06e0e5ec654..5ac4a3a3954 100644 --- a/update-common/src/artifacts/extracted_artifacts.rs +++ b/update-common/src/artifacts/extracted_artifacts.rs @@ -106,7 +106,7 @@ pub struct ExtractedArtifacts { impl ExtractedArtifacts { pub fn new(log: &Logger) -> Result { let tempdir = camino_tempfile::Builder::new() - .prefix("wicketd-update-artifacts.") + .prefix("update-artifacts.") .tempdir() .map_err(RepositoryError::TempDirCreate)?; info!( @@ -189,7 +189,7 @@ impl ExtractedArtifacts { &self, ) -> Result { let file = NamedUtf8TempFile::new_in(self.tempdir.path()).map_err( - |error| RepositoryError::TempFileCreate { + |error| RepositoryError::NamedTempFileCreate { path: self.tempdir.path().to_owned(), error, }, diff --git a/update-common/src/artifacts/update_plan.rs b/update-common/src/artifacts/update_plan.rs index 7704d5fe8a1..c5b171d6483 100644 --- a/update-common/src/artifacts/update_plan.rs +++ b/update-common/src/artifacts/update_plan.rs @@ -2,7 +2,8 @@ // 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/. -//! Constructor for the `UpdatePlan` wicketd uses to drive sled mupdates. +//! Constructor for the `UpdatePlan` wicketd and Nexus use to drive sled +//! mupdates. //! //! This is a "plan" in name only: it is a strict list of which artifacts to //! apply to which components; the ordering and application of the plan lives @@ -20,6 +21,7 @@ use futures::StreamExt; use futures::TryStreamExt; use hubtools::RawHubrisArchive; use omicron_common::api::external::SemverVersion; +use omicron_common::api::external::TufArtifactMeta; use omicron_common::api::internal::nexus::KnownArtifactKind; use omicron_common::update::ArtifactHash; use omicron_common::update::ArtifactHashId; @@ -107,6 +109,11 @@ pub struct UpdatePlanBuilder<'a> { host_phase_2_hash: Option, control_plane_hash: Option, + // The by_id and by_hash maps, and metadata, used in `ArtifactsWithPlan`. + by_id: BTreeMap>, + by_hash: HashMap, + artifacts_meta: Vec, + // extra fields we use to build the plan extracted_artifacts: ExtractedArtifacts, log: &'a Logger, @@ -135,30 +142,27 @@ impl<'a> UpdatePlanBuilder<'a> { host_phase_2_hash: None, control_plane_hash: None, + by_id: BTreeMap::new(), + by_hash: HashMap::new(), + artifacts_meta: Vec::new(), + extracted_artifacts, log, }) } + /// Adds an artifact with these contents to the by_id and by_hash maps. pub async fn add_artifact( &mut self, artifact_id: ArtifactId, artifact_hash: ArtifactHash, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { // If we don't know this artifact kind, we'll still serve it up by hash, // but we don't do any further processing on it. let Some(artifact_kind) = artifact_id.kind.to_known() else { return self - .add_unknown_artifact( - artifact_id, - artifact_hash, - stream, - by_id, - by_hash, - ) + .add_unknown_artifact(artifact_id, artifact_hash, stream) .await; }; @@ -175,39 +179,25 @@ impl<'a> UpdatePlanBuilder<'a> { artifact_kind, artifact_hash, stream, - by_id, - by_hash, ) .await } KnownArtifactKind::GimletRot | KnownArtifactKind::PscRot | KnownArtifactKind::SwitchRot => { - self.add_rot_artifact( - artifact_id, - artifact_kind, - stream, - by_id, - by_hash, - ) - .await + self.add_rot_artifact(artifact_id, artifact_kind, stream).await } KnownArtifactKind::Host => { - self.add_host_artifact(artifact_id, stream, by_id, by_hash) + self.add_host_artifact(artifact_id, stream) + } + KnownArtifactKind::Trampoline => { + self.add_trampoline_artifact(artifact_id, stream) } - KnownArtifactKind::Trampoline => self.add_trampoline_artifact( - artifact_id, - stream, - by_id, - by_hash, - ), KnownArtifactKind::ControlPlane => { self.add_control_plane_artifact( artifact_id, artifact_hash, stream, - by_id, - by_hash, ) .await } @@ -220,8 +210,6 @@ impl<'a> UpdatePlanBuilder<'a> { artifact_kind: KnownArtifactKind, artifact_hash: ArtifactHash, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { let sp_map = match artifact_kind { KnownArtifactKind::GimletSp => &mut self.gimlet_sp, @@ -276,10 +264,8 @@ impl<'a> UpdatePlanBuilder<'a> { data: data.clone(), }); - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, data, artifact_kind.into(), self.log, @@ -293,8 +279,6 @@ impl<'a> UpdatePlanBuilder<'a> { artifact_id: ArtifactId, artifact_kind: KnownArtifactKind, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { let (rot_a, rot_a_kind, rot_b, rot_b_kind) = match artifact_kind { KnownArtifactKind::GimletRot => ( @@ -353,18 +337,14 @@ impl<'a> UpdatePlanBuilder<'a> { rot_a.push(ArtifactIdData { id: rot_a_id, data: rot_a_data.clone() }); rot_b.push(ArtifactIdData { id: rot_b_id, data: rot_b_data.clone() }); - record_extracted_artifact( + self.record_extracted_artifact( artifact_id.clone(), - by_id, - by_hash, rot_a_data, rot_a_kind, self.log, )?; - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, rot_b_data, rot_b_kind, self.log, @@ -377,8 +357,6 @@ impl<'a> UpdatePlanBuilder<'a> { &mut self, artifact_id: ArtifactId, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { if self.host_phase_1.is_some() || self.host_phase_2_hash.is_some() { return Err(RepositoryError::DuplicateArtifactKind( @@ -407,18 +385,14 @@ impl<'a> UpdatePlanBuilder<'a> { Some(ArtifactIdData { id: phase_1_id, data: phase_1_data.clone() }); self.host_phase_2_hash = Some(phase_2_data.hash()); - record_extracted_artifact( + self.record_extracted_artifact( artifact_id.clone(), - by_id, - by_hash, phase_1_data, ArtifactKind::HOST_PHASE_1, self.log, )?; - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, phase_2_data, ArtifactKind::HOST_PHASE_2, self.log, @@ -431,8 +405,6 @@ impl<'a> UpdatePlanBuilder<'a> { &mut self, artifact_id: ArtifactId, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { if self.trampoline_phase_1.is_some() || self.trampoline_phase_2.is_some() @@ -470,18 +442,14 @@ impl<'a> UpdatePlanBuilder<'a> { self.trampoline_phase_2 = Some(ArtifactIdData { id: phase_2_id, data: phase_2_data.clone() }); - record_extracted_artifact( + self.record_extracted_artifact( artifact_id.clone(), - by_id, - by_hash, phase_1_data, ArtifactKind::TRAMPOLINE_PHASE_1, self.log, )?; - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, phase_2_data, ArtifactKind::TRAMPOLINE_PHASE_2, self.log, @@ -495,8 +463,6 @@ impl<'a> UpdatePlanBuilder<'a> { artifact_id: ArtifactId, artifact_hash: ArtifactHash, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { if self.control_plane_hash.is_some() { return Err(RepositoryError::DuplicateArtifactKind( @@ -516,10 +482,8 @@ impl<'a> UpdatePlanBuilder<'a> { self.control_plane_hash = Some(data.hash()); - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, data, KnownArtifactKind::ControlPlane.into(), self.log, @@ -533,8 +497,6 @@ impl<'a> UpdatePlanBuilder<'a> { artifact_id: ArtifactId, artifact_hash: ArtifactHash, stream: impl Stream> + Send, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, ) -> Result<(), RepositoryError> { let artifact_kind = artifact_id.kind.clone(); let artifact_hash_id = @@ -543,10 +505,8 @@ impl<'a> UpdatePlanBuilder<'a> { let data = self.extracted_artifacts.store(artifact_hash_id, stream).await?; - record_extracted_artifact( + self.record_extracted_artifact( artifact_id, - by_id, - by_hash, data, artifact_kind, self.log, @@ -660,7 +620,62 @@ impl<'a> UpdatePlanBuilder<'a> { Ok((image1, image2)) } - pub fn build(self) -> Result { + // Record an artifact in `by_id` and `by_hash`, or fail if either already has an + // entry for this id/hash. + fn record_extracted_artifact( + &mut self, + tuf_repo_artifact_id: ArtifactId, + data: ExtractedArtifactDataHandle, + data_kind: ArtifactKind, + log: &Logger, + ) -> Result<(), RepositoryError> { + use std::collections::hash_map::Entry; + + let artifact_hash_id = + ArtifactHashId { kind: data_kind.clone(), hash: data.hash() }; + + let by_hash_slot = match self.by_hash.entry(artifact_hash_id) { + Entry::Occupied(slot) => { + return Err(RepositoryError::DuplicateHashEntry( + slot.key().clone(), + )); + } + Entry::Vacant(slot) => slot, + }; + + info!( + log, "added artifact"; + "name" => %tuf_repo_artifact_id.name, + "kind" => %by_hash_slot.key().kind, + "version" => %tuf_repo_artifact_id.version, + "hash" => %by_hash_slot.key().hash, + "length" => data.file_size(), + ); + + self.by_id + .entry(tuf_repo_artifact_id.clone()) + .or_default() + .push(by_hash_slot.key().clone()); + + // In the artifacts_meta document, use the expanded artifact ID + // (artifact kind = data_kind, and name and version from + // tuf_repo_artifact_id). + let artifacts_meta_id = ArtifactId { + name: tuf_repo_artifact_id.name, + version: tuf_repo_artifact_id.version, + kind: data_kind, + }; + self.artifacts_meta.push(TufArtifactMeta { + id: artifacts_meta_id, + hash: data.hash(), + size: data.file_size() as u64, + }); + by_hash_slot.insert(data); + + Ok(()) + } + + pub fn build(self) -> Result { // Ensure our multi-board-supporting kinds have at least one board // present. for (kind, no_artifacts) in [ @@ -738,7 +753,7 @@ impl<'a> UpdatePlanBuilder<'a> { } } - Ok(UpdatePlan { + let plan = UpdatePlan { system_version: self.system_version, gimlet_sp: self.gimlet_sp, // checked above gimlet_rot_a: self.gimlet_rot_a, // checked above @@ -770,10 +785,24 @@ impl<'a> UpdatePlanBuilder<'a> { KnownArtifactKind::ControlPlane, ), )?, + }; + Ok(UpdatePlanBuildOutput { + plan, + by_id: self.by_id, + by_hash: self.by_hash, + artifacts_meta: self.artifacts_meta, }) } } +/// The output of [`UpdatePlanBuilder::build`]. +pub struct UpdatePlanBuildOutput { + pub plan: UpdatePlan, + pub by_id: BTreeMap>, + pub by_hash: HashMap, + pub artifacts_meta: Vec, +} + // This function takes and returns `id` to avoid an unnecessary clone; `id` will // be present in either the Ok tuple or the error. fn read_hubris_board_from_archive( @@ -807,48 +836,6 @@ fn read_hubris_board_from_archive( Ok((id, Board(board.to_string()))) } -// Record an artifact in `by_id` and `by_hash`, or fail if either already has an -// entry for this id/hash. -fn record_extracted_artifact( - tuf_repo_artifact_id: ArtifactId, - by_id: &mut BTreeMap>, - by_hash: &mut HashMap, - data: ExtractedArtifactDataHandle, - data_kind: ArtifactKind, - log: &Logger, -) -> Result<(), RepositoryError> { - use std::collections::hash_map::Entry; - - let artifact_hash_id = - ArtifactHashId { kind: data_kind, hash: data.hash() }; - - let by_hash_slot = match by_hash.entry(artifact_hash_id) { - Entry::Occupied(slot) => { - return Err(RepositoryError::DuplicateHashEntry( - slot.key().clone(), - )); - } - Entry::Vacant(slot) => slot, - }; - - info!( - log, "added artifact"; - "name" => %tuf_repo_artifact_id.name, - "kind" => %by_hash_slot.key().kind, - "version" => %tuf_repo_artifact_id.version, - "hash" => %by_hash_slot.key().hash, - "length" => data.file_size(), - ); - - by_id - .entry(tuf_repo_artifact_id) - .or_default() - .push(by_hash_slot.key().clone()); - by_hash_slot.insert(data); - - Ok(()) -} - #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -962,13 +949,11 @@ mod tests { let logctx = test_setup_log("test_update_plan_from_artifacts"); - let mut by_id = BTreeMap::new(); - let mut by_hash = HashMap::new(); let mut plan_builder = UpdatePlanBuilder::new("0.0.0".parse().unwrap(), &logctx.log) .unwrap(); - // Add a couple artifacts with kinds wicketd doesn't understand; it + // Add a couple artifacts with kinds wicketd/nexus don't understand; it // should still ingest and serve them. let mut expected_unknown_artifacts = BTreeSet::new(); @@ -986,8 +971,6 @@ mod tests { id, hash, futures::stream::iter([Ok(Bytes::from(data))]), - &mut by_id, - &mut by_hash, ) .await .unwrap(); @@ -1009,8 +992,6 @@ mod tests { id, hash, futures::stream::iter([Ok(Bytes::from(data))]), - &mut by_id, - &mut by_hash, ) .await .unwrap(); @@ -1038,8 +1019,6 @@ mod tests { id, hash, futures::stream::iter([Ok(Bytes::from(data))]), - &mut by_id, - &mut by_hash, ) .await .unwrap(); @@ -1067,8 +1046,6 @@ mod tests { id, hash, futures::stream::iter([Ok(data.clone())]), - &mut by_id, - &mut by_hash, ) .await .unwrap(); @@ -1095,14 +1072,13 @@ mod tests { id, hash, futures::stream::iter([Ok(data.clone())]), - &mut by_id, - &mut by_hash, ) .await .unwrap(); } - let plan = plan_builder.build().unwrap(); + let UpdatePlanBuildOutput { plan, by_id, .. } = + plan_builder.build().unwrap(); assert_eq!(plan.gimlet_sp.len(), 2); assert_eq!(plan.psc_sp.len(), 2); diff --git a/update-common/src/errors.rs b/update-common/src/errors.rs index 5fba43b9441..4d992e70b2d 100644 --- a/update-common/src/errors.rs +++ b/update-common/src/errors.rs @@ -21,8 +21,20 @@ pub enum RepositoryError { #[error("error creating temporary directory")] TempDirCreate(#[source] std::io::Error), + #[error("error creating temporary file")] + TempFileCreate(#[source] std::io::Error), + + #[error("error reading chunk off of input stream")] + ReadChunkFromStream(#[source] HttpError), + + #[error("error writing to temporary file")] + TempFileWrite(#[source] std::io::Error), + + #[error("error flushing temporary file")] + TempFileFlush(#[source] std::io::Error), + #[error("error creating temporary file in {path}")] - TempFileCreate { + NamedTempFileCreate { path: Utf8PathBuf, #[source] error: std::io::Error, @@ -138,10 +150,21 @@ impl RepositoryError { // Errors we had that are unrelated to the contents of a repository // uploaded by a client. RepositoryError::TempDirCreate(_) - | RepositoryError::TempFileCreate { .. } => { + | RepositoryError::TempFileCreate(_) + | RepositoryError::TempFileWrite(_) + | RepositoryError::TempFileFlush(_) + | RepositoryError::NamedTempFileCreate { .. } => { HttpError::for_unavail(None, message) } + // This error is bubbled up. + RepositoryError::ReadChunkFromStream(error) => HttpError { + status_code: error.status_code, + error_code: error.error_code.clone(), + external_message: error.external_message.clone(), + internal_message: error.internal_message.clone(), + }, + // Errors that are definitely caused by bad repository contents. RepositoryError::DuplicateArtifactKind(_) | RepositoryError::LocateTarget { .. } diff --git a/wicket/Cargo.toml b/wicket/Cargo.toml index efb8e51dff9..140c0115114 100644 --- a/wicket/Cargo.toml +++ b/wicket/Cargo.toml @@ -37,7 +37,7 @@ tokio = { workspace = true, features = ["full"] } tokio-util.workspace = true toml.workspace = true toml_edit.workspace = true -tui-tree-widget = "0.13.0" +tui-tree-widget.workspace = true unicode-width.workspace = true zeroize.workspace = true diff --git a/wicket/src/runner.rs b/wicket/src/runner.rs index 32fabde53ee..e83d321459a 100644 --- a/wicket/src/runner.rs +++ b/wicket/src/runner.rs @@ -34,7 +34,6 @@ use crate::{Action, Cmd, Event, KeyHandler, Recorder, State, TICK_INTERVAL}; // We can avoid a bunch of unnecessary type parameters by picking them ahead of time. pub type Term = Terminal>; -pub type Frame<'a> = ratatui::Frame<'a, CrosstermBackend>; const MAX_RECORDED_EVENTS: usize = 10000; diff --git a/wicket/src/state/update.rs b/wicket/src/state/update.rs index 6d8a1686144..77bbdd83d2d 100644 --- a/wicket/src/state/update.rs +++ b/wicket/src/state/update.rs @@ -333,6 +333,7 @@ impl UpdateItem { } } +#[derive(Debug, Copy, Clone)] pub enum UpdateState { NotStarted, Starting, diff --git a/wicket/src/ui/controls/mod.rs b/wicket/src/ui/controls/mod.rs index 4305fb58099..a2682b8052b 100644 --- a/wicket/src/ui/controls/mod.rs +++ b/wicket/src/ui/controls/mod.rs @@ -2,8 +2,8 @@ // 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/. -use crate::{Action, Cmd, Frame, State}; -use ratatui::layout::Rect; +use crate::{Action, Cmd, State}; +use ratatui::{layout::Rect, Frame}; /// A [`Control`] is the an item on a screen that can be selected and interacted with. /// Control's render [`ratatui::widgets::Widget`]s when drawn. diff --git a/wicket/src/ui/main.rs b/wicket/src/ui/main.rs index 58ea6c17715..379cbd03afd 100644 --- a/wicket/src/ui/main.rs +++ b/wicket/src/ui/main.rs @@ -8,11 +8,12 @@ use super::{Control, OverviewPane, RackSetupPane, StatefulList, UpdatePane}; use crate::ui::defaults::colors::*; use crate::ui::defaults::style; use crate::ui::widgets::Fade; -use crate::{Action, Cmd, Frame, State, Term}; +use crate::{Action, Cmd, State, Term}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, Paragraph}; +use ratatui::Frame; use slog::{o, Logger}; use wicketd_client::types::GetLocationResponse; diff --git a/wicket/src/ui/panes/overview.rs b/wicket/src/ui/panes/overview.rs index e8cf50bb32e..f2d4d4a7aba 100644 --- a/wicket/src/ui/panes/overview.rs +++ b/wicket/src/ui/panes/overview.rs @@ -16,11 +16,12 @@ use crate::ui::defaults::style; use crate::ui::widgets::IgnitionPopup; use crate::ui::widgets::{BoxConnector, BoxConnectorKind, Rack}; use crate::ui::wrap::wrap_text; -use crate::{Action, Cmd, Frame, State}; +use crate::{Action, Cmd, State}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::Style; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::Frame; use wicketd_client::types::RotState; use wicketd_client::types::SpComponentCaboose; use wicketd_client::types::SpComponentInfo; diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index 086d01ce9d2..ab85c638193 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -16,7 +16,6 @@ use crate::ui::widgets::PopupScrollOffset; use crate::Action; use crate::Cmd; use crate::Control; -use crate::Frame; use crate::State; use ratatui::layout::Constraint; use ratatui::layout::Direction; @@ -29,6 +28,7 @@ use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; +use ratatui::Frame; use std::borrow::Cow; use wicketd_client::types::Baseboard; use wicketd_client::types::CurrentRssUserConfig; diff --git a/wicket/src/ui/panes/update.rs b/wicket/src/ui/panes/update.rs index d14b90dfaba..be219849973 100644 --- a/wicket/src/ui/panes/update.rs +++ b/wicket/src/ui/panes/update.rs @@ -17,7 +17,7 @@ use crate::ui::widgets::{ PopupScrollOffset, StatusView, }; use crate::ui::wrap::wrap_text; -use crate::{Action, Cmd, Frame, State}; +use crate::{Action, Cmd, State}; use indexmap::IndexMap; use omicron_common::api::internal::nexus::KnownArtifactKind; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -26,6 +26,7 @@ use ratatui::widgets::{ Block, BorderType, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, }; +use ratatui::Frame; use slog::{info, o, Logger}; use tui_tree_widget::{Tree, TreeItem, TreeState}; use update_engine::{ @@ -148,8 +149,11 @@ pub struct UpdatePane { /// TODO: Move following state into global `State` so that recorder snapshots /// capture all state. - tree_state: TreeState, - items: Vec>, + /// + /// TODO: The generic parameter is carried over from earlier versions + /// of tui-tree-widget, but there's likely a better index type. + tree_state: TreeState, + items: Vec>, // Per-component update state that isn't serializable. component_state: BTreeMap, @@ -175,14 +179,20 @@ impl UpdatePane { pub fn new(log: &Logger) -> UpdatePane { let log = log.new(o!("component" => "UpdatePane")); let mut tree_state = TreeState::default(); - tree_state.select_first(); + let items = ALL_COMPONENT_IDS + .iter() + .enumerate() + .map(|(index, id)| { + TreeItem::new(index, id.to_string_uppercase(), vec![]) + .expect("no children so no duplicate identifiers") + }) + .collect::>(); + tree_state.select_first(&items); + UpdatePane { log, tree_state, - items: ALL_COMPONENT_IDS - .iter() - .map(|id| TreeItem::new(id.to_string_uppercase(), vec![])) - .collect(), + items, help: vec![ ("Expand", ""), ("Collapse", ""), @@ -826,7 +836,8 @@ impl UpdatePane { .update_state .items .iter() - .map(|(id, states)| { + .enumerate() + .map(|(index, (id, states))| { let children: Vec<_> = states .iter() .flat_map(|(component, s)| { @@ -834,9 +845,8 @@ impl UpdatePane { artifact_version(id, component, &versions); let installed_versions = all_installed_versions(id, component, inventory); - let contents_rect = self.contents_rect; installed_versions.into_iter().map(move |v| { - let spans = vec![ + vec![ Span::styled(v.title, style::selected()), Span::styled(v.version, style::selected_line()), Span::styled( @@ -844,17 +854,20 @@ impl UpdatePane { style::selected(), ), Span::styled(s.to_string(), s.style()), - ]; - TreeItem::new_leaf(align_by( - 0, - MAX_COLUMN_WIDTH, - contents_rect, - spans, - )) + ] }) }) + .enumerate() + .map(|(leaf_index, spans)| { + let contents_rect = self.contents_rect; + TreeItem::new_leaf( + leaf_index, + align_by(0, MAX_COLUMN_WIDTH, contents_rect, spans), + ) + }) .collect(); - TreeItem::new(id.to_string_uppercase(), children) + TreeItem::new(index, id.to_string_uppercase(), children) + .expect("tree does not contain duplicate identifiers") }) .collect(); } @@ -1365,6 +1378,7 @@ impl UpdatePane { // Draw the contents let tree = Tree::new(self.items.clone()) + .expect("tree does not have duplicate identifiers") .block(block.clone().borders(Borders::LEFT | Borders::RIGHT)) .style(style::plain_text()) .highlight_style(style::highlighted()); @@ -1421,12 +1435,11 @@ impl UpdatePane { Constraint::Length(cell_width), Constraint::Length(cell_width), ]; - let header_table = Table::new(std::iter::empty()) + let header_table = Table::new(std::iter::empty(), &width_constraints) .header( Row::new(vec!["COMPONENT", "VERSION", "TARGET", "STATUS"]) .style(header_style), ) - .widths(&width_constraints) .block(block.clone().title("OVERVIEW (* = active)")); frame.render_widget(header_table, self.table_headers_rect); @@ -1458,12 +1471,11 @@ impl UpdatePane { ]) }) }); - let version_table = - Table::new(version_rows).widths(&width_constraints).block( - block - .clone() - .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM), - ); + let version_table = Table::new(version_rows, &width_constraints).block( + block + .clone() + .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM), + ); frame.render_widget(version_table, self.status_view_version_rect); // Ensure the version table is connected to the table headers @@ -2413,7 +2425,7 @@ impl Control for UpdatePane { Some(Action::Redraw) } Cmd::GotoTop => { - self.tree_state.select_first(); + self.tree_state.select_first(&self.items); state.rack_state.selected = ALL_COMPONENT_IDS[0]; Some(Action::Redraw) } diff --git a/wicket/src/ui/splash.rs b/wicket/src/ui/splash.rs index cc8ab0bff82..9da9fa8648e 100644 --- a/wicket/src/ui/splash.rs +++ b/wicket/src/ui/splash.rs @@ -10,9 +10,10 @@ use super::defaults::colors::*; use super::defaults::dimensions::RectExt; use super::defaults::style; use super::widgets::{Logo, LogoState, LOGO_HEIGHT, LOGO_WIDTH}; -use crate::{Cmd, Frame, Term}; +use crate::{Cmd, Term}; use ratatui::style::Style; use ratatui::widgets::Block; +use ratatui::Frame; const TOTAL_FRAMES: usize = 100; diff --git a/wicket/src/ui/widgets/fade.rs b/wicket/src/ui/widgets/fade.rs index d1669cd5b7f..5462a4ecf29 100644 --- a/wicket/src/ui/widgets/fade.rs +++ b/wicket/src/ui/widgets/fade.rs @@ -9,15 +9,6 @@ pub struct Fade {} impl Widget for Fade { fn render(self, area: Rect, buf: &mut Buffer) { - for x in area.left()..area.right() { - for y in area.top()..area.bottom() { - buf.set_string( - x, - y, - buf.get(x, y).symbol.clone(), - style::faded_background(), - ); - } - } + buf.set_style(area, style::faded_background()); } } diff --git a/wicket/src/ui/widgets/status_view.rs b/wicket/src/ui/widgets/status_view.rs index 7418fed512a..b9e981c9bc8 100644 --- a/wicket/src/ui/widgets/status_view.rs +++ b/wicket/src/ui/widgets/status_view.rs @@ -6,10 +6,9 @@ use ratatui::{ layout::{Alignment, Rect}, text::Text, widgets::{Block, Borders, List, Paragraph, StatefulWidget, Widget}, + Frame, }; -use crate::Frame; - use super::{BoxConnector, BoxConnectorKind}; /// A displayer for the status view. diff --git a/wicketd/src/artifacts/store.rs b/wicketd/src/artifacts/store.rs index a5f24993a8f..01543432a23 100644 --- a/wicketd/src/artifacts/store.rs +++ b/wicketd/src/artifacts/store.rs @@ -3,11 +3,9 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::http_entrypoints::InstallableArtifacts; -use dropshot::HttpError; use omicron_common::api::external::SemverVersion; use omicron_common::update::ArtifactHashId; use slog::Logger; -use std::io; use std::sync::Arc; use std::sync::Mutex; use update_common::artifacts::ArtifactsWithPlan; @@ -32,22 +30,12 @@ impl WicketdArtifactStore { Self { log, artifacts_with_plan: Default::default() } } - pub(crate) async fn put_repository( + pub(crate) fn set_artifacts_with_plan( &self, - data: T, - ) -> Result<(), HttpError> - where - T: io::Read + io::Seek + Send + 'static, - { - slog::debug!(self.log, "adding repository"); - - let log = self.log.clone(); - let new_artifacts = ArtifactsWithPlan::from_zip(data, &log) - .await - .map_err(|error| error.to_http_error())?; - self.replace(new_artifacts); - - Ok(()) + artifacts_with_plan: ArtifactsWithPlan, + ) { + slog::debug!(self.log, "setting artifacts_with_plan"); + self.replace(artifacts_with_plan); } pub(crate) fn system_version_and_artifact_ids( diff --git a/wicketd/src/http_entrypoints.rs b/wicketd/src/http_entrypoints.rs index dbd3e310726..9c1740679f1 100644 --- a/wicketd/src/http_entrypoints.rs +++ b/wicketd/src/http_entrypoints.rs @@ -25,7 +25,6 @@ use dropshot::Path; use dropshot::RequestContext; use dropshot::StreamingBody; use dropshot::TypedBody; -use futures::TryStreamExt; use gateway_client::types::IgnitionCommand; use gateway_client::types::SpIdentifier; use gateway_client::types::SpType; @@ -44,11 +43,9 @@ use sled_hardware::Baseboard; use slog::o; use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::io; use std::net::IpAddr; use std::net::Ipv6Addr; use std::time::Duration; -use tokio::io::AsyncWriteExt; use wicket_common::rack_setup::PutRssUserConfigInsensitive; use wicket_common::update_events::EventReport; use wicket_common::WICKETD_TIMEOUT; @@ -570,44 +567,7 @@ async fn put_repository( ) -> Result { let rqctx = rqctx.context(); - // Create a temporary file to store the incoming archive. - let tempfile = tokio::task::spawn_blocking(|| { - camino_tempfile::tempfile().map_err(|err| { - HttpError::for_unavail( - None, - format!("failed to create temp file: {err}"), - ) - }) - }) - .await - .unwrap()?; - let mut tempfile = - tokio::io::BufWriter::new(tokio::fs::File::from_std(tempfile)); - - let mut body = std::pin::pin!(body.into_stream()); - - // Stream the uploaded body into our tempfile. - while let Some(bytes) = body.try_next().await? { - tempfile.write_all(&bytes).await.map_err(|err| { - HttpError::for_unavail( - None, - format!("failed to write to temp file: {err}"), - ) - })?; - } - - // Flush writes. We don't need to seek back to the beginning of the file - // because extracting the repository will do its own seeking as a part of - // unzipping this repo. - tempfile.flush().await.map_err(|err| { - HttpError::for_unavail( - None, - format!("failed to flush temp file: {err}"), - ) - })?; - - let tempfile = tempfile.into_inner().into_std().await; - rqctx.update_tracker.put_repository(io::BufReader::new(tempfile)).await?; + rqctx.update_tracker.put_repository(body.into_stream()).await?; Ok(HttpResponseUpdatedNoContent()) } diff --git a/wicketd/src/preflight_check/uplink.rs b/wicketd/src/preflight_check/uplink.rs index 25411f17a5b..47995f0c107 100644 --- a/wicketd/src/preflight_check/uplink.rs +++ b/wicketd/src/preflight_check/uplink.rs @@ -161,8 +161,11 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( |_cx| async { // Check that the port name is valid and that it has no links // configured already. - let port_id = PortId::from_str(&uplink.port) - .map_err(UplinkPreflightTerminalError::InvalidPortName)?; + let port_id = PortId::from_str(&uplink.port).map_err(|_| { + UplinkPreflightTerminalError::InvalidPortName( + uplink.port.clone(), + ) + })?; let links = dpd_client .link_list(&port_id) .await @@ -892,7 +895,7 @@ type DpdError = dpd_client::Error; #[derive(Debug, Error)] pub(crate) enum UplinkPreflightTerminalError { #[error("invalid port name: {0}")] - InvalidPortName(&'static str), + InvalidPortName(String), #[error("failed to connect to dpd to check for current configuration")] GetCurrentConfig(#[source] DpdError), #[error("uplink already configured - is rack already initialized?")] diff --git a/wicketd/src/update_tracker.rs b/wicketd/src/update_tracker.rs index 823a7964de5..eec3ee58685 100644 --- a/wicketd/src/update_tracker.rs +++ b/wicketd/src/update_tracker.rs @@ -18,8 +18,10 @@ use anyhow::bail; use anyhow::ensure; use anyhow::Context; use base64::Engine; +use bytes::Bytes; use display_error_chain::DisplayErrorChain; use dropshot::HttpError; +use futures::Stream; use futures::TryFutureExt; use gateway_client::types::HostPhase2Progress; use gateway_client::types::HostPhase2RecoveryImageId; @@ -48,7 +50,6 @@ use slog::Logger; use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::io; use std::net::SocketAddrV6; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -64,6 +65,7 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio_util::io::StreamReader; use update_common::artifacts::ArtifactIdData; +use update_common::artifacts::ArtifactsWithPlan; use update_common::artifacts::UpdatePlan; use update_engine::events::ProgressUnits; use update_engine::AbortHandle; @@ -342,15 +344,21 @@ impl UpdateTracker { } /// Updates the repository stored inside the update tracker. - pub(crate) async fn put_repository( + pub(crate) async fn put_repository( &self, - data: T, - ) -> Result<(), HttpError> - where - T: io::Read + io::Seek + Send + 'static, - { + stream: impl Stream> + Send + 'static, + ) -> Result<(), HttpError> { + // Build the ArtifactsWithPlan from the stream. + let artifacts_with_plan = ArtifactsWithPlan::from_stream( + stream, + // We don't have a good file name here because file contents are + // uploaded over stdin, so let ArtifactsWithPlan pick the name. + None, &self.log, + ) + .await + .map_err(|error| error.to_http_error())?; let mut update_data = self.sp_update_data.lock().await; - update_data.put_repository(data).await + update_data.set_artifacts_with_plan(artifacts_with_plan).await } /// Gets a list of artifacts stored in the update repository. @@ -725,10 +733,10 @@ impl UpdateTrackerData { } } - async fn put_repository(&mut self, data: T) -> Result<(), HttpError> - where - T: io::Read + io::Seek + Send + 'static, - { + async fn set_artifacts_with_plan( + &mut self, + artifacts_with_plan: ArtifactsWithPlan, + ) -> Result<(), HttpError> { // Are there any updates currently running? If so, then reject the new // repository. let running_sps = self @@ -745,8 +753,8 @@ impl UpdateTrackerData { )); } - // Put the repository into the artifact store. - self.artifact_store.put_repository(data).await?; + // Set the new artifacts_with_plan. + self.artifact_store.set_artifacts_with_plan(artifacts_with_plan); // Reset all running data: a new repository means starting afresh. self.sp_update_data.clear(); diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index b6d61d9ea58..49b2489c406 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -15,6 +15,7 @@ publish = false ### BEGIN HAKARI SECTION [dependencies] ahash = { version = "0.8.6" } +aho-corasick = { version = "1.0.4" } anyhow = { version = "1.0.75", features = ["backtrace"] } base16ct = { version = "0.2.0", default-features = false, features = ["alloc"] } bit-set = { version = "0.5.3" } @@ -54,7 +55,8 @@ gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway- generic-array = { version = "0.14.7", default-features = false, features = ["more_lengths", "zeroize"] } getrandom = { version = "0.2.10", default-features = false, features = ["js", "rdrand", "std"] } group = { version = "0.13.0", default-features = false, features = ["alloc"] } -hashbrown = { version = "0.13.2" } +hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.2", features = ["raw"] } +hashbrown-594e8ee84c453af0 = { package = "hashbrown", version = "0.13.2" } hex = { version = "0.4.3", features = ["serde"] } hmac = { version = "0.12.1", default-features = false, features = ["reset"] } hyper = { version = "0.14.27", features = ["full"] } @@ -79,11 +81,11 @@ petgraph = { version = "0.6.4", features = ["serde-1"] } postgres-types = { version = "0.2.6", default-features = false, features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } ppv-lite86 = { version = "0.2.17", default-features = false, features = ["simd", "std"] } predicates = { version = "3.1.0" } -proc-macro2 = { version = "1.0.74" } +proc-macro2 = { version = "1.0.78" } rand = { version = "0.8.5" } rand_chacha = { version = "0.3.1", default-features = false, features = ["std"] } -regex = { version = "1.10.2" } -regex-automata = { version = "0.4.3", default-features = false, features = ["dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "unicode"] } +regex = { version = "1.10.3" } +regex-automata = { version = "0.4.4", default-features = false, features = ["dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "unicode"] } regex-syntax = { version = "0.8.2" } reqwest = { version = "0.11.22", features = ["blocking", "json", "rustls-tls", "stream"] } ring = { version = "0.17.7", features = ["std"] } @@ -94,13 +96,12 @@ serde_json = { version = "1.0.111", features = ["raw_value", "unbounded_depth"] sha2 = { version = "0.10.8", features = ["oid"] } similar = { version = "2.3.0", features = ["inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } -snafu = { version = "0.7.5", features = ["futures"] } socket2 = { version = "0.5.5", default-features = false, features = ["all"] } spin = { version = "0.9.8" } string_cache = { version = "0.8.7" } subtle = { version = "2.5.0" } syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.46", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.48", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } time = { version = "0.3.27", features = ["formatting", "local-offset", "macros", "parsing"] } tokio = { version = "1.35.1", features = ["full", "test-util"] } tokio-postgres = { version = "0.7.10", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } @@ -113,7 +114,7 @@ trust-dns-proto = { version = "0.22.0" } unicode-bidi = { version = "0.3.13" } unicode-normalization = { version = "0.1.22" } usdt = { version = "0.3.5" } -uuid = { version = "1.6.1", features = ["serde", "v4"] } +uuid = { version = "1.7.0", features = ["serde", "v4"] } yasna = { version = "0.5.2", features = ["bit-vec", "num-bigint", "std", "time"] } zerocopy = { version = "0.7.31", features = ["derive", "simd"] } zeroize = { version = "1.7.0", features = ["std", "zeroize_derive"] } @@ -121,6 +122,7 @@ zip = { version = "0.6.6", default-features = false, features = ["bzip2", "defla [build-dependencies] ahash = { version = "0.8.6" } +aho-corasick = { version = "1.0.4" } anyhow = { version = "1.0.75", features = ["backtrace"] } base16ct = { version = "0.2.0", default-features = false, features = ["alloc"] } bit-set = { version = "0.5.3" } @@ -160,7 +162,8 @@ gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway- generic-array = { version = "0.14.7", default-features = false, features = ["more_lengths", "zeroize"] } getrandom = { version = "0.2.10", default-features = false, features = ["js", "rdrand", "std"] } group = { version = "0.13.0", default-features = false, features = ["alloc"] } -hashbrown = { version = "0.13.2" } +hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.2", features = ["raw"] } +hashbrown-594e8ee84c453af0 = { package = "hashbrown", version = "0.13.2" } hex = { version = "0.4.3", features = ["serde"] } hmac = { version = "0.12.1", default-features = false, features = ["reset"] } hyper = { version = "0.14.27", features = ["full"] } @@ -185,11 +188,11 @@ petgraph = { version = "0.6.4", features = ["serde-1"] } postgres-types = { version = "0.2.6", default-features = false, features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } ppv-lite86 = { version = "0.2.17", default-features = false, features = ["simd", "std"] } predicates = { version = "3.1.0" } -proc-macro2 = { version = "1.0.74" } +proc-macro2 = { version = "1.0.78" } rand = { version = "0.8.5" } rand_chacha = { version = "0.3.1", default-features = false, features = ["std"] } -regex = { version = "1.10.2" } -regex-automata = { version = "0.4.3", default-features = false, features = ["dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "unicode"] } +regex = { version = "1.10.3" } +regex-automata = { version = "0.4.4", default-features = false, features = ["dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "unicode"] } regex-syntax = { version = "0.8.2" } reqwest = { version = "0.11.22", features = ["blocking", "json", "rustls-tls", "stream"] } ring = { version = "0.17.7", features = ["std"] } @@ -200,13 +203,12 @@ serde_json = { version = "1.0.111", features = ["raw_value", "unbounded_depth"] sha2 = { version = "0.10.8", features = ["oid"] } similar = { version = "2.3.0", features = ["inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } -snafu = { version = "0.7.5", features = ["futures"] } socket2 = { version = "0.5.5", default-features = false, features = ["all"] } spin = { version = "0.9.8" } string_cache = { version = "0.8.7" } subtle = { version = "2.5.0" } syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.46", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.48", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } time = { version = "0.3.27", features = ["formatting", "local-offset", "macros", "parsing"] } time-macros = { version = "0.2.13", default-features = false, features = ["formatting", "parsing"] } tokio = { version = "1.35.1", features = ["full", "test-util"] } @@ -220,7 +222,7 @@ trust-dns-proto = { version = "0.22.0" } unicode-bidi = { version = "0.3.13" } unicode-normalization = { version = "0.1.22" } usdt = { version = "0.3.5" } -uuid = { version = "1.6.1", features = ["serde", "v4"] } +uuid = { version = "1.7.0", features = ["serde", "v4"] } yasna = { version = "0.5.2", features = ["bit-vec", "num-bigint", "std", "time"] } zerocopy = { version = "0.7.31", features = ["derive", "simd"] } zeroize = { version = "1.7.0", features = ["std", "zeroize_derive"] }