Skip to content
Merged
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
168 changes: 168 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
name: Test

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches:
- main
workflow_dispatch:

concurrency:
group: test-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: write
pull-requests: write

env:
CARGO_TERM_COLOR: always
DATABASE_URL: postgres://postgres:postgres@localhost:5432/csfx
HISTORY_BRANCH: coverage-data
HISTORY_LIMIT: 100

jobs:
test:
name: Test and coverage
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: csfx
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6

- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview

- uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev libpq-dev protobuf-compiler jq

- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-test-

- name: Unit and workspace tests
run: cargo llvm-cov --no-report --workspace

- name: Database integration tests
run: cargo llvm-cov --no-report -p shared -- --ignored

- name: Coverage reports
run: |
cargo llvm-cov report --lcov --output-path lcov.info
cargo llvm-cov report --json --summary-only --output-path coverage.json
cargo llvm-cov report --html --output-dir coverage-html

- name: Build coverage summary
id: summary
run: |
TOTAL=$(jq -r '.data[0].totals.lines.percent * 100 | round / 100' coverage.json)
echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
jq -r '
.data[0].files
| map({crate: (.filename | capture("(?<c>[^/]+)/src/").c), c: .summary.lines.count, v: .summary.lines.covered})
| group_by(.crate)
| map({crate: .[0].crate, count: (map(.c) | add), covered: (map(.v) | add)})
| sort_by(.crate)
| map("| \(.crate) | \(if .count == 0 then 0 else (.covered * 10000 / .count | round / 100) end)% | \(.covered) / \(.count) |")
| .[]
' coverage.json > crates.md

- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
with:
name: coverage
path: |
lcov.info
coverage.json
coverage-html

