Skip to content

Commit 70534c9

Browse files
authored
[omdb] TUI for inspecting support bundles (#8034)
Adds the `omdb nexus sb inspect` command, which includes a dashboard that can be used inspect support bundles. - `dev-tools/omdb/src/bin/omdb/nexus.rs` includes the integration of this command. Support bundles may be accessed by: UUID, path, or by "most recently created". - `dev-tools/support-bundle-reader-lib` exposes a dashboard for actually running a TUI. This TUI acts on a dynamic `SupportBundleAccessor` trait, which can implement a file-opening and file-reader interface, regardless of where the support bundle is stored. - `dev-tools/support-bundle-reader-lib/src/dashboard.rs` actually implements the ratatui interface, which allows users to inspect parts of the support bundle, and optionally dump the contents of the bundles to stdout. ![image](https://github.com/user-attachments/assets/9b6e7df6-3123-497c-bc81-0e33b747bf5f)
1 parent ecfab33 commit 70534c9

File tree

9 files changed

+824
-0
lines changed

9 files changed

+824
-0
lines changed

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ members = [
4949
"dev-tools/releng",
5050
"dev-tools/repl-utils",
5151
"dev-tools/repo-depot-standalone",
52+
"dev-tools/support-bundle-reader-lib",
5253
"dev-tools/xtask",
5354
"dns-server",
5455
"dns-server-api",
@@ -200,6 +201,7 @@ default-members = [
200201
"dev-tools/releng",
201202
"dev-tools/repl-utils",
202203
"dev-tools/repo-depot-standalone",
204+
"dev-tools/support-bundle-reader-lib",
203205
# Do not include xtask in the list of default members, because this causes
204206
# hakari to not work as well and build times to be longer.
205207
# See omicron#4392.
@@ -695,6 +697,7 @@ strum = { version = "0.26", features = [ "derive" ] }
695697
subprocess = "0.2.9"
696698
subtle = "2.6.1"
697699
supports-color = "3.0.2"
700+
support-bundle-reader-lib = { path = "dev-tools/support-bundle-reader-lib" }
698701
swrite = "0.1.0"
699702
sync-ptr = "0.1.1"
700703
libsw = { version = "3.4.0", features = ["tokio"] }

dev-tools/omdb/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ slog.workspace = true
5959
slog-error-chain.workspace = true
6060
steno.workspace = true
6161
strum.workspace = true
62+
support-bundle-reader-lib.workspace = true
6263
supports-color.workspace = true
6364
tabled.workspace = true
6465
textwrap.workspace = true

dev-tools/omdb/src/bin/omdb/nexus.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ enum SupportBundleCommands {
501501
GetIndex(SupportBundleIndexArgs),
502502
/// View a file within a support bundle
503503
GetFile(SupportBundleFileArgs),
504+
/// Creates a dashboard for viewing the contents of a support bundle
505+
Inspect(SupportBundleInspectArgs),
504506
}
505507

506508
#[derive(Debug, Args)]
@@ -523,6 +525,23 @@ struct SupportBundleFileArgs {
523525
output: Option<Utf8PathBuf>,
524526
}
525527

528+
#[derive(Debug, Args)]
529+
struct SupportBundleInspectArgs {
530+
/// A specific bundle to inspect.
531+
///
532+
/// If none is supplied, the latest active bundle is used.
533+
/// Mutually exclusive with "path".
534+
#[arg(short, long)]
535+
id: Option<SupportBundleUuid>,
536+
537+
/// A local bundle file to inspect.
538+
///
539+
/// If none is supplied, the latest active bundle is used.
540+
/// Mutually exclusive with "id".
541+
#[arg(short, long)]
542+
path: Option<Utf8PathBuf>,
543+
}
544+
526545
impl NexusArgs {
527546
/// Run a `omdb nexus` subcommand.
528547
pub(crate) async fn run_cmd(
@@ -737,6 +756,9 @@ impl NexusArgs {
737756
NexusCommands::SupportBundles(SupportBundleArgs {
738757
command: SupportBundleCommands::GetFile(args),
739758
}) => cmd_nexus_support_bundles_get_file(&client, args).await,
759+
NexusCommands::SupportBundles(SupportBundleArgs {
760+
command: SupportBundleCommands::Inspect(args),
761+
}) => cmd_nexus_support_bundles_inspect(&client, args).await,
740762
}
741763
}
742764
}
@@ -3880,3 +3902,16 @@ async fn cmd_nexus_support_bundles_get_file(
38803902
})?;
38813903
Ok(())
38823904
}
3905+
3906+
/// Runs `omdb nexus support-bundles inspect`
3907+
async fn cmd_nexus_support_bundles_inspect(
3908+
client: &nexus_client::Client,
3909+
args: &SupportBundleInspectArgs,
3910+
) -> Result<(), anyhow::Error> {
3911+
support_bundle_reader_lib::run_dashboard(
3912+
client,
3913+
args.id,
3914+
args.path.as_ref(),
3915+
)
3916+
.await
3917+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "support-bundle-reader-lib"
3+
version = "0.1.0"
4+
edition = "2021"
5+
license = "MPL-2.0"
6+
7+
[lints]
8+
workspace = true
9+
10+
[dependencies]
11+
anyhow.workspace = true
12+
async-trait.workspace = true
13+
bytes.workspace = true
14+
camino.workspace = true
15+
crossterm.workspace = true
16+
futures.workspace = true
17+
nexus-client.workspace = true
18+
omicron-workspace-hack.workspace = true
19+
omicron-uuid-kinds.workspace = true
20+
ratatui.workspace = true
21+
reqwest.workspace = true
22+
tokio.workspace = true
23+
zip.workspace = true
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! APIs to help access bundles
6+
7+
use crate::index::SupportBundleIndex;
8+
use anyhow::Context as _;
9+
use anyhow::Result;
10+
use async_trait::async_trait;
11+
use bytes::Buf;
12+
use bytes::Bytes;
13+
use camino::Utf8Path;
14+
use camino::Utf8PathBuf;
15+
use futures::Stream;
16+
use futures::StreamExt;
17+
use omicron_uuid_kinds::GenericUuid;
18+
use omicron_uuid_kinds::SupportBundleUuid;
19+
use std::io;
20+
use std::pin::Pin;
21+
use std::task::Context;
22+
use std::task::Poll;
23+
use tokio::io::AsyncRead;
24+
use tokio::io::ReadBuf;
25+
26+
/// An I/O source which can read to a buffer
27+
///
28+
/// This describes access to individual files within the bundle.
29+
pub trait FileAccessor: AsyncRead + Unpin {}
30+
impl<T: AsyncRead + Unpin + ?Sized> FileAccessor for T {}
31+
32+
pub type BoxedFileAccessor<'a> = Box<dyn FileAccessor + 'a>;
33+
34+
/// Describes how the support bundle's data and metadata are accessed.
35+
#[async_trait]
36+
pub trait SupportBundleAccessor {
37+
/// Access the index of a support bundle
38+
async fn get_index(&self) -> Result<SupportBundleIndex>;
39+
40+
/// Access a file within the support bundle
41+
async fn get_file<'a>(
42+
&mut self,
43+
path: &Utf8Path,
44+
) -> Result<BoxedFileAccessor<'a>>
45+
where
46+
Self: 'a;
47+
}
48+
49+
pub struct StreamedFile<'a> {
50+
client: &'a nexus_client::Client,
51+
id: SupportBundleUuid,
52+
path: Utf8PathBuf,
53+
stream: Option<Pin<Box<dyn Stream<Item = reqwest::Result<Bytes>> + Send>>>,
54+
buffer: Bytes,
55+
}
56+
57+
impl<'a> StreamedFile<'a> {
58+
fn new(
59+
client: &'a nexus_client::Client,
60+
id: SupportBundleUuid,
61+
path: Utf8PathBuf,
62+
) -> Self {
63+
Self { client, id, path, stream: None, buffer: Bytes::new() }
64+
}
65+
66+
// NOTE: This is a distinct method from "new", because ideally some day we could
67+
// use range requests to stream out portions of the file.
68+
//
69+
// This means that we would potentially want to restart the stream with a different position.
70+
async fn start_stream(&mut self) -> Result<()> {
71+
// TODO: Add range headers, for range requests? Though this
72+
// will require adding support to Progenitor + Nexus too.
73+
let stream = self
74+
.client
75+
.support_bundle_download_file(
76+
self.id.as_untyped_uuid(),
77+
self.path.as_str(),
78+
)
79+
.await
80+
.with_context(|| {
81+
format!(
82+
"downloading support bundle file {}: {}",
83+
self.id, self.path
84+
)
85+
})?
86+
.into_inner_stream();
87+
88+
self.stream = Some(Box::pin(stream));
89+
Ok(())
90+
}
91+
}
92+
93+
impl AsyncRead for StreamedFile<'_> {
94+
fn poll_read(
95+
mut self: Pin<&mut Self>,
96+
cx: &mut Context<'_>,
97+
buf: &mut ReadBuf<'_>,
98+
) -> Poll<io::Result<()>> {
99+
while self.buffer.is_empty() {
100+
match futures::ready!(
101+
self.stream
102+
.as_mut()
103+
.expect("Stream must be initialized before polling")
104+
.as_mut()
105+
.poll_next(cx)
106+
) {
107+
Some(Ok(bytes)) => {
108+
self.buffer = bytes;
109+
}
110+
Some(Err(e)) => {
111+
return Poll::Ready(Err(io::Error::new(
112+
io::ErrorKind::Other,
113+
e,
114+
)));
115+
}
116+
None => return Poll::Ready(Ok(())), // EOF
117+
}
118+
}
119+
120+
let to_copy = std::cmp::min(self.buffer.len(), buf.remaining());
121+
buf.put_slice(&self.buffer[..to_copy]);
122+
self.buffer.advance(to_copy);
123+
124+
Poll::Ready(Ok(()))
125+
}
126+
}
127+
128+
/// Access to a support bundle from the internal API
129+
pub struct InternalApiAccess<'a> {
130+
client: &'a nexus_client::Client,
131+
id: SupportBundleUuid,
132+
}
133+
134+
impl<'a> InternalApiAccess<'a> {
135+
pub fn new(
136+
client: &'a nexus_client::Client,
137+
id: SupportBundleUuid,
138+
) -> Self {
139+
Self { client, id }
140+
}
141+
}
142+
143+
// Access for: The nexus internal API
144+
#[async_trait]
145+
impl<'c> SupportBundleAccessor for InternalApiAccess<'c> {
146+
async fn get_index(&self) -> Result<SupportBundleIndex> {
147+
let stream = self
148+
.client
149+
.support_bundle_index(self.id.as_untyped_uuid())
150+
.await
151+
.with_context(|| {
152+
format!("downloading support bundle index {}", self.id)
153+
})?
154+
.into_inner_stream();
155+
let s = utf8_stream_to_string(stream).await?;
156+
157+
Ok(SupportBundleIndex::new(&s))
158+
}
159+
160+
async fn get_file<'a>(
161+
&mut self,
162+
path: &Utf8Path,
163+
) -> Result<BoxedFileAccessor<'a>>
164+
where
165+
'c: 'a,
166+
{
167+
let mut file =
168+
StreamedFile::new(self.client, self.id, path.to_path_buf());
169+
file.start_stream()
170+
.await
171+
.with_context(|| "failed to start stream in get_file")?;
172+
Ok(Box::new(file))
173+
}
174+
}
175+
176+
pub struct LocalFileAccess {
177+
archive: zip::read::ZipArchive<std::fs::File>,
178+
}
179+
180+
impl LocalFileAccess {
181+
pub fn new(path: &Utf8Path) -> Result<Self> {
182+
let file = std::fs::File::open(path)?;
183+
Ok(Self { archive: zip::read::ZipArchive::new(file)? })
184+
}
185+
}
186+
187+
// Access for: Local zip files
188+
#[async_trait]
189+
impl SupportBundleAccessor for LocalFileAccess {
190+
async fn get_index(&self) -> Result<SupportBundleIndex> {
191+
let names: Vec<&str> = self.archive.file_names().collect();
192+
let all_names = names.join("\n");
193+
Ok(SupportBundleIndex::new(&all_names))
194+
}
195+
196+
async fn get_file<'a>(
197+
&mut self,
198+
path: &Utf8Path,
199+
) -> Result<BoxedFileAccessor<'a>> {
200+
let mut file = self.archive.by_name(path.as_str())?;
201+
let mut buf = Vec::new();
202+
std::io::copy(&mut file, &mut buf)?;
203+
204+
Ok(Box::new(AsyncZipFile { buf, copied: 0 }))
205+
}
206+
}
207+
208+
// We're currently buffering the entire file into memory, mostly because dealing with the lifetime
209+
// of ZipArchive and ZipFile objects is so difficult.
210+
pub struct AsyncZipFile {
211+
buf: Vec<u8>,
212+
copied: usize,
213+
}
214+
215+
impl AsyncRead for AsyncZipFile {
216+
fn poll_read(
217+
mut self: Pin<&mut Self>,
218+
_cx: &mut Context<'_>,
219+
buf: &mut ReadBuf<'_>,
220+
) -> Poll<io::Result<()>> {
221+
let to_copy =
222+
std::cmp::min(self.buf.len() - self.copied, buf.remaining());
223+
if to_copy == 0 {
224+
return Poll::Ready(Ok(()));
225+
}
226+
let src = &self.buf[self.copied..];
227+
buf.put_slice(&src[..to_copy]);
228+
self.copied += to_copy;
229+
Poll::Ready(Ok(()))
230+
}
231+
}
232+
233+
async fn utf8_stream_to_string(
234+
mut stream: impl futures::Stream<Item = reqwest::Result<bytes::Bytes>>
235+
+ std::marker::Unpin,
236+
) -> Result<String> {
237+
let mut bytes = Vec::new();
238+
while let Some(chunk) = stream.next().await {
239+
let chunk = chunk?;
240+
bytes.extend_from_slice(&chunk);
241+
}
242+
Ok(String::from_utf8(bytes)?)
243+
}

0 commit comments

Comments
 (0)