WIP: Agent auto update#1726
WIP: Agent auto update#1726Vladyslav Nikonov (vnikonov-devolutions) wants to merge 3 commits intomasterfrom
Conversation
Let maintainers know that an action is required on their side
|
There was a problem hiding this comment.
Pull request overview
Adds “Agent auto update” support across the Windows Agent MSI, the Agent updater runtime, and Gateway’s API surface so that the Agent can self-update (manually triggered and on a maintenance-window schedule).
Changes:
- Introduces an updater shim executable (
devolutions-agent-updater.exe) and packages it into the Windows Agent MSI + CI artifacts. - Adds Gateway API endpoints to trigger an Agent update and to read/write Agent auto-update configuration.
- Extends the Agent updater to support
Agentas an updateable product and implements periodic self-update checks using interval + maintenance window settings.
Reviewed changes
Copilot reviewed 30 out of 33 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| package/AgentWindowsManaged/Resources/Strings_en-US.json | Adds Service Configuration dialog strings for auto-update. |
| package/AgentWindowsManaged/Resources/Strings.g.cs | Regenerates strongly-typed string IDs for new resources. |
| package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl | Adds French localized strings + updates feature name/description for updater feature. |
| package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl | Adds English localized strings + updates feature name/description for updater feature. |
| package/AgentWindowsManaged/Properties/AgentProperties.g.tt | Formatting-only updates to the T4 template. |
| package/AgentWindowsManaged/Properties/AgentProperties.g.cs | Formatting-only updates to generated properties. |
| package/AgentWindowsManaged/Program.cs | Adds updater shim file into MSI layout under an updater feature folder. |
| package/AgentWindowsManaged/Dialogs/Wizard.cs | Minor formatting change. |
| package/AgentWindowsManaged/DevolutionsAgent.csproj | Adds WixSharp.wix.bin reference (WiX toolset payload). |
| package/AgentWindowsManaged/Actions/CustomActions.cs | Adds MSI custom action to write Updater.AgentAutoUpdate.Enabled into agent.json. |
| package/AgentWindowsManaged/Actions/AgentActions.cs | Schedules new auto-update configuration custom action in execute sequence. |
| devolutions-gateway/src/api/update_agent.rs | New Gateway endpoints: trigger agent update + get/set agent auto-update config. |
| devolutions-gateway/src/api/update.rs | Extends update manifest payload to include agent: None. |
| devolutions-gateway/src/api/mod.rs | Wires new update-agent routes into the main router. |
| devolutions-agent/src/updater/productinfo/mod.rs | Adds productinfo ID constant for Agent. |
| devolutions-agent/src/updater/product_actions.rs | Adds Agent-specific update actions (self-update path) and guards unreachable branches. |
| devolutions-agent/src/updater/product.rs | Adds Product::Agent and integrates with UpdateJson/productinfo mapping. |
| devolutions-agent/src/updater/package.rs | Implements Agent self-update install via detached shim; skips in-process uninstall for Agent. |
| devolutions-agent/src/updater/mod.rs | Adds periodic polling for auto-update, interval parsing, maintenance window logic + tests, and version gating. |
| devolutions-agent/src/updater/error.rs | Adds updater shim specific error variants. |
| devolutions-agent/src/updater/detect.rs | Adds registry detection for installed Agent version/product-code. |
| devolutions-agent/src/config.rs | Adds updater.agent_auto_update config field (documented) to agent config DTO. |
| devolutions-agent/src/agent-updater.rs | New Windows-only shim executable that runs msiexec for uninstall/install silently. |
| devolutions-agent/Cargo.toml | Adds second binary target + Windows-only deps for interval/window logic. |
| crates/devolutions-agent-shared/src/update_json.rs | Extends UpdateJson schema with optional Agent section. |
| crates/devolutions-agent-shared/src/lib.rs | Exposes new agent auto-update module + re-exports config type. |
| crates/devolutions-agent-shared/src/agent_auto_update.rs | New helpers to read/write Updater.AgentAutoUpdate in agent.json. |
| crates/devolutions-agent-shared/Cargo.toml | Adds serde_json dependency for new config helpers. |
| ci/tlk.ps1 | Adds Windows build packaging for the new updater shim binary. |
| ci/package-agent-windows.ps1 | Threads updater exe through MSI packaging script and env vars. |
| Cargo.lock | Locks new Rust dependencies (humantime, time). |
| .github/workflows/package.yml | Includes shim in agent artifacts and wires DAGENT_UPDATER_EXECUTABLE. |
| .github/workflows/ci.yml | Exposes/upstreams the shim executable path into Windows packaging steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let updater_file_path = get_updater_file_path(); | ||
|
|
||
| if !updater_file_path.exists() { | ||
| return Err( | ||
| HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE).msg("Agent updater service is not installed") | ||
| ); | ||
| } |
There was a problem hiding this comment.
get_updater_file_path() points to .../update.json. Using !exists() as a proxy for “agent updater service is not installed” can produce false negatives (e.g., first run where the file hasn't been created yet, or if the file was deleted) and the error message becomes misleading. Consider attempting to create/write the file and mapping IO errors to 503/500, or checking a more reliable installation marker (service presence / shim executable) if you need an explicit “not installed” signal.
|
|
||
| # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. | ||
| # After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. | ||
| # After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. |
There was a problem hiding this comment.
Typo in comment: contsnts → contents.
| # After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. | |
| # After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. |
| try | ||
| { | ||
| using StreamReader reader = new StreamReader(path); | ||
| config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd()); |
There was a problem hiding this comment.
JsonConvert.DeserializeObject<Dictionary<string, object>> can legally return null without throwing (e.g., if the file contains JSON null). In that case config becomes null and the subsequent TryGetValue/indexing will throw, failing the install action. Consider null-coalescing the deserialization result to an empty dictionary (or explicitly handling a null root) before using it.
| config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd()); | |
| config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd()) ?? []; |
| updaterSection = existingObj.ToObject<Dictionary<string, object>>(); | ||
| } | ||
|
|
||
| updaterSection["AgentAutoUpdate"] = new Dictionary<string, bool> { { "Enabled", enable } }; |
There was a problem hiding this comment.
This overwrites the entire Updater section (and AgentAutoUpdate sub-object) with a new dictionary containing only Enabled. That will drop any existing updater settings (e.g., Updater.Enabled, interval/window values), which is likely unintended and can surprise admins who configured those fields. Prefer preserving existing Updater/AgentAutoUpdate objects and only updating the Enabled key.
| updaterSection["AgentAutoUpdate"] = new Dictionary<string, bool> { { "Enabled", enable } }; | |
| // Navigate into or create the "AgentAutoUpdate" section and only update the Enabled key. | |
| Dictionary<string, object> autoUpdateSection; | |
| if (updaterSection.TryGetValue("AgentAutoUpdate", out object existingAutoUpdate)) | |
| { | |
| if (existingAutoUpdate is Newtonsoft.Json.Linq.JObject existingAutoUpdateObj) | |
| { | |
| autoUpdateSection = existingAutoUpdateObj.ToObject<Dictionary<string, object>>() ?? []; | |
| } | |
| else if (existingAutoUpdate is Dictionary<string, object> existingAutoUpdateDict) | |
| { | |
| autoUpdateSection = existingAutoUpdateDict; | |
| } | |
| else | |
| { | |
| autoUpdateSection = []; | |
| } | |
| } | |
| else | |
| { | |
| autoUpdateSection = []; | |
| } | |
| autoUpdateSection["Enabled"] = enable; | |
| updaterSection["AgentAutoUpdate"] = autoUpdateSection; |
| // The first agent version with self-update support is 2026.2 (anything built after | ||
| // 2026.1.x lacks the updater shim and would permanently disable auto-update). | ||
| const AGENT_MIN_SELF_UPDATE_VERSION: DateVersion = DateVersion { | ||
| year: 2026, | ||
| month: 1, | ||
| day: 0, | ||
| revision: 0, | ||
| }; |
There was a problem hiding this comment.
The comment says the first version supporting self-update is 2026.2, but the constant is set to 2026.1.0.0. With the current <= checks, versions like 2026.1.5.0 would be treated as self-update capable (because they are > 2026.1.0.0), which could trigger an update to a build that lacks the updater shim and break future auto-updates. Consider setting the threshold to 2026.2.0.0 (or otherwise excluding all 2026.1.*) and using a < min_supported style comparison; also the comment likely meant “built before 2026.2”.
| /// is available, trigger a silent MSI update during the configured maintenance window. | ||
| /// | ||
| /// This setting can be managed remotely via the Devolutions Gateway API | ||
| /// (`GET`/`POST /jet/update-agent`) or set directly in this file. |
There was a problem hiding this comment.
The documentation references GET/POST /jet/update-agent, but the router in this PR exposes /jet/agent-update and /jet/agent-update-config. Please update the comment so operators can find the correct endpoints.
| /// (`GET`/`POST /jet/update-agent`) or set directly in this file. | |
| /// (`GET`/`POST /jet/agent-update` and `/jet/agent-update-config`) or set directly in this file. |
| /// | ||
| /// Returns the current `AgentAutoUpdate` configuration from `agent.json`. | ||
| /// When the section is absent the response contains the built-in defaults | ||
| /// (`Enabled: false`, `IntervalHours: 24`, window `02:00`-`04:00`). |
There was a problem hiding this comment.
This docstring mentions defaults like IntervalHours: 24, but the API types/config use Interval as a humantime string (default "1d") and window fields UpdateWindowStart/End. Updating the comment to match the actual payload would avoid confusing API consumers.
| /// (`Enabled: false`, `IntervalHours: 24`, window `02:00`-`04:00`). | |
| /// (`Enabled: false`, `Interval: "1d"`, `UpdateWindowStart: "02:00"`, `UpdateWindowEnd: "04:00"`). |
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thank you!
I roughly reviewed the API side and left a few questions to guide the design.
We’ll have to perform a dry run of the workflows to ensure everything is packaged as expected 💡
There was a problem hiding this comment.
I see the /jet/update route is already used to update the Devolutions Gateway itself and the Hub service. This PR writes to the same update.json file that /jet/update already writes to, but through a completely separate endpoint with a different API shape. That means we now have two routes that mutate the same file, with potentially different authorization, different parameter styles, and no coordination between them. Why not extend the existing endpoint instead?
The questions I have to direct the direction:
- Could we extend the existing
/jet/updateinstead? (In a clean way) - Is the Agent the only program we’ll ever want to auto-update?
- If we do add a separate endpoint, what prevents a proliferation of update endpoints as more products are added?
- If a separate endpoint is chosen, should it at least live under
/jet/update/agentto signal the relationship, rather than an orthogonal path? - Who should be authorized to trigger an Agent update? Same
gateway.updatescope, or a new one? This affects both approaches. - What happens if the Agent is asked to update itself while it's mid-way through processing another update (e.g.: Gateway)?
- The current
/jet/updateuses query params for version, should we take this opportunity to move version into the request body instead, for consistency and extensibility (e.g.: specifying multiple product versions in one call)?
| let update_json = UpdateJson { | ||
| agent: Some(ProductUpdateInfo { target_version }), | ||
| gateway: None, | ||
| hub_service: None, | ||
| }; | ||
|
|
||
| let update_json = serde_json::to_string(&update_json).map_err( | ||
| HttpError::internal() | ||
| .with_msg("failed to serialize the update manifest") | ||
| .err(), | ||
| )?; | ||
|
|
||
| std::fs::write(updater_file_path, update_json).map_err( | ||
| HttpError::internal() | ||
| .with_msg("failed to write the new `update.json` manifest on disk") | ||
| .err(), | ||
| )?; |
There was a problem hiding this comment.
note: Here we handle the json serialization and filesystem write in Devolutions Gateway itself.
There was a problem hiding this comment.
thought: And now in this module we use serde_json directly, and perform I/O with the filesystem. Just thought it was worth pointing out the difference, although I see the nature of the operations is materially different.
|
|
||
| [target.'cfg(windows)'.dependencies] | ||
| aws-lc-rs = "1.15" | ||
| humantime = "2" |
There was a problem hiding this comment.
issue: I would rather avoid humantime since this is purely machine-to-machine communication. We don’t need to add parsing overhead I think.
There was a problem hiding this comment.
Well, I think it still worth to use for more clean agent.json representation, so it could be edited manually on the machine; It is still possible to set interval as seconds without unit suffixes.
There was a problem hiding this comment.
Okay! Just confirming: is it something we really expect users to modify? I am under the impression we’ll only document the DVLS-path with UI. If you think it’s really worth it, you can leave humantime.
devolutions-agent/Cargo.toml
Outdated
| [[bin]] | ||
| name = "devolutions-agent" | ||
| path = "src/main.rs" | ||
|
|
||
| [[bin]] | ||
| name = "devolutions-agent-updater" | ||
| path = "src/agent-updater.rs" | ||
|
|
There was a problem hiding this comment.
issue: Adding a second binary to this crate has the benefit of simplicity, but the agent crate pulls in ironrdp, devolutions-pedm, win-api-wrappers with heavy Windows API features, rand, rustls, and more. The updater binary needs almost none of that, yet both [[bin]] targets link against the same lib.rs, so we’re paying for all of it in compile time and binary size.
Same-crate is fine as a starting point with a note to extract later, but we should not ship it as-is.
|
Also, please link a Jira ticket! Thanks 🙂 |
No description provided.