- name: Update coverage history
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
TOTAL: ${{ steps.summary.outputs.total }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --heads origin "$HISTORY_BRANCH" >/dev/null; then
git fetch origin "$HISTORY_BRANCH"
git worktree add history "origin/$HISTORY_BRANCH"
git -C history checkout -B "$HISTORY_BRANCH"
else
git worktree add --orphan -b "$HISTORY_BRANCH" history
echo '[]' > history/history.json
fi
jq --arg sha "${GITHUB_SHA::7}" --arg date "$(date -u +%F)" --argjson lines "$TOTAL" --argjson limit "$HISTORY_LIMIT" \
'(. + [{sha: $sha, date: $date, lines: $lines}]) | .[-$limit:]' \
history/history.json > history/history.next
mv history/history.next history/history.json
git -C history add history.json
git -C history commit -m "chore: record coverage ${TOTAL} for ${GITHUB_SHA::7}"
git -C history push origin "$HISTORY_BRANCH"

- name: Render report
env:
TOTAL: ${{ steps.summary.outputs.total }}
run: |
git fetch origin "$HISTORY_BRANCH" 2>/dev/null || true
HISTORY=$(git show "origin/$HISTORY_BRANCH:history.json" 2>/dev/null || echo '[]')
BASE=$(jq -r 'if length == 0 then "" else .[-1].lines end' <<<"$HISTORY")
{
echo "<!-- csfx-coverage -->"
echo "## Test coverage"
echo
if [ -n "$BASE" ]; then
DELTA=$(jq -n --argjson a "$TOTAL" --argjson b "$BASE" '($a - $b) * 100 | round / 100')
echo "Line coverage **${TOTAL}%** (${DELTA} pp against main at ${BASE}%)"
else
echo "Line coverage **${TOTAL}%**"
fi
echo
echo "| Crate | Lines | Covered / total |"
echo "|---|---|---|"
cat crates.md
echo
echo "### Trend on main"
echo
echo "| Date | Commit | Lines |"
echo "|---|---|---|"
jq -r '.[-10:] | reverse | .[] | "| \(.date) | \(.sha) | \(.lines)% |"' <<<"$HISTORY"
} > report.md
cat report.md >> "$GITHUB_STEP_SUMMARY"

- name: Comment on pull request
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --paginate \
--jq '.[] | select(.body | startswith("<!-- csfx-coverage -->")) | .id' | head -n 1)
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -F body=@report.md >/dev/null
else
gh pr comment "$PR_NUMBER" --body-file report.md
fi
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 16 additions & 9 deletions agent/src/firecracker/rootfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -538,21 +538,28 @@ mod tests {
use super::*;
use std::io::Write;

fn layer_header(path: &str, entry_type: tar::EntryType, size: u64, mode: u32) -> tar::Header {
let mut header = tar::Header::new_gnu();
header.set_path(path).unwrap();
header.set_entry_type(entry_type);
header.set_size(size);
header.set_mode(mode);
header.set_uid(u64::from(unsafe { libc::geteuid() }));
header.set_gid(u64::from(unsafe { libc::getegid() }));
header.set_mtime(0);
header.set_cksum();
header
}

fn build_gzip_layer(entries: &[(&str, &[u8], bool)]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (path, contents, is_dir) in entries {
if *is_dir {
let mut header = tar::Header::new_gnu();
header.set_path(path).unwrap();
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_cksum();
let header = layer_header(path, tar::EntryType::Directory, 0, 0o755);
builder.append(&header, std::io::empty()).unwrap();
} else {
let mut header = tar::Header::new_gnu();
header.set_path(path).unwrap();
header.set_size(contents.len() as u64);
header.set_cksum();
let header =
layer_header(path, tar::EntryType::Regular, contents.len() as u64, 0o644);
builder.append(&header, *contents).unwrap();
}
}
Expand Down
3 changes: 3 additions & 0 deletions control-plane/shared/shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true, features = ["json"] }

[dev-dependencies]
migration = { path = "../migration" }
13 changes: 13 additions & 0 deletions control-plane/shared/shared/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use migration::{Migrator, MigratorTrait};
use sea_orm::{Database, DatabaseConnection};

pub async fn migrated_database() -> DatabaseConnection {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let db = Database::connect(&url)
.await
.expect("database must be reachable");
Migrator::up(&db, None)
.await
.expect("migrations must apply");
db
}
74 changes: 74 additions & 0 deletions control-plane/shared/shared/tests/ntfy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
mod common;

use shared::ntfy::{self, NtfyConfig, NtfyError};

fn valid_config() -> NtfyConfig {
NtfyConfig {
enabled: true,
topic: "csfx-alerts".to_string(),
..NtfyConfig::default()
}
}

#[test]
fn rejects_invalid_configuration() {
let cases = [
NtfyConfig {
server_url: "ftp://ntfy.sh".to_string(),
..valid_config()
},
NtfyConfig {
topic: String::new(),
..valid_config()
},
NtfyConfig {
topic: "a/b".to_string(),
..valid_config()
},
NtfyConfig {
min_severity: "fatal".to_string(),
..valid_config()
},
];
for case in cases {
assert!(matches!(case.validate(), Err(NtfyError::Invalid(_))));
}
assert!(valid_config().validate().is_ok());
}

#[test]
fn filters_by_minimum_severity() {
let config = valid_config();
assert!(!config.wants("info"));
assert!(config.wants("warning"));
assert!(config.wants("critical"));
assert!(!NtfyConfig {
enabled: false,
..config
}
.wants("critical"));
}

#[tokio::test]
#[ignore = "requires postgres via DATABASE_URL"]
async fn persists_and_updates_configuration() {
let db = common::migrated_database().await;

let first = valid_config();
ntfy::save_config(&db, &first).await.unwrap();
let loaded = ntfy::load_config(&db).await.unwrap();
assert_eq!(loaded.topic, first.topic);
assert!(loaded.enabled);

let second = NtfyConfig {
topic: "csfx-other".to_string(),
token: Some("secret".to_string()),
min_severity: "critical".to_string(),
..first
};
ntfy::save_config(&db, &second).await.unwrap();
let loaded = ntfy::load_config(&db).await.unwrap();
assert_eq!(loaded.topic, "csfx-other");
assert_eq!(loaded.token.as_deref(), Some("secret"));
assert_eq!(loaded.min_severity, "critical");
}
26 changes: 26 additions & 0 deletions control-plane/shared/shared/tests/ssh_access.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
mod common;

use chrono::{Duration, SubsecRound, Utc};
use shared::ssh_access::{self, SshAccess};

#[tokio::test]
#[ignore = "requires postgres via DATABASE_URL"]
async fn persists_and_overwrites_access_window() {
let db = common::migrated_database().await;

let opened = SshAccess {
enabled: true,
expires_at: Some((Utc::now() + Duration::hours(1)).trunc_subsecs(0)),
};
ssh_access::save(&db, &opened).await.unwrap();
assert_eq!(ssh_access::load(&db).await.unwrap(), opened);
assert!(ssh_access::load(&db).await.unwrap().is_open());

let closed = SshAccess {
enabled: false,
expires_at: None,
};
ssh_access::save(&db, &closed).await.unwrap();
assert_eq!(ssh_access::load(&db).await.unwrap(), closed);
assert!(!ssh_access::load(&db).await.unwrap().is_open());
}
15 changes: 10 additions & 5 deletions control-plane/volume-manager/src/patroni/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,19 @@ impl PatroniClient {
}

#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;

pub(crate) fn test_client(nodes: Vec<String>) -> PatroniClient {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
PatroniClient::new("test".to_string(), nodes)
}

#[test]
fn test_extract_node_name() {
let client =
PatroniClient::new("test".to_string(), vec!["http://patroni1:8008".to_string()]);
let client = test_client(vec!["http://patroni1:8008".to_string()]);

assert_eq!(client.extract_node_name("http://patroni1:8008"), "patroni1");
assert_eq!(
Expand All @@ -234,8 +240,7 @@ mod tests {

#[test]
fn test_build_postgres_url() {
let client =
PatroniClient::new("test".to_string(), vec!["http://patroni1:8008".to_string()]);
let client = test_client(vec!["http://patroni1:8008".to_string()]);

assert_eq!(
client.build_postgres_url("http://patroni1:8008"),
Expand Down
12 changes: 5 additions & 7 deletions control-plane/volume-manager/src/patroni/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,14 @@ impl PatroniMonitor {
#[cfg(test)]
mod tests {
use super::*;
use crate::patroni::client::tests::test_client;

#[tokio::test]
async fn test_monitor_creation() {
let client = PatroniClient::new(
"test-scope".to_string(),
vec![
"http://patroni1:8008".to_string(),
"http://patroni2:8008".to_string(),
],
);
let client = test_client(vec![
"http://patroni1:8008".to_string(),
"http://patroni2:8008".to_string(),
]);

let monitor = PatroniMonitor::new(client, 10);
assert_eq!(monitor.check_interval, Duration::from_secs(10));
Expand Down
Loading