Skip to content

Commit 330cefe

Browse files
feat(agent,dgw): add Hub Service auto-updater support (#1557)
Co-authored-by: Marc-André Moreau <mamoreau@devolutions.net>
1 parent 6fbbbea commit 330cefe

9 files changed

Lines changed: 275 additions & 54 deletions

File tree

crates/devolutions-agent-shared/src/update_json.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use std::fmt;
77
/// {
88
/// "Gateway": {
99
/// "TargetVersion": "1.2.3.4"
10+
/// },
11+
/// "HubService": {
12+
/// "TargetVersion": "latest"
1013
/// }
1114
/// }
1215
/// ```
@@ -16,6 +19,8 @@ use std::fmt;
1619
pub struct UpdateJson {
1720
#[serde(skip_serializing_if = "Option::is_none")]
1821
pub gateway: Option<ProductUpdateInfo>,
22+
#[serde(skip_serializing_if = "Option::is_none")]
23+
pub hub_service: Option<ProductUpdateInfo>,
1924
}
2025

2126
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

crates/devolutions-agent-shared/src/windows/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ pub const GATEWAY_UPDATE_CODE: Uuid = uuid!("{db3903d6-c451-4393-bd80-eb9f45b902
1616
///
1717
/// See [`GATEWAY_UPDATE_CODE`] for more information on update codes.
1818
pub const AGENT_UPDATE_CODE: Uuid = uuid!("{82318d3c-811f-4d5d-9a82-b7c31b076755}");
19+
/// MSI upgrade code for the Devolutions Hub Service.
20+
///
21+
/// See [`GATEWAY_UPDATE_CODE`] for more information on update codes.
22+
pub const HUB_SERVICE_UPDATE_CODE: Uuid = uuid!("{f437046e-8e13-430a-8c8f-29fcb9023b59}");

devolutions-agent/src/updater/detect.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use uuid::Uuid;
33

44
use devolutions_agent_shared::DateVersion;
5-
use devolutions_agent_shared::windows::{GATEWAY_UPDATE_CODE, registry};
5+
use devolutions_agent_shared::windows::{GATEWAY_UPDATE_CODE, HUB_SERVICE_UPDATE_CODE, registry};
66

77
use crate::updater::{Product, UpdaterError};
88

@@ -12,11 +12,17 @@ pub(crate) fn get_installed_product_version(product: Product) -> Result<Option<D
1212
Product::Gateway => {
1313
registry::get_installed_product_version(GATEWAY_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry)
1414
}
15+
Product::HubService => {
16+
registry::get_installed_product_version(HUB_SERVICE_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry)
17+
}
1518
}
1619
}
1720

1821
pub(crate) fn get_product_code(product: Product) -> Result<Option<Uuid>, UpdaterError> {
1922
match product {
2023
Product::Gateway => registry::get_product_code(GATEWAY_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry),
24+
Product::HubService => {
25+
registry::get_product_code(HUB_SERVICE_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry)
26+
}
2127
}
2228
}

devolutions-agent/src/updater/mod.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub(crate) use self::product::Product;
3535
const UPDATE_JSON_WATCH_INTERVAL: Duration = Duration::from_secs(3);
3636

3737
// List of updateable products could be extended in future
38-
const PRODUCTS: &[Product] = &[Product::Gateway];
38+
const PRODUCTS: &[Product] = &[Product::Gateway, Product::HubService];
3939

4040
/// Context for updater task
4141
struct UpdaterCtx {
@@ -210,8 +210,16 @@ async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result<UpdateJ
210210
let update_json_data = fs::read(update_file_path)
211211
.await
212212
.context("failed to read update.json file")?;
213+
214+
// Strip UTF-8 BOM if present (some editors add it)
215+
let data_without_bom = if update_json_data.starts_with(&[0xEF, 0xBB, 0xBF]) {
216+
&update_json_data[3..]
217+
} else {
218+
&update_json_data
219+
};
220+
213221
let update_json: UpdateJson =
214-
serde_json::from_slice(&update_json_data).context("failed to parse update.json file")?;
222+
serde_json::from_slice(data_without_bom).context("failed to parse update.json file")?;
215223

216224
Ok(update_json)
217225
}

devolutions-agent/src/updater/package.rs

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub(crate) async fn install_package(
2222
log_path: &Utf8Path,
2323
) -> Result<(), UpdaterError> {
2424
match ctx.product {
25-
Product::Gateway => install_msi(ctx, path, log_path).await,
25+
Product::Gateway | Product::HubService => install_msi(ctx, path, log_path).await,
2626
}
2727
}
2828

@@ -32,7 +32,7 @@ pub(crate) async fn uninstall_package(
3232
log_path: &Utf8Path,
3333
) -> Result<(), UpdaterError> {
3434
match ctx.product {
35-
Product::Gateway => uninstall_msi(ctx, product_code, log_path).await,
35+
Product::Gateway | Product::HubService => uninstall_msi(ctx, product_code, log_path).await,
3636
}
3737
}
3838

@@ -67,14 +67,46 @@ async fn install_msi(ctx: &UpdaterCtx, path: &Utf8Path, log_path: &Utf8Path) ->
6767
}
6868
}
6969

