Skip to content

Commit 4431b67

Browse files
committed
refactoring
1 parent 2c017fc commit 4431b67

File tree

6 files changed

+224
-67
lines changed

6 files changed

+224
-67
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub use update_manifest::{
1515
UpdateProductKey, UpdateSchedule, VersionMajorV2, VersionSpecification, default_schedule_window_start,
1616
detect_update_manifest_major_version,
1717
};
18-
pub use update_status::UpdateStatus;
18+
pub use update_status::{UpdateStatus, UpdateStatusV2};
1919

2020
cfg_if! {
2121
if #[cfg(target_os = "windows")] {

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,10 @@ pub struct ProductUpdateInfo {
6262
pub target_version: VersionSpecification,
6363
}
6464

65-
/// Minor version of the V2 manifest format written by current build of the agent.
65+
/// Minor version of the V2 manifest format written by the current build of the agent.
6666
///
67-
/// The minor version tracks backwards-compatible changes within V2 (major version) manifest.
68-
/// Increment this value when adding new fields to `UpdateManifestV2` or making other non-breaking
69-
/// changes that require gateway and agent to be updated in tandem to take advantage of the new
70-
/// features.
67+
/// Increment this value when adding new fields to [`UpdateManifestV2`] or making other
68+
/// backwards-compatible changes that the gateway should be aware of.
7169
pub const UPDATE_MANIFEST_V2_MINOR_VERSION: u32 = 1;
7270

7371
pub fn default_schedule_window_start() -> u32 {
@@ -170,7 +168,7 @@ pub struct UpdateManifestV2 {
170168
/// [`UpdateManifest`] distinguish V2 from legacy V1 payloads and prevent further parsing
171169
/// attempt of V2 structure
172170
pub version_major: VersionMajorV2,
173-
/// Feature-set version within V2. Defaults to `0` when the field is absent in the file.
171+
/// Feature-set version within V2.
174172
pub version_minor: u32,
175173
/// Auto-update schedule set by the gateway. Agent persists it to `agent.json`.
176174
#[serde(skip_serializing_if = "Option::is_none")]
@@ -210,7 +208,7 @@ pub enum UpdateManifest {
210208
Legacy(UpdateManifestV1),
211209
}
212210

213-
fn strip_bom(data: &[u8]) -> &[u8] {
211+
pub(crate) fn strip_bom(data: &[u8]) -> &[u8] {
214212
data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data)
215213
}
216214

@@ -363,7 +361,7 @@ mod tests {
363361

364362
#[test]
365363
fn empty_v2_stub_parses_as_manifest() {
366-
let manifest = UpdateManifest::parse(br#"{"VersionMajor":2}"#).unwrap();
364+
let manifest = UpdateManifest::parse(br#"{"VersionMajor":2,"VersionMinor":1}"#).unwrap();
367365
assert!(matches!(manifest, UpdateManifest::ManifestV2(_)));
368366
assert!(manifest.into_products().is_empty());
369367
}

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

Lines changed: 155 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,48 @@ use std::collections::HashMap;
22

33
use serde::{Deserialize, Serialize};
44

5-
use crate::{ProductUpdateInfo, UpdateProductKey, UpdateSchedule};
5+
use crate::update_manifest::strip_bom;
6+
use crate::{ProductUpdateInfo, UPDATE_MANIFEST_V2_MINOR_VERSION, UpdateProductKey, UpdateSchedule, VersionMajorV2};
67

8+
/// Version 2 of the agent status format, written by agent >=2026.2.0.
9+
///
10+
/// Uses the same major version marker ([`VersionMajorV2`]) as [`crate::UpdateManifestV2`]
11+
/// so both files share the minor-version constant and version numbering scheme.
12+
///
13+
/// Example:
14+
/// ```json
15+
/// {
16+
/// "VersionMajor": 2,
17+
/// "VersionMinor": 1,
18+
/// "Schedule": { "Enabled": true, "Interval": 86400, "UpdateWindowStart": 7200 },
19+
/// "Products": { "Agent": { "TargetVersion": "2026.2.0" } }
20+
/// }
21+
/// ```
22+
///
723
/// Agent runtime status written to `agent_status.json` on agent start and refreshed
824
/// after each updater run or auto-update schedule change.
925
///
10-
/// The gateway reads this file for `GET /jet/update/schedule` so that it can surface
11-
/// current agent state without needing knowledge of the agent's internal `agent.json`
12-
/// configuration format.
26+
/// The gateway reads this file for `GET /jet/update` and `GET /jet/update/schedule` so
27+
/// that it can surface current agent state without needing knowledge of the agent's
28+
/// internal `agent.json` configuration format.
1329
///
1430
/// Unlike [`crate::UpdateManifest`] (`update.json`), this file is **read-only** for
1531
/// the Gateway service: its DACL grants NETWORK SERVICE read access but **no write
1632
/// access**. The agent is the sole writer.
1733
///
1834
/// Note: if the agent itself is being updated, `agent_status.json` will be
1935
/// automatically refreshed when the agent restarts after the update completes.
20-
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36+
#[derive(Debug, Clone, Serialize, Deserialize)]
2137
#[serde(rename_all = "PascalCase")]
22-
pub struct UpdateStatus {
38+
pub struct UpdateStatusV2 {
39+
/// Always `2` — reuses [`VersionMajorV2`] so the version numbering is consistent
40+
/// with [`crate::UpdateManifestV2`].
41+
pub version_major: VersionMajorV2,
42+
/// Feature-set version within V2.
43+
pub version_minor: u32,
2344
/// Current auto-update schedule configured for this agent.
2445
#[serde(skip_serializing_if = "Option::is_none")]
2546
pub schedule: Option<UpdateSchedule>,
26-
2747
/// Map of product name → currently **installed** version.
2848
///
2949
/// Each entry's `TargetVersion` field holds the installed version of the product,
@@ -32,9 +52,136 @@ pub struct UpdateStatus {
3252
pub products: HashMap<UpdateProductKey, ProductUpdateInfo>,
3353
}
3454

55+
impl Default for UpdateStatusV2 {
56+
fn default() -> Self {
57+
Self {
58+
version_major: VersionMajorV2,
59+
version_minor: UPDATE_MANIFEST_V2_MINOR_VERSION,
60+
schedule: None,
61+
products: HashMap::new(),
62+
}
63+
}
64+
}
65+
66+
/// A parsed agent status file: currently only V2 is defined.
67+
///
68+
/// Serde variant order is significant: `StatusV2` is tried first; its `VersionMajor`
69+
/// field causes deserialization to fail when the value is not `2`, allowing the untagged
70+
/// enum to fall through to future variants. When V3 is introduced, a `StatusV3`
71+
/// variant is inserted before `StatusV2`.
72+
#[derive(Debug, Clone, Serialize, Deserialize)]
73+
#[serde(untagged)]
74+
pub enum UpdateStatus {
75+
/// V2 format: contains `"VersionMajor": 2`.
76+
StatusV2(UpdateStatusV2),
77+
}
78+
3579
impl UpdateStatus {
3680
/// Parse `agent_status.json` bytes.
81+
///
82+
/// Strips a UTF-8 BOM if present before parsing.
3783
pub fn parse(data: &[u8]) -> serde_json::Result<Self> {
38-
serde_json::from_slice(data)
84+
serde_json::from_slice(strip_bom(data))
85+
}
86+
87+
/// Return the format version as a `"major.minor"` string (e.g. `"2.1"`).
88+
pub fn version_string(&self) -> String {
89+
match self {
90+
Self::StatusV2(v2) => format!("2.{}", v2.version_minor),
91+
}
92+
}
93+
94+
/// Borrow the schedule from whichever version is present.
95+
pub fn schedule(&self) -> Option<&UpdateSchedule> {
96+
match self {
97+
Self::StatusV2(v2) => v2.schedule.as_ref(),
98+
}
99+
}
100+
101+
/// Consume the status and return the product map from whichever version is present.
102+
pub fn into_products(self) -> HashMap<UpdateProductKey, ProductUpdateInfo> {
103+
match self {
104+
Self::StatusV2(v2) => v2.products,
105+
}
106+
}
107+
}
108+
109+
impl Default for UpdateStatus {
110+
fn default() -> Self {
111+
Self::StatusV2(UpdateStatusV2::default())
112+
}
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
#![allow(clippy::unwrap_used, reason = "test code can panic on errors")]
118+
119+
use super::*;
120+
use crate::VersionSpecification;
121+
122+
#[test]
123+
fn bom_is_stripped() {
124+
// UTF-8 BOM prefix
125+
let mut data = vec![0xEF, 0xBB, 0xBF];
126+
data.extend_from_slice(br#"{"VersionMajor":2,"VersionMinor":1}"#);
127+
let status = UpdateStatus::parse(&data).unwrap();
128+
assert!(matches!(status, UpdateStatus::StatusV2(_)));
129+
}
130+
131+
#[test]
132+
fn v2_minimal_parses() {
133+
let status = UpdateStatus::parse(br#"{"VersionMajor":2,"VersionMinor":1}"#).unwrap();
134+
assert!(matches!(status, UpdateStatus::StatusV2(_)));
135+
assert!(status.schedule().is_none());
136+
assert!(status.into_products().is_empty());
137+
}
138+
139+
#[test]
140+
fn wrong_major_fails() {
141+
assert!(UpdateStatus::parse(br#"{"VersionMajor":1,"VersionMinor":1}"#).is_err());
142+
assert!(UpdateStatus::parse(br#"{"VersionMajor":3,"VersionMinor":0}"#).is_err());
143+
}
144+
145+
#[test]
146+
fn v2_with_schedule_roundtrip() {
147+
let json = r#"{"VersionMajor":2,"VersionMinor":1,"Schedule":{"Enabled":true,"Interval":86400,"UpdateWindowStart":7200,"Products":[]}}"#;
148+
let status = UpdateStatus::parse(json.as_bytes()).unwrap();
149+
let schedule = status.schedule().unwrap();
150+
assert!(schedule.enabled);
151+
assert_eq!(schedule.interval, 86400);
152+
assert_eq!(schedule.update_window_start, 7200);
153+
let reserialized = serde_json::to_string(&status).unwrap();
154+
assert_eq!(reserialized, json);
155+
}
156+
157+
#[test]
158+
fn v2_with_products_roundtrip() {
159+
let json = r#"{"VersionMajor":2,"VersionMinor":1,"Products":{"Agent":{"TargetVersion":"2026.2.0"},"Gateway":{"TargetVersion":"latest"}}}"#;
160+
let status = UpdateStatus::parse(json.as_bytes()).unwrap();
161+
let products = status.into_products();
162+
assert_eq!(products.len(), 2);
163+
assert!(matches!(
164+
products[&UpdateProductKey::Gateway].target_version,
165+
VersionSpecification::Latest
166+
));
167+
assert!(matches!(
168+
products[&UpdateProductKey::Agent].target_version,
169+
VersionSpecification::Specific(_)
170+
));
171+
}
172+
173+
#[test]
174+
fn version_string_format() {
175+
let status = UpdateStatus::parse(br#"{"VersionMajor":2,"VersionMinor":3}"#).unwrap();
176+
assert_eq!(status.version_string(), "2.3");
177+
}
178+
179+
#[test]
180+
fn v2_stub_serialise_roundtrip() {
181+
let stub = UpdateStatus::StatusV2(UpdateStatusV2::default());
182+
let serialized = serde_json::to_string(&stub).unwrap();
183+
assert_eq!(serialized, r#"{"VersionMajor":2,"VersionMinor":1}"#);
184+
let back = UpdateStatus::parse(serialized.as_bytes()).unwrap();
185+
assert!(matches!(back, UpdateStatus::StatusV2(_)));
39186
}
40187
}

devolutions-agent/src/updater/mod.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use async_trait::async_trait;
2626
use camino::{Utf8Path, Utf8PathBuf};
2727
use devolutions_agent_shared::{
2828
DateVersion, ProductUpdateInfo, UpdateManifest, UpdateManifestV2, UpdateProductKey, UpdateSchedule, UpdateStatus,
29-
VersionSpecification, get_agent_status_file_path, get_updater_file_path,
29+
UpdateStatusV2, VersionSpecification, get_agent_status_file_path, get_updater_file_path,
3030
};
3131
use devolutions_gateway_task::{ShutdownSignal, Task};
3232
use notify_debouncer_mini::notify::RecursiveMode;
@@ -745,10 +745,11 @@ fn collect_installed_products() -> HashMap<UpdateProductKey, ProductUpdateInfo>
745745
async fn init_agent_status_json(schedule: Option<&UpdateSchedule>) -> anyhow::Result<()> {
746746
let status_file_path = get_agent_status_file_path();
747747

748-
let status = UpdateStatus {
748+
let status = UpdateStatus::StatusV2(UpdateStatusV2 {
749749
schedule: schedule.cloned(),
750750
products: collect_installed_products(),
751-
};
751+
..UpdateStatusV2::default()
752+
});
752753

753754
let json = serde_json::to_string_pretty(&status).context("failed to serialize agent_status.json")?;
754755
fs::write(&status_file_path, json)
@@ -783,10 +784,11 @@ async fn init_agent_status_json(schedule: Option<&UpdateSchedule>) -> anyhow::Re
783784
async fn update_agent_status_json(schedule: Option<&UpdateSchedule>) {
784785
let status_file_path = get_agent_status_file_path();
785786

786-
let status = UpdateStatus {
787+
let status = UpdateStatus::StatusV2(UpdateStatusV2 {
787788
schedule: schedule.cloned(),
788789
products: collect_installed_products(),
789-
};
790+
..UpdateStatusV2::default()
791+
});
790792

791793
match serde_json::to_string_pretty(&status) {
792794
Ok(json) => {

devolutions-gateway/openapi/gateway-api.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,8 @@ paths:
617617
description: Insufficient permissions
618618
'500':
619619
description: Failed to read agent status file
620+
'503':
621+
description: Agent updater service is unavailable
620622
security:
621623
- scope_token:
622624
- gateway.update.read
@@ -695,7 +697,7 @@ paths:
695697
'403':
696698
description: Insufficient permissions
697699
'500':
698-
description: Failed to read update manifest
700+
description: Failed to read agent status file
699701
'503':
700702
description: Agent updater service is unavailable
701703
security:
@@ -997,7 +999,12 @@ components:
997999
GetUpdateProductsResponse:
9981000
type: object
9991001
description: Installed version of each product, as reported by Devolutions Agent.
1002+
required:
1003+
- ManifestVersion
10001004
properties:
1005+
ManifestVersion:
1006+
type: string
1007+
description: Version of the `agent_status.json` format in `"major.minor"` form (e.g. `"1.1"`).
10011008
Products:
10021009
type: object
10031010
description: Map of product name to its currently installed version.
@@ -1007,6 +1014,7 @@ components:
10071014
type: object
10081015
description: Current auto-update schedule for Devolutions Agent.
10091016
required:
1017+
- ManifestVersion
10101018
- Enabled
10111019
- Interval
10121020
- UpdateWindowStart
@@ -1022,6 +1030,9 @@ components:
10221030
10231031
`0` means check once at `UpdateWindowStart`.
10241032
minimum: 0
1033+
ManifestVersion:
1034+
type: string
1035+
description: Version of the `agent_status.json` format in `"major.minor"` form (e.g. `"1.1"`).
10251036
Products:
10261037
type: array
10271038
items:

0 commit comments

Comments
 (0)