Skip to content

Commit 1179141

Browse files
committed
Add initial HostApp model
This also extracts the hostapp information from the target state and converts it to a target hostapp. Change-type: patch
1 parent e90c404 commit 1179141

File tree

8 files changed

+275
-23
lines changed

8 files changed

+275
-23
lines changed

helios-api/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use helios_util::types::Uuid;
3434
// However that means that if at some point we want to support a new target state endpoint version,
3535
// the local API will change.
3636
// FIXME: define API specific input types and validations
37-
use helios_remote::types::AppTarget;
37+
use helios_remote::types::UserAppTarget as AppTarget;
3838

3939
pub enum Listener {
4040
Tcp(TcpListener),

helios-integration-tests/src/lib.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,7 @@ mod tests {
121121
}
122122
}
123123
}
124-
}
125-
);
124+
});
126125
let body = client
127126
.post(format!("{HELIOS_URL}/v3/device/apps/test-app"))
128127
.json(&target)

helios-remote-types/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ version.workspace = true
1616
helios-util = { path = "../helios-util", version = "0" }
1717

1818
serde.workspace = true
19+
serde_json.workspace = true
1920

2021
[dev-dependencies]
2122
serde_json.workspace = true

helios-remote-types/src/lib.rs

Lines changed: 185 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,155 @@ pub struct DeviceTarget {
1818
pub name: String,
1919

2020
#[serde(default)]
21-
pub apps: HashMap<Uuid, AppTarget>,
21+
pub apps: AppTargetMap,
22+
}
23+
24+
#[derive(Clone, Debug, Default)]
25+
pub struct AppTargetMap(HashMap<Uuid, AppTarget>);
26+
27+
impl Deref for AppTargetMap {
28+
type Target = HashMap<Uuid, AppTarget>;
29+
fn deref(&self) -> &Self::Target {
30+
&self.0
31+
}
32+
}
33+
34+
impl DerefMut for AppTargetMap {
35+
fn deref_mut(&mut self) -> &mut Self::Target {
36+
&mut self.0
37+
}
38+
}
39+
40+
impl IntoIterator for AppTargetMap {
41+
type Item = (Uuid, AppTarget);
42+
type IntoIter = std::collections::hash_map::IntoIter<Uuid, AppTarget>;
43+
44+
fn into_iter(self) -> Self::IntoIter {
45+
self.0.into_iter()
46+
}
47+
}
48+
impl<'de> Deserialize<'de> for AppTargetMap {
49+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
50+
where
51+
D: serde::Deserializer<'de>,
52+
{
53+
#[derive(Deserialize, Clone, Debug)]
54+
struct RemoteAppTarget {
55+
pub id: u32,
56+
pub name: String,
57+
#[serde(default)]
58+
pub is_host: bool,
59+
#[serde(default)]
60+
pub releases: ReleaseTargetMap,
61+
}
62+
63+
let mut apps: HashMap<Uuid, RemoteAppTarget> = HashMap::deserialize(deserializer)?;
64+
65+
// validate that there is only one hostapp, and it has the appropriate metadata
66+
let hostapps: HashMap<Uuid, RemoteAppTarget> = apps
67+
.iter()
68+
.filter(|(_, app)| app.is_host)
69+
.map(|(uuid, app)| (uuid.clone(), app.clone()))
70+
.collect();
71+
72+
if hostapps.len() > 1 {
73+
return Err(serde::de::Error::custom(
74+
"only one target hostapp is allowed",
75+
));
76+
}
77+
78+
if let Some((uuid, app)) = hostapps.into_iter().last() {
79+
if app.releases.is_empty() {
80+
return Err(serde::de::Error::custom(
81+
"the hostapp must have at least one target release",
82+
));
83+
}
84+
85+
let release = app.releases.values().last().unwrap();
86+
let hostapp = release.services.values().find(|svc| {
87+
svc.labels
88+
.get("io.balena.image.class")
89+
.map(|value| value == "hostapp")
90+
.is_some()
91+
});
92+
93+
if hostapp.is_none_or(|svc| {
94+
!svc.labels
95+
.contains_key("io.balena.private.hostapp.board-rev")
96+
}) {
97+
// The target OS may not have a board revision as it may be
98+
// before v6.1.18. We ignore the hostapp in that case
99+
apps.remove(&uuid);
100+
}
101+
}
102+
103+
let apps = apps
104+
.into_iter()
105+
.map(|(app_uuid, app)| {
106+
let RemoteAppTarget {
107+
id,
108+
name,
109+
releases,
110+
is_host,
111+
} = app;
112+
if !is_host {
113+
(
114+
app_uuid,
115+
AppTarget::User(UserAppTarget { id, name, releases }),
116+
)
117+
} else {
118+
// we have already validated that the release exists
119+
let (release_uuid, release) = releases.into_iter().last().unwrap();
120+
let mut service = release
121+
.services
122+
.into_values()
123+
.find(|s| {
124+
s.labels
125+
.get("io.balena.image.class")
126+
.map(|v| v == "hostapp")
127+
.is_some()
128+
})
129+
.unwrap();
130+
131+
let board_rev = service
132+
.labels
133+
.remove("io.balena.private.hostapp.board-rev")
134+
.unwrap();
135+
136+
(
137+
app_uuid,
138+
AppTarget::Host(HostAppTarget {
139+
release_uuid,
140+
image: service.image,
141+
board_rev,
142+
}),
143+
)
144+
}
145+
})
146+
.collect();
147+
148+
Ok(AppTargetMap(apps))
149+
}
150+
}
151+
152+
#[derive(Clone, Debug)]
153+
pub enum AppTarget {
154+
User(UserAppTarget),
155+
Host(HostAppTarget),
156+
}
157+
158+
#[derive(Clone, Debug)]
159+
pub struct HostAppTarget {
160+
pub release_uuid: Uuid,
161+
pub image: ImageUri,
162+
pub board_rev: String,
22163
}
23164