70-
if msi_install_result.is_err() {
71-
return Err(UpdaterError::MsiInstall {
72-
product: ctx.product,
73-
msi_path: path.to_owned(),
74-
});
70+
match msi_install_result {
71+
Ok(status) => {
72+
let exit_code = status.code().unwrap_or(-1);
73+
74+
// MSI exit codes:
75+
// 0 = Success
76+
// 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot)
77+
// 1641 = Success and reboot initiated
78+
// Other codes = Error
79+
match exit_code {
80+
0 => {
81+
info!("MSI installation completed successfully");
82+
Ok(())
83+
}
84+
3010 | 1641 => {
85+
// Our installers should not require a reboot, but if they do, log as warning
86+
// and continue since the installation technically succeeded
87+
warn!(
88+
%exit_code,
89+
"MSI installation completed but unexpectedly requires system reboot"
90+
);
91+
Ok(())
92+
}
93+
_ => {
94+
error!(%exit_code, "MSI installation failed with exit code");
95+
Err(UpdaterError::MsiInstall {
96+
product: ctx.product,
97+
msi_path: path.to_owned(),
98+
})
99+
}
100+
}
101+
}
102+
Err(_) => {
103+
error!("Failed to execute msiexec command");
104+
Err(UpdaterError::MsiInstall {
105+
product: ctx.product,
106+
msi_path: path.to_owned(),
107+
})
108+
}
75109
}
76-
77-
Ok(())
78110
}
79111

80112
async fn uninstall_msi(ctx: &UpdaterCtx, product_code: Uuid, log_path: &Utf8Path) -> Result<(), UpdaterError> {
@@ -101,14 +133,47 @@ async fn uninstall_msi(ctx: &UpdaterCtx, product_code: Uuid, log_path: &Utf8Path
101133
}
102134
}
103135

104-
if msi_uninstall_result.is_err() {
105-
return Err(UpdaterError::MsiUninstall {
106-
product: ctx.product,
107-
product_code,
108-
});
136+
match msi_uninstall_result {
137+
Ok(status) => {
138+
let exit_code = status.code().unwrap_or(-1);
139+
140+
// MSI exit codes:
141+
// 0 = Success
142+
// 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot)
143+
// 1641 = Success and reboot initiated
144+
// Other codes = Error
145+
match exit_code {
146+
0 => {
147+
info!(%product_code, "MSI uninstallation completed successfully");
148+
Ok(())
149+
}
150+
3010 | 1641 => {
151+
// Our installers should not require a reboot, but if they do, log as warning
152+
// and continue since the uninstallation technically succeeded
153+
warn!(
154+
%exit_code,
155+
%product_code,
156+
"MSI uninstallation completed but unexpectedly requires system reboot"
157+
);
158+
Ok(())
159+
}
160+
_ => {
161+
error!(%exit_code, %product_code, "MSI uninstallation failed with exit code");
162+
Err(UpdaterError::MsiUninstall {
163+
product: ctx.product,
164+
product_code,
165+
})
166+
}
167+
}
168+
}
169+
Err(_) => {
170+
error!(%product_code, "Failed to execute msiexec command");
171+
Err(UpdaterError::MsiUninstall {
172+
product: ctx.product,
173+
product_code,
174+
})
175+
}
109176
}
110-
111-
Ok(())
112177
}
113178

114179
fn ensure_enough_rights() -> Result<(), UpdaterError> {
@@ -159,7 +224,7 @@ fn ensure_enough_rights() -> Result<(), UpdaterError> {
159224

160225
pub(crate) fn validate_package(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> {
161226
match ctx.product {
162-
Product::Gateway => validate_msi(ctx, path),
227+
Product::Gateway | Product::HubService => validate_msi(ctx, path),
163228
}
164229
}
165230

devolutions-agent/src/updater/product.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@ use std::str::FromStr;
33

44
use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson};
55

6-
use crate::updater::productinfo::GATEWAY_PRODUCT_ID;
6+
use crate::updater::productinfo::{GATEWAY_PRODUCT_ID, HUB_SERVICE_PRODUCT_ID};
77

88
/// Product IDs to track updates for
99
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010
pub(crate) enum Product {
1111
Gateway,
12+
HubService,
1213
}
1314

1415
impl fmt::Display for Product {
1516
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1617
match self {
1718
Product::Gateway => write!(f, "Gateway"),
19+
Product::HubService => write!(f, "HubService"),
1820
}
1921
}
2022
}
@@ -25,6 +27,7 @@ impl FromStr for Product {
2527
fn from_str(s: &str) -> Result<Self, Self::Err> {
2628
match s {
2729
"Gateway" => Ok(Product::Gateway),
30+
"HubService" => Ok(Product::HubService),
2831
_ => Err(()),
2932
}
3033
}
@@ -34,18 +37,21 @@ impl Product {
3437
pub(crate) fn get_update_info(self, update_json: &UpdateJson) -> Option<ProductUpdateInfo> {
3538
match self {
3639
Product::Gateway => update_json.gateway.clone(),
40+
Product::HubService => update_json.hub_service.clone(),
3741
}
3842
}
3943

4044
pub(crate) const fn get_productinfo_id(self) -> &'static str {
4145
match self {
4246
Product::Gateway => GATEWAY_PRODUCT_ID,
47+
Product::HubService => HUB_SERVICE_PRODUCT_ID,
4348
}
4449
}
4550

4651
pub(crate) const fn get_package_extension(self) -> &'static str {
4752
match self {
4853
Product::Gateway => "msi",
54+
Product::HubService => "msi",
4955
}
5056
}
5157
}

0 commit comments

Comments
 (0)