Skip to content

Commit 1a0bb74

Browse files
authored
nexus: split out control plane zones as separate artifacts (#7452)
The primary change here is adding a control knob to `ArtifactsWithPlan::from_stream` that either splits out the artifacts or combines all the zones into a single tarball. `ControlPlaneZonesMode::Composite` is the current behavior and is used in Wicket and existing unit tests. For the moment this doesn't support handling repositories with artifacts already split out, but the semantics of this setting are that if/when we do that, it will combine them into a single artifact for Wicket. `ControlPlaneZonesMode::Split` enables the new behavior and is used in Nexus. This makes the individual zone images available in the TUF Repo Depot and therefore immediately usable by Sled Agent when creating zones. Additionally, we add a check to assembling a TUF repo to ensure that no individual zone images have the same hash; since the fake zones created during tests had the same hashes, we learned such a repo would not be accepted by Nexus due to this check: https://github.com/oxidecomputer/omicron/blob/e5bf48fdba89707257ef6ba75375714359941370/update-common/src/artifacts/update_plan.rs#L875-L882 Closes #4411. (If we want to split out the control plane zones in actual repos we can open a new issue for that.)
1 parent 1dbbf1f commit 1a0bb74

File tree

24 files changed

+672
-313
lines changed

24 files changed

+672
-313
lines changed

Cargo.lock

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"api_identity",
44
"bootstore",
5+
"brand-metadata",
56
"certificates",
67
"clickhouse-admin",
78
"clickhouse-admin/api",
@@ -135,6 +136,7 @@ members = [
135136
default-members = [
136137
"api_identity",
137138
"bootstore",
139+
"brand-metadata",
138140
"certificates",
139141
"clickhouse-admin",
140142
"clickhouse-admin/api",
@@ -493,6 +495,7 @@ nexus-types = { path = "nexus/types" }
493495
nom = "7.1.3"
494496
num-integer = "0.1.46"
495497
num = { version = "0.4.3", default-features = false, features = [ "libm" ] }
498+
omicron-brand-metadata = { path = "brand-metadata" }
496499
omicron-clickhouse-admin = { path = "clickhouse-admin" }
497500
omicron-certificates = { path = "certificates" }
498501
omicron-cockroach-admin = { path = "cockroach-admin" }

brand-metadata/Cargo.toml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "omicron-brand-metadata"
3+
version = "0.1.0"
4+
edition = "2021"
5+
license = "MPL-2.0"
6+
7+
[dependencies]
8+
omicron-workspace-hack.workspace = true
9+
semver.workspace = true
10+
serde.workspace = true
11+
serde_json.workspace = true
12+
tar.workspace = true
13+
14+
[lints]
15+
workspace = true

brand-metadata/src/lib.rs

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
//! Handling of `oxide.json` metadata files in tarballs.
6+
//!
7+
//! `oxide.json` is originally defined by the omicron1(7) zone brand, which
8+
//! lives at <https://github.com/oxidecomputer/helios-omicron-brand>. tufaceous
9+
//! extended this format with additional archive types for identifying other
10+
//! types of tarballs; this crate covers those extensions so they can be used
11+
//! across the Omicron codebase.
12+
13+
use std::io::{Error, ErrorKind, Read, Result, Write};
14+
15+
use serde::{Deserialize, Serialize};
16+
17+
#[derive(Clone, Debug, Deserialize, Serialize)]
18+
pub struct Metadata {
19+
v: String,
20+
21+
// helios-build-utils defines a top-level `i` field for extra information,
22+
// but omicron-package doesn't use this for the package name and version.
23+
// We can also benefit from having rich types for these extra fields, so
24+
// any additional top-level fields (including `i`) that exist for a given
25+
// archive type should be deserialized as part of `ArchiveType`.
26+
#[serde(flatten)]
27+
t: ArchiveType,
28+
}
29+
30+
#[derive(Clone, Debug, Deserialize, Serialize)]
31+
#[serde(rename_all = "snake_case", tag = "t")]
32+
pub enum ArchiveType {
33+
// Originally defined in helios-build-utils (part of helios-omicron-brand):
34+
Baseline,
35+
Layer(LayerInfo),
36+
Os,
37+
38+
// tufaceous extensions:
39+
Rot,
40+
ControlPlane,
41+
}
42+
43+
#[derive(Clone, Debug, Deserialize, Serialize)]
44+
pub struct LayerInfo {
45+
pub pkg: String,
46+
pub version: semver::Version,
47+
}
48+
49+
impl Metadata {
50+
pub fn new(archive_type: ArchiveType) -> Metadata {
51+
Metadata { v: "1".into(), t: archive_type }
52+
}
53+
54+
pub fn append_to_tar<T: Write>(
55+
&self,
56+
a: &mut tar::Builder<T>,
57+
mtime: u64,
58+
) -> Result<()> {
59+
let mut b = serde_json::to_vec(self)?;
60+
b.push(b'\n');
61+
62+
let mut h = tar::Header::new_ustar();
63+
h.set_entry_type(tar::EntryType::Regular);
64+
h.set_username("root")?;
65+
h.set_uid(0);
66+
h.set_groupname("root")?;
67+
h.set_gid(0);
68+
h.set_path("oxide.json")?;
69+
h.set_mode(0o444);
70+
h.set_size(b.len().try_into().unwrap());
71+
h.set_mtime(mtime);
72+
h.set_cksum();
73+
74+
a.append(&h, b.as_slice())?;
75+
Ok(())
76+
}
77+
78+
/// Read `Metadata` from a tar archive.
79+
///
80+
/// `oxide.json` is generally the first file in the archive, so this should
81+
/// be a just-opened archive with no entries already read.
82+
pub fn read_from_tar<T: Read>(a: &mut tar::Archive<T>) -> Result<Metadata> {
83+
for entry in a.entries()? {
84+
let mut entry = entry?;
85+
if entry.path()? == std::path::Path::new("oxide.json") {
86+
return Ok(serde_json::from_reader(&mut entry)?);
87+
}
88+
}
89+
Err(Error::new(ErrorKind::InvalidData, "oxide.json is not present"))
90+
}
91+
92+
pub fn archive_type(&self) -> &ArchiveType {
93+
&self.t
94+
}
95+
96+
pub fn is_layer(&self) -> bool {
97+
matches!(&self.t, ArchiveType::Layer(_))
98+
}
99+
100+
pub fn layer_info(&self) -> Result<&LayerInfo> {
101+
match &self.t {
102+
ArchiveType::Layer(info) => Ok(info),
103+
_ => Err(Error::new(
104+
ErrorKind::InvalidData,
105+
"archive is not the \"layer\" type",
106+
)),
107+
}
108+
}
109+
110+
pub fn is_baseline(&self) -> bool {
111+
matches!(&self.t, ArchiveType::Baseline)
112+
}
113+
114+
pub fn is_os(&self) -> bool {
115+
matches!(&self.t, ArchiveType::Os)
116+
}
117+
118+
pub fn is_rot(&self) -> bool {
119+
matches!(&self.t, ArchiveType::Rot)
120+
}
121+
122+
pub fn is_control_plane(&self) -> bool {
123+
matches!(&self.t, ArchiveType::ControlPlane)
124+
}
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
131+
#[test]
132+
fn test_deserialize() {
133+
let metadata: Metadata = serde_json::from_str(
134+
r#"{"v":"1","t":"layer","pkg":"nexus","version":"12.0.0-0.ci+git3a2ed5e97b3"}"#,
135+
)
136+
.unwrap();
137+
assert!(metadata.is_layer());
138+
let info = metadata.layer_info().unwrap();
139+
assert_eq!(info.pkg, "nexus");
140+
assert_eq!(info.version, "12.0.0-0.ci+git3a2ed5e97b3".parse().unwrap());
141+
142+
let metadata: Metadata = serde_json::from_str(
143+
r#"{"v":"1","t":"os","i":{"checksum":"42eda100ee0e3bf44b9d0bb6a836046fa3133c378cd9d3a4ba338c3ba9e56eb7","name":"ci 3a2ed5e/9d37813 2024-12-20 08:54"}}"#,
144+
).unwrap();
145+
assert!(metadata.is_os());
146+
147+
let metadata: Metadata =
148+
serde_json::from_str(r#"{"v":"1","t":"control_plane"}"#).unwrap();
149+
assert!(metadata.is_control_plane());
150+
}
151+
}

common/src/api/internal/nexus.rs

+3
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,10 @@ pub enum KnownArtifactKind {
305305
GimletRotBootloader,
306306
Host,
307307
Trampoline,
308+
/// Composite artifact of all control plane zones
308309
ControlPlane,
310+
/// Individual control plane zone
311+
Zone,
309312

310313
// PSC Artifacts
311314
PscSp,

nexus/src/app/background/tasks/tuf_artifact_replication.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ enum ArtifactHandle {
126126
}
127127

128128
impl ArtifactHandle {
129-
async fn file(&self) -> anyhow::Result<tokio::fs::File> {
129+
async fn file(&self) -> std::io::Result<tokio::fs::File> {
130130
match self {
131131
ArtifactHandle::Extracted(handle) => handle.file().await,
132132
#[cfg(test)]

nexus/src/app/update/mod.rs

+10-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use nexus_db_queries::context::OpContext;
1313
use omicron_common::api::external::{
1414
Error, SemverVersion, TufRepoInsertResponse, TufRepoInsertStatus,
1515
};
16-
use update_common::artifacts::ArtifactsWithPlan;
16+
use update_common::artifacts::{ArtifactsWithPlan, ControlPlaneZonesMode};
1717

1818
mod common_sp_update;
1919
mod host_phase1_updater;
@@ -51,10 +51,15 @@ impl super::Nexus {
5151
Error::internal_error("updates system not initialized")
5252
})?;
5353

54-
let artifacts_with_plan =
55-
ArtifactsWithPlan::from_stream(body, Some(file_name), &self.log)
56-
.await
57-
.map_err(|error| error.to_http_error())?;
54+
let artifacts_with_plan = ArtifactsWithPlan::from_stream(
55+
body,
56+
Some(file_name),
57+
ControlPlaneZonesMode::Split,
58+
&self.log,
59+
)
60+
.await
61+
.map_err(|error| error.to_http_error())?;
62+
5863
// Now store the artifacts in the database.
5964
let tuf_repo_description = TufRepoDescription::from_external(
6065
artifacts_with_plan.description().clone(),

nexus/tests/integration_tests/updates.rs

+21
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,27 @@ async fn test_repo_upload() -> Result<()> {
131131
.map(|artifact| artifact.hash)
132132
.collect::<HashSet<_>>()
133133
.len();
134+
// The repository description should have `Zone` artifacts instead of the
135+
// composite `ControlPlane` artifact.
136+
assert_eq!(
137+
initial_description
138+
.artifacts
139+
.iter()
140+
.filter_map(|artifact| {
141+
if artifact.id.kind == KnownArtifactKind::Zone.into() {
142+
Some(&artifact.id.name)
143+
} else {
144+
None
145+
}
146+
})
147+
.collect::<Vec<_>>(),
148+
["zone1", "zone2"]
149+
);
150+
assert!(!initial_description
151+
.artifacts
152+
.iter()
153+
.any(|artifact| artifact.id.kind
154+
== KnownArtifactKind::ControlPlane.into()));
134155

135156
// The artifact replication background task should have been activated, and
136157
// we should see a local repo and successful PUTs.

sled-agent/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ omicron-workspace-hack.workspace = true
108108
slog-error-chain.workspace = true
109109
walkdir.workspace = true
110110
zip.workspace = true
111+
omicron-brand-metadata.workspace = true
111112

112113
[target.'cfg(target_os = "illumos")'.dependencies]
113114
opte-ioctl.workspace = true

sled-agent/src/bootstrap/http_entrypoints.rs

+6-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use sled_agent_types::rack_ops::RackOperationStatus;
2828
use sled_hardware_types::Baseboard;
2929
use sled_storage::manager::StorageHandle;
3030
use slog::Logger;
31+
use slog_error_chain::InlineErrorChain;
3132
use sprockets_tls::keys::SprocketsConfig;
3233
use std::net::Ipv6Addr;
3334
use tokio::sync::mpsc::error::TrySendError;
@@ -85,10 +86,11 @@ impl BootstrapAgentApi for BootstrapAgentImpl {
8586
) -> Result<HttpResponseOk<Vec<Component>>, HttpError> {
8687
let ctx = rqctx.context();
8788
let updates = UpdateManager::new(ctx.updates.clone());
88-
let components = updates
89-
.components_get()
90-
.await
91-
.map_err(|err| HttpError::for_internal_error(err.to_string()))?;
89+
let components = updates.components_get().await.map_err(|err| {
90+
HttpError::for_internal_error(
91+
InlineErrorChain::new(&err).to_string(),
92+
)
93+
})?;
9294
Ok(HttpResponseOk(components))
9395
}
9496

0 commit comments

Comments
 (0)