24165
/// Target app as defined by the remote backend
25166
#[derive(Deserialize, Clone, Debug)]
26-
pub struct AppTarget {
167+
pub struct UserAppTarget {
27168
pub id: u32,
28169
pub name: String,
29-
30-
#[serde(default)]
31-
pub is_host: bool,
32-
33170
#[serde(default)]
34171
pub releases: ReleaseTargetMap,
35172
}
@@ -94,6 +231,9 @@ pub struct ReleaseTarget {
94231
pub struct ServiceTarget {
95232
pub id: u32,
96233
pub image: ImageUri,
234+
235+
#[serde(default)]
236+
pub labels: HashMap<String, String>,
97237
}
98238

99239
// FIXME: add remaining fields
@@ -139,4 +279,43 @@ mod tests {
139279
let release = serde_json::from_value::<ReleaseTargetMap>(json);
140280
assert!(release.is_err());
141281
}
282+
283+
#[test]
284+
fn test_rejects_target_apps_with_more_than_one_hostapp() {
285+
let json = json!({
286+
"app-one": {
287+
"id": 1,
288+
"name": "ubuntu",
289+
"is_host": true,
290+
},
291+
"app-two": {
292+
"id": 2,
293+
"name": "fedora",
294+
"is_host": true,
295+
}
296+
});
297+
298+
let apps = serde_json::from_value::<AppTargetMap>(json);
299+
assert!(apps.is_err_and(|e| e.to_string() == "only one target hostapp is allowed"));
300+
}
301+
302+
#[test]
303+
fn test_rejects_target_apps_with_no_releases() {
304+
let json = json!({
305+
"app-one": {
306+
"id": 1,
307+
"name": "ubuntu",
308+
"is_host": true,
309+
"releases": {}
310+
},
311+
312+
});
313+
314+
let apps = serde_json::from_value::<AppTargetMap>(json);
315+
assert!(
316+
apps.is_err_and(
317+
|e| e.to_string() == "the hostapp must have at least one target release"
318+
)
319+
);
320+
}
142321
}

helios-state/src/models/app.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
33
use std::collections::BTreeMap;
44

55
use crate::common_types::Uuid;
6-
use crate::remote_types::AppTarget as RemoteAppTarget;
6+
use crate::remote_types::UserAppTarget as RemoteAppTarget;
77

88
use super::release::Release;
99

helios-state/src/models/device.rs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ use serde::{Deserialize, Serialize};
55

66
use crate::common_types::{ImageUri, OperatingSystem, Uuid};
77
use crate::oci::RegistryAuth;
8-
use crate::remote_types::DeviceTarget as RemoteDeviceTarget;
8+
use crate::remote_types::{AppTarget as RemoteAppTarget, DeviceTarget as RemoteDeviceTarget};
99

1010
use super::app::App;
11+
use super::hostapp::HostApp;
1112
use super::image::Image;
1213

1314
pub type RegistryAuthSet = HashSet<RegistryAuth>;
@@ -41,6 +42,11 @@ pub struct Device {
4142
#[serde(default)]
4243
pub apps: BTreeMap<Uuid, App>,
4344

45+
/// The hostapp configuration
46+
#[serde(skip_serializing_if = "Option::is_none")]
47+
#[serde(default)]
48+
pub hostapp: Option<HostApp>,
49+
4450
#[serde(default)]
4551
pub needs_cleanup: bool,
4652
}
@@ -54,6 +60,7 @@ impl Device {
5460
auths: RegistryAuthSet::new(),
5561
images: BTreeMap::new(),
5662
apps: BTreeMap::new(),
63+
hostapp: None,
5764
needs_cleanup: false,
5865
}
5966
}
@@ -64,12 +71,14 @@ impl From<Device> for DeviceTarget {
6471
let Device {
6572
name,
6673
apps,
74+
hostapp,
6775
needs_cleanup,
6876
..
6977
} = device;
7078
Self {
7179
name,
7280
apps,
81+
hostapp,
7382
needs_cleanup,
7483
}
7584
}
@@ -79,22 +88,32 @@ impl From<RemoteDeviceTarget> for DeviceTarget {
7988
fn from(tgt: RemoteDeviceTarget) -> Self {
8089
let RemoteDeviceTarget { name, apps, .. } = tgt;
8190

82-
// This makes user app support optional while the feature is being developed
83-
let apps = if cfg!(feature = "userapps") {
84-
apps.into_iter()
85-
// filter host apps for now
86-
.filter(|(_, app)| !app.is_host)
87-
.map(|(uuid, app)| (uuid, app.into()))
88-
.collect()
89-
} else {
90-
BTreeMap::new()
91-
};
91+
let mut userapps = BTreeMap::new();
92+
let mut hostapps = Vec::new();
93+
for (app_uuid, app) in apps {
94+
match app {
95+
// Read the hostapp info if it exists and the feature is enabled
96+
RemoteAppTarget::Host(hostapp) => {
97+
if cfg!(feature = "balenahup") {
98+
hostapps.push((app_uuid, hostapp).into());
99+
}
100+
}
101+
// Read the userapp info if it exists and the feature is enabled
102+
RemoteAppTarget::User(userapp) => {
103+
if cfg!(feature = "userapps") {
104+
userapps.insert(app_uuid, userapp.into());
105+
}
106+
}
107+
};
108+
}
92109

93-
// TODO: process the hostapp target separately
110+
// Get only the first hostapp if any
111+
let hostapp = hostapps.pop();
94112

95113
Self {
96114
name: Some(name),
97-
apps,
115+
apps: userapps,
116+
hostapp,
98117
needs_cleanup: false,
99118
}
100119
}

helios-state/src/models/hostapp.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use mahler::State;
2+
use serde::{Deserialize, Serialize};
3+
use thiserror::Error;
4+
5+
use crate::common_types::{ImageUri, Uuid};
6+
use crate::remote_types::HostAppTarget as RemoteHostAppTarget;
7+
8+
#[derive(State, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
9+
pub struct HostApp {
10+
/// The host app uuid
11+
///
12+
/// The value will not be available on first boot (it's not provided by the host OS)
13+
/// so it will be read from local storage
14+
pub app_uuid: Uuid,
15+
16+
/// The host app release
17+
///
18+
/// The value will not be available on first boot (it's not provided by the host OS)
19+
/// so it will be read from local storage
20+
pub release_uuid: Uuid,
21+
22+
/// The host app image
23+
///
24+
/// This is needed for reporting and will be stored on local storage
25+
pub image: ImageUri,
26+
27+
/// The board revision
28+
///
29+
/// This is used for comparing with the target hostapp as the image digest cannot be used
30+
pub board_rev: String,
31+
}
32+
33+
#[derive(Error, Debug)]
34+
#[error("invalid hostapp: ${0}")]
35+
pub struct InvalidHostApp(&'static str);
36+
37+
impl From<(Uuid, RemoteHostAppTarget)> for HostAppTarget {
38+
fn from((app_uuid, app): (Uuid, RemoteHostAppTarget)) -> Self {
39+
let RemoteHostAppTarget {
40+
release_uuid,
41+
image,
42+
board_rev,
43+
} = app;
44+
45+
HostAppTarget {
46+
app_uuid,
47+
release_uuid,
48+
image,
49+
board_rev,
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)