Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ If you want to deploy multiple flakes or a subset of profiles with one invocatio

Running in this mode, if any of the deploys fails, the deploy will be aborted and all successful deploys rolled back. `--rollback-succeeded false` can be used to override this behavior, otherwise the `auto-rollback` argument takes precedent.

You can also filter which profiles are deployed by group using `deploy --groups <group> [<group> ...]`. A profile is selected if any of the requested groups appears in its merged `groups` set.

If you require a signing key to push closures to your server, specify the path to it in the `LOCAL_KEY` environment variable.

Check out `deploy --help` for CLI flags! Remember to check there before making one-time changes to things like hostnames.
Expand Down Expand Up @@ -204,6 +206,10 @@ This is a set of options that can be put in any of the above definitions, with t
# This is an optional list of arguments that will be passed to SSH.
sshOpts = [ "-p" "2121" ];

# Optional groups used for filtering deployments.
# Can be a string or a list of strings; values from profile > node > deploy are merged as a set (deduplicated).
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README documentation states groups values are "merged as a set (deduplicated)" from "profile > node > deploy", but the > notation is used throughout the README (line 185) to denote override priority (where a profile's value takes precedence over the node's value). For groups specifically, ALL values from all three levels are unioned together — a profile with no groups still inherits groups from node and deploy levels. This union semantics is different from the override semantics of all other generic settings and is not clearly distinguished in the documentation. The comment should explicitly state that unlike other settings, groups from all levels are combined (not overridden), so users don't expect only the most specific level's groups to be used.

Suggested change
# Can be a string or a list of strings; values from profile > node > deploy are merged as a set (deduplicated).
# Can be a string or a list of strings; unlike other settings that follow
# profile > node > deploy override semantics, group values from all three
# levels are combined (unioned and deduplicated). A profile with no groups
# still inherits groups defined at the node and deploy levels.

Copilot uses AI. Check for mistakes.
groups = [ "web" "prod" ];

# Fast connection to the node. If this is true, copy the whole closure instead of letting the node substitute.
# This defaults to `false`
fastConnection = false;
Expand Down
16 changes: 16 additions & 0 deletions examples/groups/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2025 Serokell <https://serokell.io/>

SPDX-License-Identifier: MPL-2.0
-->

# Example group-based deployment

This example shows how to assign `groups` at deploy, node, and profile levels, then filter
deployments with `--groups`.

Example usage:
- Deploy only profiles matching the `web` group:
- `nix run github:serokell/deploy-rs -- --groups web`
- Deploy only profiles matching `blue` or `db`:
- `nix run github:serokell/deploy-rs -- --groups blue db`
33 changes: 33 additions & 0 deletions examples/groups/flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: 2025 Serokell <https://serokell.io/>
#
# SPDX-License-Identifier: MPL-2.0

{
description = "Deploy two profiles and filter by groups";

inputs.deploy-rs.url = "github:serokell/deploy-rs";

outputs = { self, nixpkgs, deploy-rs }: {
deploy = {
groups = [ "prod" ];
nodes.example = {
hostname = "localhost";
groups = [ "web" "edge" ];
profiles = {
hello = {
groups = "blue";
user = "balsoft";
path = deploy-rs.lib.x86_64-linux.setActivate nixpkgs.legacyPackages.x86_64-linux.hello "./bin/hello";
};
cowsay = {
groups = [ "green" "db" ];
user = "balsoft";
path = deploy-rs.lib.x86_64-linux.setActivate nixpkgs.legacyPackages.x86_64-linux.cowsay "./bin/cowsay";
};
};
};
};

checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib;
};
}
13 changes: 13 additions & 0 deletions interface.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
"type": "string"
}
},
"groups": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The groups schema in interface.json allows arrays but does not specify "uniqueItems": true. Since groups are modeled as a set (using BTreeSet in Rust, with duplicates silently deduplicated during deserialization), declaring uniqueItems: true would make the schema's intent clearer and allow schema validators to flag redundant entries. Compare profilesOrder at line 68 which already uses this constraint for similar reasons.

Suggested change
}
},
"uniqueItems": true

