-
Notifications
You must be signed in to change notification settings - Fork 42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[v2] TUF integration in Nexus + update artifact fetching by sled-agent #717
Conversation
nexus is async, tough is not async, but tough uses reqwest in blocking mode which is async. https://docs.rs/reqwest/0.11.7/reqwest/blocking/index.html: > Conversely, the functionality in `reqwest::blocking` must not be > executed within an async runtime, or it will panic when attempting to > block. moves the relevant code out to updates.rs, since the function can't borrow self due to lifetime constraints.
... since We only get a JoinError if the task we're waiting on panics.
this fixes problematic generation of an OpenAPI interface, which in turn fixes generation of non-compiling code where `()` becomes an enum with no variants.
test_nexus_openapi_internal fails due to a change in dropshot, which I'm debugging now.
we intend to have an updates repository that customers can mirror on their internal networks in case they make the reasonable decision to not plug their racks into the internet. also: at some point we may choose to pivot the metadata URL used to "hide" updates from earlier versions of nexus, in case we don't invent another mechanism to do that. that is, we would publish one final nexus update to the update metadata in /metadata/, and that update would change nexus to use /metadata-new/. this allows us to force a two-stage update if necessary. or, perhaps, we might have other update channels that can be configured on the rack. a user may want to say "update to beta". having the entry point be a single URL allows us to build these abstractions without telling the user to change the URL in a specific way or requiring specific a specific mirroring setup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks awesome - adds useful functionality, has tests up and down the stack, and provides configuration that lets folks opt-in in both dev and production environments.
Thanks for all the hard work on this! Looks good, with some comments below, but nothing that I think should block us from moving forward.
/// Updates-related configuration. Updates APIs return 400 Bad Request when this is | ||
/// unconfigured. | ||
#[serde(default)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, this seems like a decent tradeoff to not break folks doing development.
|
||
// Fetch the artifact and write to the file in its entirety, | ||
// replacing it if it exists. | ||
// TODO: Would love to stream this instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is totally doable! I updated the file server example in Nexus to show how this works - and also added a test to show how streaming works on both the client / server side.
nexus/src/config.rs
Outdated
/** Trusted root.json role for the TUF updates repository. */ | ||
pub trusted_root: PathBuf, | ||
/** Default base URLs for the TUF repository. */ | ||
pub default_base_url: String, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So it seems straightforward to me that we'll host a TUF repo somewhere, and set the value according to that (for default_base_url
) but where would the root.json
file come from?
I saw you added it to the .gitignore
, but if we merge this PR and I want to actually set up a TUF repo + go through this workflow, where should I get this file from?
(If this is a step a developer must do manually, maybe we should add some docs?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is something I expect we'll add during CI. We might have a different root.json for test builds, for instance. (A generic root.json might work here, but how do you share that key, and how do you avoid shipping production builds with that role as the trusted root?)
pub async fn update_available_artifact_hard_delete_outdated( | ||
&self, | ||
current_targets_role_version: i64, | ||
) -> DeleteResult { | ||
// 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_available_artifact::dsl; | ||
diesel::delete(dsl::update_available_artifact) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a comment out of my own curiosity, and doesn't need to block us, but...
What are the implications for rollback here of deleting all artifacts with an old "target role version"? Does this prevent Nexus from being able to roll itself back?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
artifacts.json should likely contain the list of all valid updates, including what Nexus can decide to downgrade itself to. (This might not scale good, and maybe the document format should be redesigned?)
) -> Result<(), Error> { | ||
match artifact.kind { | ||
UpdateArtifactKind::Zone => { | ||
let directory = PathBuf::from("/var/tmp/zones"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're doing this right now, but if this is /opt/oxide
, this will "just work" with the rest of the Zone management stuff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, we have this in the demo branch we used yesterday. The main reason I'm keeping this as /var/tmp for now is because this will work in integration tests running on pretty much any box. Giving sled-agent some form of configuration for this value (which we can set to a TempDir
during testing!) is probably the right way forward here.
// 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? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The artifacts.json is "local to nexus", right? If it's in memory in Nexus, what's the potential issue?
Even if multiple Nexus instances are concurrently upserting these artifacts, the database will ultimately end up in the same final state, won't it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
artifacts.json leaves Nexus's memory after the TUF metadata is fetched, leaving the relevant contents behind in this table. So when Nexus is considering what updates to apply and how, it's going to rely solely on the data in the database. (That's the plan, at least.)
for sled in self | ||
.db_datastore | ||
.sled_list(&DataPageParams { | ||
marker: None, | ||
direction: PaginationOrder::Ascending, | ||
limit: NonZeroU32::new(100).unwrap(), | ||
}) | ||
.await? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We discussed this in-person, but I think there's a race condition here:
- We should ensure that multiple Nexii running concurrently don't accidentally downgrade sled agents
- We should ensure that multiple requests to a Sled agent to update an artifact don't actually retrigger the download, especially if a download is already in progress.
I don't want to hold up this PR, but we should document this limitations somewhere so we remember to fix 'em.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added to the bulleted list at the top!
// Demo-quality solution could be "destroy it on boot" or something? | ||
// (we aren't doing that yet). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we don't fix this "removal-of-old-artifacts" stuff now, could we file an issue / link it here?
(This seems like one of those solutions that could get us pretty far, but which we probably shouldn't ship with)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added this up top
// 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<u8>. | ||
// | ||
// 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a comment I think I left before experimenting with streaming in dropshot - the examples here: https://github.com/oxidecomputer/dropshot/blob/main/dropshot/tests/test_streaming.rs
should give us a mechanism to do this (specifically, hyper_staticfile::FileBytesStream::new
around a file)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added an explicit "fix the read into memory" bullet up top
#717) * WIP Nexus download endpoint * WIP writing files * free function now method * sorting out errs * Add tests, fix bugs * I guess compiling code is better than the alternative * I EXPECTORATEd that my JSON would be more up-to-date * initial work for using the tough client in nexus * run tough client in tokio::task::spawn_blocking nexus is async, tough is not async, but tough uses reqwest in blocking mode which is async. https://docs.rs/reqwest/0.11.7/reqwest/blocking/index.html: > Conversely, the functionality in `reqwest::blocking` must not be > executed within an async runtime, or it will panic when attempting to > block. moves the relevant code out to updates.rs, since the function can't borrow self due to lifetime constraints. * allow an unconfigured updates system * woo license header * fix hardcoded base URLs to use localhost, for now * add smf/nexus/root.json to .gitignore * rename columns to make more sense at first glance * keep table in sync with artifacts.json * make the tests happy * add /updates/refresh to oxapi_demo * fix examples/config-file.toml * test [updates] in config works * remove dead code * .unwrap() on JoinError from spawn_blocking ... since We only get a JoinError if the task we're waiting on panics. * wire download_artifact up to the database * tell all sleds to apply all updates * fixup! wire download_artifact up to the database * actually verify the target after download * Keep merging * use ..UpdatedNoContent instead of ..Ok<()> this fixes problematic generation of an OpenAPI interface, which in turn fixes generation of non-compiling code where `()` becomes an enum with no variants. * end-to-end updates test * move UpdateArtifactKind into common * use a single updates base URL we intend to have an updates repository that customers can mirror on their internal networks in case they make the reasonable decision to not plug their racks into the internet. also: at some point we may choose to pivot the metadata URL used to "hide" updates from earlier versions of nexus, in case we don't invent another mechanism to do that. that is, we would publish one final nexus update to the update metadata in /metadata/, and that update would change nexus to use /metadata-new/. this allows us to force a two-stage update if necessary. or, perhaps, we might have other update channels that can be configured on the rack. a user may want to say "update to beta". having the entry point be a single URL allows us to build these abstractions without telling the user to change the URL in a specific way or requiring specific a specific mirroring setup. * clean up unnecessary ResourceType variants * refactor to require full artifact descriptions * work around dropshot Response<Body> issue * undo this change * combine updates integ tests into one module * remove errant fixme * comment nit Co-authored-by: Sean Klein <sean@oxide.computer>
Previous PRs: #469 #457 — I do feel somewhat strange about opening a new PR but it's diverged significantly from where #469 left off and merges in #457, so a fresh PR is probably warranted.
RFD 183 recommends the use of TUF as a transport mechanism to get updates from Oxide to the rack for a few reasons, one of which is "I wrote a TUF client once already". This integrates tough into Nexus and adds code for sled-agent to download update artifacts from Nexus (ty @smklein).
There's still a lot of work to do for updates, but this is the foundation on which everything will work: Nexus fetches TUF metadata from an update server and stores a list of artifacts, decides when to apply updates and instructs sled-agent to do so, and serves those artifacts to sled-agent.
The life of an artifact
A repository has a single base URL, such as https://rack-updates.oxide.computer/v1 or https://internal-mirror.it.bigcorp.example/oxide. Under this base URL are
metadata
andtargets
prefixes as used by TUF.In TUF parlance, "target" refers to a file in the repository, as opposed to "metadata" or "role" which refer to the TUF-level metadata. We need a different word to refer to the blobs of data we want to make it all the way to sled-agents and get applied somehow, so we choose "artifact".
Artifacts are added to the TUF repository (an example of generating this repository can be found in https://github.com/oxidecomputer/omicron/blob/updates-demo/nexus/tests/integration_tests/updates.rs), along with an
artifacts.json
target that describes what each artifact does. An example:"target"
in the metadata entry refers to the name of the TUF repository target to download and verify to get the version 1 cockroach zone.Although we expect updates to eventually be automatic, it is currently manual; the
/updates/refresh
API is used to instruct Nexus to fetch the most recent TUF metadata and list of artifacts, and cache this list of artifacts in the database. This is the step that performs TUF repository validation. This assumes the database Nexus uses can only be modified via Nexus, as we do not cache the TUF metadata.In the future, Nexus can use this list of targets and the list of software in use across the rack to decide a plan to update any outdated software. Currently, Nexus tells every sled-agent to apply every update whenever this refresh occurs via sled-agent's
/update
endpoint. sled-agent then fetches the artifact from Nexus via the internal/artifacts/{kind}/{name}/{version}
endpoint. In the future, it will do something useful with this artifact. It currently just puts it on the filesystem.When Nexus is instructed to fetch an artifact, it stores them in a cache directory,
/var/tmp/oxide_artifacts
. This storage is treated as volatile; there's no assumptions made that an artifact was or was not previously stored here. An eventual TODO for me is to make Nexus re-verify the hash of a file as it is read back from the cache, and to make sled-agent verify the hash as it reads from Nexus's response.Things to convert into issues when this is merged
/artifacts/{kind}/{name}/{version}
endpoint. This might be fixed by improve OpenAPI description for Response<Body> endpoints dropshot#295. In the meantime, sled-agent directly hits this endpoint with the reqwestClient
instead of using the client method./artifacts/{kind}/{name}/{version}
(and its consumer in sled-agent) should stream data instead of reading it all into memory, especially when we start adding things like Oxide-provided OS images through the repository. Depending on how we want to go about this (wrt the above issue), we might want to use our normal pagination, implementRange
header support, or just stream the response body.Nexus::updates_refresh_metadata
should be turned into a single datastore function that wraps it in a transaction, to keep the list of artifacts in the database consistent.hey/can/you/look/../../../../up/the/directory/tree
and that should have a test written to ensure it errors.