Merge pull request #124 from DeterminateSystems/cel-expressions

This commit is contained in:
Luc Perkins 2024-07-02 07:54:49 -07:00 committed by GitHub
commit 6ba8ec538e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1299 additions and 420 deletions

View File

@ -36,6 +36,21 @@ jobs:
- name: cargo test
run: nix develop -c cargo test
check-flake-cel-condition:
name: Check flake.lock test (CEL condition)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check flake.lock
run: |
nix develop -c \
cargo run -- \
--condition "supportedRefs.contains(gitRef) && numDaysOld < 30 && owner == 'NixOS'" \
./tests/flake.cel.0.lock
check-flake-dirty:
name: Check flake.lock test (dirty 😈)
runs-on: ubuntu-22.04

879
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
[package]
name = "flake-checker"
version = "0.1.20"
version = "0.2.0"
edition = "2021"
[workspace]
resolver = "2"
members = [".", "parse-flake-lock"]
[workspace.dependencies]
@ -13,11 +14,8 @@ serde_json = { version = "1.0.100", default-features = false, features = [
] }
thiserror = { version = "1.0.40", default-features = false }
[features]
default = []
allowed-refs = []
[dependencies]
cel-interpreter = { version = "0.7.1", default-features = false }
chrono = { version = "0.4.25", default-features = false, features = ["clock"] }
clap = { version = "4.3.0", default-features = false, features = [
"derive",
@ -27,8 +25,7 @@ clap = { version = "4.3.0", default-features = false, features = [
] }
handlebars = { version = "4.3.7", default-features = false }
is_ci = "1.1.1"
once_cell = { version = "1.19.0", default-features = false }
parse-flake-lock = { path = "parse-flake-lock" }
parse-flake-lock = { path = "./parse-flake-lock" }
reqwest = { version = "0.11.18", default-features = false, features = [
"blocking",
"json",
@ -38,3 +35,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { version = "0.10.6", default-features = false }
thiserror = { workspace = true }
[features]
default = []
allowed-refs = []

View File

@ -14,21 +14,83 @@ nix run github:DeterminateSystems/flake-checker
nix run github:DeterminateSystems/flake-checker /path/to/flake.lock
```
Nix Flake Checker looks at your `flake.lock`'s root-level [Nixpkgs] inputs and checks that:
Nix Flake Checker looks at your `flake.lock`'s root-level [Nixpkgs] inputs.
There are two ways to express flake policies:
- Any explicit Nixpkgs Git refs are in this list:
- `nixos-23.11`
- `nixos-23.11-small`
- `nixos-unstable`
- `nixos-unstable-small`
- `nixpkgs-23.11-darwin`
- `nixpkgs-unstable`
- Any Nixpkgs dependencies are less than 30 days old
- Any Nixpkgs dependencies have the [`NixOS`][nixos-org] org as the GitHub owner (and thus that the dependency isn't a fork or non-upstream variant)
* Via [config parameters](#parameters).
* Via [policy conditions](#policy-conditions) using [Common Expression Language][cel] (CEL).
If you're running it locally, Nix Flake Checker reports any issues via text output in your terminal.
But you can also use Nix Flake Checker [in CI](#the-flake-checker-action).
## Supported branches
At any given time, [Nixpkgs] has a bounded set of branches that are considered *supported*.
The current list:
* `nixos-23.11`
* `nixos-23.11-small`
* `nixos-24.05`
* `nixos-24.05-small`
* `nixos-unstable`
* `nixos-unstable-small`
* `nixpkgs-23.11-darwin`
* `nixpkgs-24.05-darwin`
* `nixpkgs-unstable`
## Parameters
By default, Flake Checker verifies that:
- Any explicit Nixpkgs Git refs are in the [supported list](#supported-branches).
- Any Nixpkgs dependencies are less than 30 days old.
- Any Nixpkgs dependencies have the [`NixOS`][nixos-org] org as the GitHub owner (and thus that the dependency isn't a fork or non-upstream variant).
You can adjust this behavior via configuration (all are enabled by default but you can disable them):
Flag | Environment variable | Action | Default
:----|:---------------------|:-------|:-------
`--check-outdated` | `NIX_FLAKE_CHECKER_CHECK_OUTDATED` | Check for outdated Nixpkgs inputs | `true`
`--check-owner` | `NIX_FLAKE_CHECKER_CHECK_OWNER` | Check that Nixpkgs inputs have `NixOS` as the GitHub owner | `true`
`--check-supported` | `NIX_FLAKE_CHECKER_CHECK_SUPPORTED` | Check that Git refs for Nixpkgs inputs are supported | `true`
## Policy conditions
You can apply a CEL condition to your flake using the `--condition` flag.
Here's an example:
```shell
flake-checker --condition "has(numDaysOld) && numDaysOld < 365"
```
This would check that each Nixpkgs input in your `flake.lock` is less than 365 days old.
These variables are available in each condition:
Variable | Description
:--------|:-----------
`gitRef` | The Git reference of the input.
`numDaysOld` | The number of days old the input is.
`owner` | The input's owner (if a GitHub input).
`supportedRefs` | A list of [supported Git refs](#supported-branches) (all are branch names).
We recommend a condition *at least* this stringent:
```ruby
supportedRefs.contains(gitRef) && (has(numDaysOld) && numDaysOld < 30) && owner == 'NixOS'
```
Note that not all Nixpkgs inputs have a `numDaysOld` field, so make sure to ensure that that field exists when checking for the number of days.
Here are some other example conditions:
```ruby
# Updated in the last two weeks
supportedRefs.contains(gitRef) && (has(numDaysOld) && numDaysOld < 14) && owner == 'NixOS'
# Check for most recent stable Nixpkgs
gitRef.contains("24.05")
```
## The Nix Flake Checker Action
You can automate Nix Flake Checker by adding Determinate Systems' [Nix Flake Checker Action][action] to your GitHub Actions workflows:
@ -96,6 +158,7 @@ The `parse-flake-lock` crate doesn't yet exhaustively parse all input node types
If you'd like to help make the parser more exhaustive, [pull requests][prs] are quite welcome.
[action]: https://github.com/DeterminateSystems/flake-checker-action
[cel]: https://cel.dev
[detsys]: https://determinate.systems
[flakes]: https://zero-to-nix.com/concepts/flakes
[install]: https://zero-to-nix.com/start/install

View File

@ -12,7 +12,7 @@
let
inherit (stdenv.hostPlatform) system;
nightlyVersion = "2023-05-01";
nightlyVersion = "2024-06-13";
rustNightly = pkgs.rust-bin.nightly.${nightlyVersion}.default.override {
extensions = [ "rust-src" "rust-analyzer-preview" ];
targets = cargoTargets;
@ -80,7 +80,7 @@ let
# The Rust toolchain from rust-overlay has a dynamic libiconv in depsTargetTargetPropagated
# Our static libiconv needs to take precedence
++ lib.optionals pkgs.stdenv.isDarwin [
(libiconv.override { enableStatic = true; enableShared = false; })
libiconv
];
cargoExtraArgs = "--target ${crossPlatform.rustTargetSpec}";

View File

@ -2,31 +2,26 @@
"nodes": {
"crane": {
"inputs": {
"flake-compat": [
"flake-compat"
],
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
]
},
"locked": {
"narHash": "sha256-ASliYUzlN/aTGDZ2d0FIqxq5fiz+Cwk0q2rYXgy4pB0=",
"rev": "8cb0282cb7c7b5ad7ce1c47d48f647836f8924a0",
"revCount": 432,
"lastModified": 1717383740,
"narHash": "sha256-559HbY4uhNeoYvK3H6AMZAtVfmR3y8plXZ1x6ON/cWU=",
"rev": "b65673fce97d277934488a451724be94cc62499a",
"revCount": 580,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.14.2/018b3503-625a-71c8-96ff-c86e61bd12f7/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.17.3/018fdc0e-176b-7a0f-92ce-cc2d0db7b735/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/ipetkov/crane/0.14.%2A.tar.gz"
"url": "https://flakehub.com/f/ipetkov/crane/0.17.%2A"
}
},
"flake-compat": {
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"revCount": 57,
@ -35,89 +30,43 @@
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.0.1.tar.gz"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
"url": "https://flakehub.com/f/edolstra/flake-compat/1.0.1"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1704290814,
"narHash": "sha256-LWvKHp7kGxk/GEtlrGYV68qIvPHkU9iToomNFGagixU=",
"rev": "70bdadeb94ffc8806c0570eb5c2695ad29f0e421",
"revCount": 492897,
"lastModified": 1717952948,
"narHash": "sha256-mJi4/gjiwQlSaxjA6AusXBN/6rQRaPCycR7bd8fydnQ=",
"rev": "2819fffa7fa42156680f0d282c60d81e8fb185b7",
"revCount": 631440,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2305.492897%2Brev-70bdadeb94ffc8806c0570eb5c2695ad29f0e421/018ce318-b896-7d27-b495-cc2cdb39d680/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2405.631440%2Brev-2819fffa7fa42156680f0d282c60d81e8fb185b7/0190034c-678d-7039-b45c-fa38168f2500/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.2305.%2A.tar.gz"
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.2405.%2A"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay_2"
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"crane",
"flake-utils"
],
"nixpkgs": [
"crane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1696299134,
"narHash": "sha256-RS77cAa0N+Sfj5EmKbm5IdncNXaBCE1BSSQvUE8exvo=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "611ccdceed92b4d94ae75328148d84ee4a5b462d",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1697422411,
"narHash": "sha256-eCj20wEwATLm7Bd/+/wOIdbqq9jgvS6ZxMrxujX2DxU=",
"lastModified": 1719800573,
"narHash": "sha256-9DLgG4T6l7cc4pJNOCcXGUwHsFfUp8KLsiwed65MdHk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "056256f2fcf3c5a652dbc3edba9ec1a956d41f56",
"rev": "648b25dd9c3acd255dc50c1eb3ca8b987856f675",
"type": "github"
},
"original": {
@ -125,21 +74,6 @@
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@ -1,25 +1,21 @@
{
inputs = {
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2305.*.tar.gz";
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2405.*";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.follows = "flake-utils";
};
crane = {
url = "https://flakehub.com/f/ipetkov/crane/0.14.*.tar.gz";
url = "https://flakehub.com/f/ipetkov/crane/0.17.*";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-compat.follows = "flake-compat";
inputs.flake-utils.follows = "flake-utils";
};
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.0.1.tar.gz";
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.0.1";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, ... }:
outputs = { self, nixpkgs, rust-overlay, crane, ... }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f rec {

View File

@ -1,6 +1,6 @@
[package]
name = "parse-flake-lock"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
[dependencies]

View File

@ -107,7 +107,10 @@ impl<'de> Deserialize<'de> for FlakeLock {
let mut root_nodes = HashMap::new();
let root_node = &nodes[&root];
let Node::Root(root_node) = root_node else {
return Err(de::Error::custom(format!("root node was not a Root node, but was a {} node", root_node.variant())));
return Err(de::Error::custom(format!(
"root node was not a Root node, but was a {} node",
root_node.variant()
)));
};
for (root_name, root_input) in root_node.inputs.iter() {
@ -204,7 +207,7 @@ pub enum Node {
Indirect(IndirectNode),
/// A [PathNode] flake input stemming from a filesystem path.
Path(PathNode),
/// TODO
/// Nodes that point to tarball paths.
Tarball(TarballNode),
/// A "catch-all" variant for node types that don't (yet) have explicit struct definitions in
/// this crate.
@ -372,6 +375,9 @@ pub struct TarballNode {
/// Information about the tarball input that's "locked" because it's supplied by Nix.
#[derive(Clone, Debug, Deserialize)]
pub struct TarballLocked {
/// The timestamp for when the input was last modified.
#[serde(alias = "lastModified")]
pub last_modified: Option<i64>,
/// The NAR hash of the input.
#[serde(alias = "narHash")]
pub nar_hash: String,

82
src/condition.rs Normal file
View File

@ -0,0 +1,82 @@
use cel_interpreter::{Context, Program, Value};
use parse_flake_lock::{FlakeLock, Node};
use crate::{
error::FlakeCheckerError,
flake::{nixpkgs_deps, num_days_old},
issue::{Issue, IssueKind},
};
const KEY_GIT_REF: &str = "gitRef";
const KEY_NUM_DAYS_OLD: &str = "numDaysOld";
const KEY_OWNER: &str = "owner";
const KEY_SUPPORTED_REFS: &str = "supportedRefs";
pub(super) fn evaluate_condition(
flake_lock: &FlakeLock,
nixpkgs_keys: &[String],
condition: &str,
supported_refs: Vec<String>,
) -> Result<Vec<Issue>, FlakeCheckerError> {
let mut issues: Vec<Issue> = vec![];
let mut ctx = Context::default();
ctx.add_variable_from_value(KEY_SUPPORTED_REFS, supported_refs);
let deps = nixpkgs_deps(flake_lock, nixpkgs_keys)?;
for (name, node) in deps {
let (git_ref, last_modified, owner) = match node {
Node::Repo(repo) => (
repo.original.git_ref,
Some(repo.locked.last_modified),
Some(repo.original.owner),
),
Node::Tarball(tarball) => (None, tarball.locked.last_modified, None),
_ => (None, None, None),
};
add_cel_variables(&mut ctx, git_ref, last_modified, owner);
match Program::compile(condition)?.execute(&ctx) {
Ok(result) => match result {
Value::Bool(b) if !b => {
issues.push(Issue {
input: name.clone(),
kind: IssueKind::Violation,
});
}
Value::Bool(b) if b => continue,
result => {
return Err(FlakeCheckerError::NonBooleanCondition(
result.type_of().to_string(),
))
}
},
Err(e) => return Err(FlakeCheckerError::CelExecution(e)),
}
}
Ok(issues)
}
fn add_cel_variables(
ctx: &mut Context,
git_ref: Option<String>,
last_modified: Option<i64>,
owner: Option<String>,
) {
ctx.add_variable_from_value(KEY_GIT_REF, value_or_empty_string(git_ref));
ctx.add_variable_from_value(
KEY_NUM_DAYS_OLD,
value_or_zero(last_modified.map(num_days_old)),
);
ctx.add_variable_from_value(KEY_OWNER, value_or_empty_string(owner));
}
fn value_or_empty_string(value: Option<String>) -> Value {
Value::from(value.unwrap_or(String::from("")))
}
fn value_or_zero(value: Option<i64>) -> Value {
Value::from(value.unwrap_or(0))
}

View File

@ -1,11 +1,17 @@
#[derive(Debug, thiserror::Error)]
pub enum FlakeCheckerError {
#[error("CEL execution error: {0}")]
CelExecution(#[from] cel_interpreter::ExecutionError),
#[error("CEL parsing error: {0}")]
CelParse(#[from] cel_interpreter::ParseError),
#[error("env var error: {0}")]
EnvVar(#[from] std::env::VarError),
#[error("couldn't parse flake.lock: {0}")]
FlakeLock(#[from] parse_flake_lock::FlakeLockParseError),
#[error("http client error: {0}")]
Http(#[from] reqwest::Error),
#[error("CEL conditions must return a Boolean but returned {0} instead")]
NonBooleanCondition(String),
#[error("couldn't access flake.lock: {0}")]
Io(#[from] std::io::Error),
#[error("couldn't parse flake.lock: {0}")]

View File

@ -30,9 +30,9 @@ impl Default for FlakeCheckConfig {
}
}
fn nixpkgs_deps(
pub(super) fn nixpkgs_deps(
flake_lock: &FlakeLock,
keys: Vec<String>,
keys: &[String],
) -> Result<HashMap<String, Node>, FlakeCheckerError> {
let mut deps: HashMap<String, Node> = HashMap::new();
@ -49,7 +49,7 @@ fn nixpkgs_deps(
}
}
Node::Indirect(indirect_node) => {
if &indirect_node.original.id == key {
if keys.contains(key) && &indirect_node.original.id == key {
deps.insert(key.to_string(), node);
}
}
@ -83,29 +83,36 @@ pub(crate) fn check_flake_lock(
) -> Result<Vec<Issue>, FlakeCheckerError> {
let mut issues = vec![];
let deps = nixpkgs_deps(flake_lock, config.nixpkgs_keys.clone())?;
let deps = nixpkgs_deps(flake_lock, &config.nixpkgs_keys)?;
for (name, dep) in deps {
if let Node::Repo(repo) = dep {
for (name, node) in deps {
let (git_ref, last_modified, owner) = match node {
Node::Repo(repo) => (
repo.original.git_ref,
Some(repo.locked.last_modified),
Some(repo.original.owner),
),
Node::Tarball(tarball) => (None, tarball.locked.last_modified, None),
_ => (None, None, None),
};
// Check if not explicitly supported
if let Some(git_ref) = git_ref {
// Check if not explicitly supported
if config.check_supported {
if let Some(ref git_ref) = repo.original.git_ref {
if !allowed_refs.contains(git_ref) {
issues.push(Issue {
input: name.clone(),
kind: IssueKind::Disallowed(Disallowed {
reference: git_ref.to_string(),
}),
});
}
}
if config.check_supported && !allowed_refs.contains(&git_ref) {
issues.push(Issue {
input: name.clone(),
kind: IssueKind::Disallowed(Disallowed {
reference: git_ref.to_string(),
}),
});
}
}
if let Some(last_modified) = last_modified {
// Check if outdated
if config.check_outdated {
let now_timestamp = Utc::now().timestamp();
let diff = now_timestamp - repo.locked.last_modified;
let num_days_old = Duration::seconds(diff).num_days();
let num_days_old = num_days_old(last_modified);
if num_days_old > MAX_DAYS {
issues.push(Issue {
@ -114,39 +121,89 @@ pub(crate) fn check_flake_lock(
});
}
}
}
if let Some(owner) = owner {
// Check that the GitHub owner is NixOS
if config.check_owner {
let owner = repo.original.owner;
if owner.to_lowercase() != "nixos" {
issues.push(Issue {
input: name.clone(),
kind: IssueKind::NonUpstream(NonUpstream { owner }),
});
}
if config.check_owner && owner.to_lowercase() != "nixos" {
issues.push(Issue {
input: name.clone(),
kind: IssueKind::NonUpstream(NonUpstream { owner }),
});
}
}
}
Ok(issues)
}
pub(super) fn num_days_old(timestamp: i64) -> i64 {
let now_timestamp = Utc::now().timestamp();
let diff = now_timestamp - timestamp;
Duration::seconds(diff).num_days()
}
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::{
check_flake_lock,
condition::evaluate_condition,
issue::{Disallowed, Issue, IssueKind, NonUpstream},
FlakeCheckConfig, FlakeLock,
};
#[test]
fn test_clean_flake_locks() {
let allowed_refs: Vec<String> = serde_json::from_str(include_str!("../allowed-refs.json"))
.expect("couldn't deserialize allowed-refs.json file");
fn cel_conditions() {
// (condition, expected)
let cases: Vec<(&str, bool)> = vec![
(include_str!("../tests/cel-condition.txt"), true),
(
"has(gitRef) && has(numDaysOld) && has(owner) && has(supportedRefs) && supportedRefs.contains(gitRef) && owner != 'NixOS'",
false,
),
(
"has(gitRef) && has(numDaysOld) && has(owner) && has(supportedRefs) && supportedRefs.contains(gitRef) && owner != 'NixOS'",
false,
),
];
let supported_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
let path = PathBuf::from("tests/flake.cel.0.lock");
for (condition, expected) in cases {
let flake_lock = FlakeLock::new(&path).unwrap();
let config = FlakeCheckConfig {
nixpkgs_keys: vec![String::from("nixpkgs")],
..Default::default()
};
let result = evaluate_condition(
&flake_lock,
&config.nixpkgs_keys,
condition,
supported_refs.clone(),
);
if expected {
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
} else {
assert!(!result.unwrap().is_empty());
}
}
}
#[test]
fn clean_flake_locks() {
let allowed_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
for n in 0..=7 {
let path = PathBuf::from(format!("tests/flake.clean.{n}.lock"));
let flake_lock = FlakeLock::new(&path).expect("couldn't create flake.lock");
let flake_lock = FlakeLock::new(&path).unwrap();
let config = FlakeCheckConfig {
check_outdated: false,
..Default::default()
@ -161,9 +218,9 @@ mod test {
}
#[test]
fn test_dirty_flake_locks() {
let allowed_refs: Vec<String> = serde_json::from_str(include_str!("../allowed-refs.json"))
.expect("couldn't deserialize allowed-refs.json file");
fn dirty_flake_locks() {
let allowed_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
let cases: Vec<(&str, Vec<Issue>)> = vec![
(
"flake.dirty.0.lock",
@ -203,22 +260,21 @@ mod test {
for (file, expected_issues) in cases {
let path = PathBuf::from(format!("tests/{file}"));
let flake_lock = FlakeLock::new(&path).expect("couldn't create flake.lock");
let flake_lock = FlakeLock::new(&path).unwrap();
let config = FlakeCheckConfig {
check_outdated: false,
..Default::default()
};
let issues = check_flake_lock(&flake_lock, &config, allowed_refs.clone())
.expect("couldn't run check_flake_lock function");
let issues = check_flake_lock(&flake_lock, &config, allowed_refs.clone()).unwrap();
dbg!(&path);
assert_eq!(issues, expected_issues);
}
}
#[test]
fn test_explicit_nixpkgs_keys() {
let allowed_refs: Vec<String> = serde_json::from_str(include_str!("../allowed-refs.json"))
.expect("couldn't deserialize allowed-refs.json file");
fn explicit_nixpkgs_keys() {
let allowed_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
let cases: Vec<(&str, Vec<String>, Vec<Issue>)> = vec![(
"flake.explicit-keys.0.lock",
vec![String::from("nixpkgs"), String::from("nixpkgs-alt")],
@ -232,22 +288,21 @@ mod test {
for (file, nixpkgs_keys, expected_issues) in cases {
let path = PathBuf::from(format!("tests/{file}"));
let flake_lock = FlakeLock::new(&path).expect("couldn't create flake.lock");
let flake_lock = FlakeLock::new(&path).unwrap();
let config = FlakeCheckConfig {
check_outdated: false,
nixpkgs_keys,
..Default::default()
};
let issues = check_flake_lock(&flake_lock, &config, allowed_refs.clone())
.expect("couldn't run check_flake_lock function");
let issues = check_flake_lock(&flake_lock, &config, allowed_refs.clone()).unwrap();
assert_eq!(issues, expected_issues);
}
}
#[test]
fn test_missing_nixpkgs_keys() {
let allowed_refs: Vec<String> = serde_json::from_str(include_str!("../allowed-refs.json"))
.expect("couldn't deserialize allowed-refs.json file");
fn missing_nixpkgs_keys() {
let allowed_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
let cases: Vec<(&str, Vec<String>, String)> = vec![(
"flake.clean.0.lock",
vec![String::from("nixpkgs"), String::from("foo"), String::from("bar")],
@ -260,7 +315,7 @@ mod test {
)];
for (file, nixpkgs_keys, expected_err) in cases {
let path = PathBuf::from(format!("tests/{file}"));
let flake_lock = FlakeLock::new(&path).expect("couldn't create flake.lock");
let flake_lock = FlakeLock::new(&path).unwrap();
let config = FlakeCheckConfig {
check_outdated: false,
nixpkgs_keys,

View File

@ -12,6 +12,7 @@ pub(crate) enum IssueKind {
Disallowed(Disallowed),
Outdated(Outdated),
NonUpstream(NonUpstream),
Violation,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
@ -41,4 +42,8 @@ impl IssueKind {
pub(crate) fn is_non_upstream(&self) -> bool {
matches!(self, Self::NonUpstream(_))
}
pub(crate) fn is_violation(&self) -> bool {
matches!(self, Self::Violation)
}
}

View File

@ -1,5 +1,6 @@
#[cfg(feature = "allowed-refs")]
mod allowed_refs;
mod condition;
mod error;
mod flake;
mod issue;
@ -16,6 +17,8 @@ use std::process::ExitCode;
use clap::Parser;
use parse_flake_lock::FlakeLock;
use crate::condition::evaluate_condition;
/// A flake.lock checker for Nix projects.
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
@ -86,6 +89,10 @@ struct Cli {
)]
markdown_summary: bool,
/// The Common Expression Language (CEL) policy to apply to each Nixpkgs input.
#[arg(long, short, env = "NIX_FLAKE_CHECKER_CONDITION")]
condition: Option<String>,
#[cfg(feature = "allowed-refs")]
// Check to make sure that Flake Checker is aware of the current supported branches.
#[arg(long, hide = true)]
@ -98,8 +105,8 @@ struct Cli {
}
fn main() -> Result<ExitCode, FlakeCheckerError> {
let allowed_refs: Vec<String> = serde_json::from_str(include_str!("../allowed-refs.json"))
.expect("couldn't deserialize allowed-refs.json file");
let allowed_refs: Vec<String> =
serde_json::from_str(include_str!("../allowed-refs.json")).unwrap();
let Cli {
no_telemetry,
@ -111,6 +118,7 @@ fn main() -> Result<ExitCode, FlakeCheckerError> {
fail_mode,
nixpkgs_keys,
markdown_summary,
condition,
#[cfg(feature = "allowed-refs")]
check_allowed_refs,
#[cfg(feature = "allowed-refs")]
@ -166,17 +174,27 @@ fn main() -> Result<ExitCode, FlakeCheckerError> {
check_supported,
check_outdated,
check_owner,
nixpkgs_keys,
nixpkgs_keys: nixpkgs_keys.clone(),
fail_mode,
};
let issues = check_flake_lock(&flake_lock, &flake_check_config, allowed_refs.clone())?;
let issues = if let Some(condition) = &condition {
evaluate_condition(&flake_lock, &nixpkgs_keys, condition, allowed_refs.clone())?
} else {
check_flake_lock(&flake_lock, &flake_check_config, allowed_refs.clone())?
};
if !no_telemetry {
telemetry::TelemetryReport::make_and_send(&issues);
}
let summary = Summary::new(&issues, flake_lock_path, flake_check_config, allowed_refs);
let summary = Summary::new(
&issues,
flake_lock_path,
flake_check_config,
allowed_refs,
condition,
);
if std::env::var("GITHUB_ACTIONS").is_ok() {
if markdown_summary {

View File

@ -10,14 +10,24 @@ use std::path::PathBuf;
use handlebars::Handlebars;
use serde_json::json;
static MARKDOWN_TEMPLATE: &str = include_str!(concat!(
static CEL_MARKDOWN_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/templates/summary_md.hbs"
"/src/templates/summary.cel.md.hbs"
));
static TEXT_TEMPLATE: &str = include_str!(concat!(
static CEL_TEXT_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/templates/summary_txt.hbs"
"/src/templates/summary.cel.txt.hbs"
));
static STANDARD_MARKDOWN_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/templates/summary.standard.md.hbs"
));
static STANDARD_TEXT_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/templates/summary.standard.txt.hbs"
));
pub(crate) struct Summary {
@ -25,6 +35,7 @@ pub(crate) struct Summary {
data: serde_json::Value,
flake_lock_path: PathBuf,
flake_check_config: FlakeCheckConfig,
condition: Option<String>,
}
impl Summary {
@ -33,37 +44,62 @@ impl Summary {
flake_lock_path: PathBuf,
flake_check_config: FlakeCheckConfig,
allowed_refs: Vec<String>,
condition: Option<String>,
) -> Self {
let disallowed: Vec<&Issue> = issues.iter().filter(|i| i.kind.is_disallowed()).collect();
let outdated: Vec<&Issue> = issues.iter().filter(|i| i.kind.is_outdated()).collect();
let non_upstream: Vec<&Issue> =
issues.iter().filter(|i| i.kind.is_non_upstream()).collect();
let num_issues = issues.len();
let clean = issues.is_empty();
let issue_word = if issues.len() == 1 { "issue" } else { "issues" };
let data = json!({
"issues": issues,
"num_issues": issues.len(),
"clean": issues.is_empty(),
"dirty": !issues.is_empty(),
"issue_word": if issues.len() == 1 { "issue" } else { "issues" },
// Disallowed refs
"has_disallowed": !disallowed.is_empty(),
"disallowed": disallowed,
// Outdated refs
"has_outdated": !outdated.is_empty(),
"outdated": outdated,
// Non-upstream refs
"has_non_upstream": !non_upstream.is_empty(),
"non_upstream": non_upstream,
// Constants
"max_days": MAX_DAYS,
"supported_ref_names": allowed_refs,
});
let data = if let Some(condition) = &condition {
let inputs_with_violations: Vec<String> = issues
.iter()
.filter(|i| i.kind.is_violation())
.map(|i| i.input.to_owned())
.collect();
json!({
"issues": issues,
"num_issues": num_issues,
"clean": clean,
"dirty": !clean,
"issue_word": issue_word,
"condition": condition,
"inputs_with_violations": inputs_with_violations,
})
} else {
let disallowed: Vec<&Issue> =
issues.iter().filter(|i| i.kind.is_disallowed()).collect();
let outdated: Vec<&Issue> = issues.iter().filter(|i| i.kind.is_outdated()).collect();
let non_upstream: Vec<&Issue> =
issues.iter().filter(|i| i.kind.is_non_upstream()).collect();
json!({
"issues": issues,
"num_issues": num_issues,
"clean": clean,
"dirty": !clean,
"issue_word": issue_word,
// Disallowed refs
"has_disallowed": !disallowed.is_empty(),
"disallowed": disallowed,
// Outdated refs
"has_outdated": !outdated.is_empty(),
"outdated": outdated,
// Non-upstream refs
"has_non_upstream": !non_upstream.is_empty(),
"non_upstream": non_upstream,
// Constants
"max_days": MAX_DAYS,
"supported_ref_names": allowed_refs,
})
};
Self {
issues: issues.to_vec(),
data,
flake_lock_path,
flake_check_config,
condition,
}
}
@ -72,6 +108,18 @@ impl Summary {
if self.issues.is_empty() {
println!("The Determinate Nix Flake Checker scanned {file} and found no issues");
return Ok(());
}
if let Some(condition) = &self.condition {
println!(
"You supplied this CEL condition for your flake:\n\n{}",
condition
);
println!("The following inputs violate that condition:\n");
for issue in self.issues.iter() {
println!("* {}", issue.input);
}
} else {
let level = if self.flake_check_config.fail_mode {
"error"
@ -113,6 +161,7 @@ impl Summary {
None
}
}
IssueKind::Violation => Some(String::from("policy violation")),
};
if let Some(message) = message {
@ -124,10 +173,16 @@ impl Summary {
}
pub fn generate_markdown(&self) -> Result<(), FlakeCheckerError> {
let template = if self.condition.is_some() {
CEL_MARKDOWN_TEMPLATE
} else {
STANDARD_MARKDOWN_TEMPLATE
};
let mut handlebars = Handlebars::new();
handlebars
.register_template_string("summary.md", MARKDOWN_TEMPLATE)
.register_template_string("summary.md", template)
.map_err(Box::new)?;
let summary_md = handlebars.render("summary.md", &self.data)?;
@ -142,9 +197,15 @@ impl Summary {
}
pub fn generate_text(&self) -> Result<(), FlakeCheckerError> {
let template = if self.condition.is_some() {
CEL_TEXT_TEMPLATE
} else {
STANDARD_TEXT_TEMPLATE
};
let mut handlebars = Handlebars::new();
handlebars
.register_template_string("summary.txt", TEXT_TEMPLATE)
.register_template_string("summary.txt", template)
.map_err(Box::new)?;
let summary_txt = handlebars.render("summary.txt", &self.data)?;

View File

@ -0,0 +1,23 @@
# ![](https://avatars.githubusercontent.com/u/80991770?s=30) Flake checkup
{{#if clean}}
The Determinate Flake Checker Action scanned your `flake.lock` and didn't identify any issues.
All Nixpkgs inputs conform to the flake policy expressed in your supplied [Common Expression Language](https://cel.dev) condition.
{{/if}}
{{#if dirty}}
⚠️ The Determinate Nix Installer Action scanned your `flake.lock` and discovered {{num_issues}} {{issue_word}} that we recommend looking into.
You supplied this CEL condition:
```ruby
{{condition}}
```
The following inputs violate that condition:
{{#each inputs_with_violations}}
* `{{this}}`
{{/each}}
{{/if}}
<p>Feedback? Let us know at <a href="https://github.com/DeterminateSystems/flake-checker">DeterminateSystems/flake-checker</a>.</p>

View File

@ -0,0 +1,19 @@
Flake checker results:
{{#if clean}}
The flake checker scanned your flake.lock and didn't identify any issues. You specified this CEL
condition:
{{{condition}}}
All Nixpkgs inputs satisfy this condition.
{{/if}}
{{#if dirty}}
The flake checker scanned your flake.lock and discovered {{num_issues}} {{issue_word}}
that we recommend looking into. Here are the inputs that violate your supplied
condition:
{{#each inputs_with_violations}}
* {{this}}
{{/each}}
{{/if}}

View File

@ -110,4 +110,4 @@ While <a href="https://github.com/NixOS/nixpkgs">upstream Nixpkgs</a> isn't bull
{{/if}}
{{/if}}
<p>Feedback? Let us know at <a href="https://github.com/DeterminateSystems/flake-checker">DeterminateSystems/flake-checker</a>.</p>
<p>Feedback? Let us know at <a href="https://github.com/DeterminateSystems/flake-checker">DeterminateSystems/flake-checker</a>.</p>

6
tests/cel-condition.txt Normal file
View File

@ -0,0 +1,6 @@
supportedRefs == ['nixos-24.05', 'nixos-24.05-small', 'nixos-unstable', 'nixos-unstable-small', 'nixpkgs-24.05-darwin', 'nixpkgs-unstable']
&& owner == 'NixOS'
&& gitRef == 'nixos-unstable'
&& supportedRefs.contains(gitRef)
&& has(numDaysOld)
&& numDaysOld > 0

154
tests/flake.cel.0.lock Normal file
View File

@ -0,0 +1,154 @@
{
"nodes": {
"crane": {
"inputs": {
"flake-compat": [
"flake-compat"
],
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1684468982,
"narHash": "sha256-EoC1N5sFdmjuAP3UOkyQujSOT6EdcXTnRw8hPjJkEgc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "99de890b6ef4b4aab031582125b6056b792a4a30",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1686960236,
"narHash": "sha256-AYCC9rXNLpUWzD9hm+askOfpliLEC9kwAo7ITJc4HIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "04af42f3b31dba0ef742d254456dc4c14eedac86",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay_2"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"crane",
"flake-utils"
],
"nixpkgs": [
"crane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1683080331,
"narHash": "sha256-nGDvJ1DAxZIwdn6ww8IFwzoHb2rqBP4wv/65Wt5vflk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "d59c3fa0cba8336e115b376c2d9e91053aa59e56",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1684808436,
"narHash": "sha256-WG5LgB1+Oguj4H4Bpqr5GoLSc382LyGlaToiOw5xhwA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "a227d4571dd1f948138a40ea8b0d0c413eefb44b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}