Copilot uses AI. Check for mistakes.
}
]
},
"fastConnection": {
"type": "boolean"
},
Expand Down
20 changes: 20 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ pub struct Opts {
/// Override the SSH options used
#[arg(long, allow_hyphen_values = true)]
ssh_opts: Option<String>,
/// Filter profiles by group (merged from profile/node/deploy)
#[arg(long, num_args = 1..)]
groups: Option<Vec<String>>,
/// Override if the connecting to the target node should be considered fast
#[arg(long)]
fast_connection: Option<bool>,
Expand Down Expand Up @@ -547,6 +550,17 @@ async fn run_deploy(
log_dir.as_deref(),
);

if let Some(ref groups) = cmd_overrides.groups {
if !deploy_data
.merged_settings
.groups
.iter()
.any(|g| groups.contains(g))
{
continue;
}
}

let mut deploy_defs = deploy_data.defs()?;

if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
Expand All @@ -570,6 +584,11 @@ async fn run_deploy(
parts.push((deploy_flake, deploy_data, deploy_defs));
}

if parts.is_empty() {
info!("No profiles matched selection.");
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --groups is specified and all profiles are filtered out, the function returns Ok(()) with just an info! log message "No profiles matched selection." This means the process exits with code 0, even when the user may have explicitly targeted a profile via a flake fragment (e.g., myflake#mynode.myprofile --groups somegroup). A user who misspelled a group name or forgot to assign groups to profiles would see no deployment occur but no error either, which can silently mask deployment mistakes.

Consider returning an error (or at minimum a warn! or error! level message) when groups filtering is active and results in zero matched profiles, so users get a clear signal that something may have gone wrong.

Suggested change
info!("No profiles matched selection.");
if cmd_overrides.groups.is_some() {
error!("No profiles matched selection after applying group filters. This may indicate a misspelled group name or missing group assignments.");
} else {
info!("No profiles matched selection.");
}

Copilot uses AI. Check for mistakes.
return Ok(());
}

if interactive {
prompt_deployment(&parts[..])?;
} else {
Expand Down Expand Up @@ -701,6 +720,7 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> {
ssh_user: opts.ssh_user,
profile_user: opts.profile_user,
ssh_opts: opts.ssh_opts,
groups: opts.groups,
fast_connection: opts.fast_connection,
auto_rollback: opts.auto_rollback,
hostname: opts.hostname,
Expand Down
37 changes: 36 additions & 1 deletion src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// SPDX-License-Identifier: MPL-2.0

use merge::Merge;
use serde::de::Deserializer;
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;

#[derive(Deserialize, Debug, Clone, Merge)]
Expand Down Expand Up @@ -37,6 +38,40 @@ pub struct GenericSettings {
pub remote_build: Option<bool>,
#[serde(rename(deserialize = "interactiveSudo"))]
pub interactive_sudo: Option<bool>,
#[serde(
default,
rename(deserialize = "groups"),
deserialize_with = "deserialize_groups"
)]
#[merge(strategy = merge_groups)]
pub groups: BTreeSet<String>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
String(String),
Vec(Vec<String>),
}

fn deserialize_groups<'de, D>(deserializer: D) -> Result<BTreeSet<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<StringOrVec>::deserialize(deserializer)?;
Ok(match value {
None => BTreeSet::new(),
Some(StringOrVec::String(s)) => {
let mut set = BTreeSet::new();
set.insert(s);
set
}
Some(StringOrVec::Vec(v)) => v.into_iter().collect(),
})
}

fn merge_groups(left: &mut BTreeSet<String>, right: BTreeSet<String>) {
left.extend(right);
}
Comment on lines +57 to 75
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deserialize_groups function (handling both string and array inputs) and merge_groups function (set-union semantics) have no unit tests. The codebase has tests for similar data-processing functions (e.g., test_parse_flake in src/lib.rs, command builder tests in src/deploy.rs). Tests for these functions would verify:

  • Deserializing a string value (e.g. groups = "blue") creates a single-element set
  • Deserializing an array of strings creates the correct set
  • Deserializing an absent field creates an empty set
  • Two overlapping sets merge correctly (union, no duplicates)

Copilot uses AI. Check for mistakes.

#[derive(Deserialize, Debug, Clone)]
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ pub struct CmdOverrides {
pub ssh_user: Option<String>,
pub profile_user: Option<String>,
pub ssh_opts: Option<String>,
pub groups: Option<Vec<String>>,
pub fast_connection: Option<bool>,
pub auto_rollback: Option<bool>,
pub hostname: Option<String>,
Expand Down
Loading