mirror of
https://github.com/rubenhensen/k8scd.git
synced 2026-09-16 18:02:55 +02:00
Add nix-infra-machine
This commit is contained in:
@@ -3,3 +3,6 @@ repomix-output.txt
|
||||
credentials-velero
|
||||
velero-credentials
|
||||
.env
|
||||
nix-infra-machine/ssh/
|
||||
nix-infra-machine/.env
|
||||
nix-infra-machine/nix/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# NOTE: This file contains secrets are required for various operations
|
||||
# by the nix-infra CLI. Make sure the file is encrypted when not in use
|
||||
|
||||
# The Hetzner Cloud API-token is needed to perform provisioning
|
||||
# and discovery https://www.hetzner.com/cloud/
|
||||
HCLOUD_TOKEN=
|
||||
|
||||
# SSH
|
||||
SSH_KEY=
|
||||
SSH_EMAIL=
|
||||
|
||||
# Secrets
|
||||
# Password used to encrypt secrets at rest
|
||||
SECRETS_PWD=
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Env variables, some are probably secret
|
||||
.env
|
||||
# These are the application configuration secrets passed to systemd credentials
|
||||
secrets/*
|
||||
# This is the certificate authority
|
||||
ca/*
|
||||
# This is the ssh directory
|
||||
ssh/*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sebastian Ware
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,241 @@
|
||||
# nix-infra-machine
|
||||
|
||||
A standalone machine template for [nix-infra](https://github.com/jhsware/nix-infra). This template allows you to deploy and manage individual machines (or fleets of machines) with minimal configuration. All you need is a Hetzner Cloud account.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [nix-infra CLI](https://github.com/jhsware/nix-infra/releases) installed
|
||||
- A Hetzner Cloud account with an API token
|
||||
- Git installed
|
||||
|
||||
Optional but recommended: Install [Nix](https://docs.determinate.systems/determinate-nix/) and work in a nix-shell for reproducible environments.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Run this script to clone the template:
|
||||
|
||||
```sh
|
||||
sh <(curl -L https://raw.githubusercontent.com/jhsware/nix-infra-machine/refs/heads/main/scripts/get-test.sh)
|
||||
```
|
||||
|
||||
2. Get an API token from your Hetzner Cloud project
|
||||
|
||||
3. Edit the `.env` file in the created folder with your token and settings
|
||||
|
||||
4. Explore available commands:
|
||||
|
||||
```sh
|
||||
cd test-nix-infra-machine
|
||||
|
||||
# Infrastructure management (create, destroy, ssh, etc.)
|
||||
./cli --help
|
||||
|
||||
# Run test suite against machines
|
||||
./__test__/run-tests.sh --help
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The `cli` script is your main interface for managing infrastructure:
|
||||
|
||||
```sh
|
||||
# Create a machine
|
||||
./cli create node001
|
||||
|
||||
# Create multiple machines
|
||||
./cli create node001 node002 node003
|
||||
|
||||
# SSH into a machine
|
||||
./cli ssh node001
|
||||
|
||||
# Run commands on machines
|
||||
./cli cmd --target=node001 "systemctl status nginx"
|
||||
|
||||
# Update configuration and deploy apps
|
||||
./cli update node001
|
||||
|
||||
# Upgrade NixOS version
|
||||
./cli upgrade node001
|
||||
|
||||
# Rollback to previous configuration
|
||||
./cli rollback node001
|
||||
|
||||
# Run app module actions
|
||||
./cli action --target=node001 myapp status
|
||||
|
||||
# Port forward from remote to local
|
||||
./cli port-forward --target=node001 --port-mapping=8080:80
|
||||
|
||||
# Destroy machines
|
||||
./cli destroy --target="node001 node002"
|
||||
|
||||
# Launch Claude with MCP integration
|
||||
./cli claude
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
The test workflow has two stages:
|
||||
|
||||
### 1. Create the test machines
|
||||
|
||||
The `create` command provisions the base machines and verifies basic functionality:
|
||||
|
||||
```sh
|
||||
# Provision machines and run basic health checks
|
||||
./__test__/run-tests.sh create
|
||||
```
|
||||
|
||||
This creates and verifies: NixOS installation and basic system health.
|
||||
|
||||
### 2. Run app_module tests against the machines
|
||||
|
||||
Once you have running machines, use `run` to test specific app_modules:
|
||||
|
||||
```sh
|
||||
# Run a single app test (e.g., mongodb)
|
||||
./__test__/run-tests.sh run mongodb
|
||||
|
||||
# Keep test apps deployed after running
|
||||
./__test__/run-tests.sh run --no-teardown mongodb
|
||||
```
|
||||
|
||||
Available tests are defined in `__test__/<test-name>/test.sh`. List available tests:
|
||||
|
||||
```sh
|
||||
ls __test__/*/test.sh
|
||||
```
|
||||
|
||||
### Other test commands
|
||||
|
||||
```sh
|
||||
# Reset machine state between test runs
|
||||
./__test__/run-tests.sh reset mongodb
|
||||
|
||||
# Destroy all test machines
|
||||
./__test__/run-tests.sh destroy
|
||||
|
||||
# Check machine health
|
||||
./__test__/run-tests.sh test
|
||||
```
|
||||
|
||||
Useful commands for exploring running test machines:
|
||||
|
||||
```sh
|
||||
./__test__/run-tests.sh ssh node001
|
||||
./__test__/run-tests.sh cmd --target=node001 "uptime"
|
||||
```
|
||||
|
||||
### Developing App Modules with Claude
|
||||
1. Install nix-infra, including nix-infra-dev-mcp
|
||||
|
||||
https://github.com/jhsware/nix-infra
|
||||
|
||||
2. Run claude with access to nix-infra-dev-mcp:
|
||||
|
||||
```sh
|
||||
./__test__/run-tests.sh claude-dev
|
||||
```
|
||||
|
||||
3. Create a project and set instructions to:
|
||||
|
||||
```
|
||||
Important! Only use tools from nix-infra Development Tools when reading or editing files.
|
||||
|
||||
You are an expert dev-ops engineer building nix-infra app modules for single machine deployment. You use Bash to write scripts and Nix to configure NixOS.
|
||||
|
||||
The project is in /Users/jhsware/DEV/TEST_INFRA_MACHINE you only edit files in /Users/jhsware/DEV/TEST_INFRA_MACHINE/app_modules and /Users/jhsware/DEV/TEST_INFRA_MACHINE/__test__
|
||||
|
||||
Example of an app module can be found at /Users/jhsware/DEV/TEST_INFRA_MACHINE/app_modules/mongodb with tests at /Users/jhsware/DEV/TEST_INFRA_MACHINE/__test__/mongodb
|
||||
./app_modules/postgresql, ./__test__/postgresql
|
||||
./app_modules/nextcloud, ./__test__/nextcloud
|
||||
./app_modules/_unstable/crowdsec, ./__test__/crowdsec
|
||||
./app_modules/_unstable/n8n, ./__test__/n9n
|
||||
|
||||
You are tasked with creating new app modules according to requirements by user. You will create and edit files in order to achieve this goal.
|
||||
|
||||
You will also create a test environment and run the app module test files in that environment. Do not destroy the test environment unless explicitly told to do so by the user.
|
||||
|
||||
You will perform actions in clear steps and ask the user for confirmation before each step is implemented. For more complex tasks, perform them in multiple sub steps to avoid sessions to time out or overflow.
|
||||
```
|
||||
|
||||
4. Prompt Claude to create an app module and let it run tests using the run-test.sh cli
|
||||
|
||||
If a session stalls or fails to complete you can run the tests manually and paste the results. This can help in complex situations where Claude appears to get stuck or times out.
|
||||
|
||||
By having a compact project instruction and limited tool set you get maximum context space for your code and problem specific documentation. Claude will run tests and you are mainly required to coach it to complete the task. You may need to perform some limited manual editing and it is useful to create a new chat at times in order to allow Claude to clear it's context and avoid getting tunnel vision.
|
||||
|
||||
## Custom Configuration
|
||||
|
||||
To create your own configuration from scratch:
|
||||
|
||||
1. Clone this repository:
|
||||
|
||||
```sh
|
||||
git clone git@github.com:jhsware/nix-infra-machine.git my-infrastructure
|
||||
cd my-infrastructure
|
||||
```
|
||||
|
||||
2. Set up environment:
|
||||
|
||||
```sh
|
||||
cp .env.in .env
|
||||
nano .env # Add your HCLOUD_TOKEN and other settings
|
||||
```
|
||||
|
||||
3. Create and manage your machines:
|
||||
|
||||
```sh
|
||||
./cli create node001
|
||||
./cli ssh node001
|
||||
./cli update node001
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── cli # Main CLI for infrastructure management
|
||||
├── .env # Environment configuration (create from .env.in)
|
||||
├── nodes/ # Per-node configuration files
|
||||
├── node_types/ # Node type templates (standalone_machine.nix)
|
||||
├── app_modules/ # Application module definitions
|
||||
├── __test__/ # Test scripts and test definitions
|
||||
└── scripts/ # Utility scripts
|
||||
```
|
||||
|
||||
## Deploying Applications
|
||||
|
||||
Each node has its configuration in `nodes/`. Configure what apps to run and their settings here.
|
||||
|
||||
Deploy using the `update` command:
|
||||
|
||||
```sh
|
||||
./cli update node001 node002
|
||||
```
|
||||
|
||||
You can specify a custom node module:
|
||||
|
||||
```sh
|
||||
./cli create --node-module=node_types/custom_machine.nix node001
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
Store secrets securely using the nix-infra CLI:
|
||||
|
||||
```sh
|
||||
nix-infra secrets store -d . --secret="my-secret-value" --name="app.secret"
|
||||
```
|
||||
|
||||
Or save action output as a secret:
|
||||
|
||||
```sh
|
||||
./cli action --target=node001 myapp create-credentials --save-as-secret="myapp.credentials"
|
||||
```
|
||||
|
||||
Secrets are encrypted locally and deployed as systemd credentials (automatically encrypted/decrypted on demand).
|
||||
|
||||
## Node Types
|
||||
|
||||
The default node type is `node_types/standalone_machine.nix`. Create custom node types in `node_types/` for different machine configurations, then reference them with `--node-module`.
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assertion library for nix-infra-machine tests
|
||||
# Provides reusable assertion functions with consistent output formatting
|
||||
#
|
||||
# All assertions follow this pattern:
|
||||
# assert_* "label" [args...]
|
||||
# Returns 0 on pass, 1 on fail
|
||||
# Prints colored pass/fail message
|
||||
#
|
||||
# Requires: Colors (GREEN, RED, YELLOW, NC) from shared.sh
|
||||
# Requires: cmd, cmd_value, cmd_clean functions from shared.sh
|
||||
|
||||
# ============================================================================
|
||||
# Service Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if a systemd service is active
|
||||
# Usage: assert_service_active "$node" "service-name" ["optional label"]
|
||||
assert_service_active() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
local label="${3:-$service}"
|
||||
|
||||
local status
|
||||
status=$(cmd_value "$node" "systemctl is-active $service")
|
||||
|
||||
if [[ "$status" == "active" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label: active [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: $status [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a oneshot service completed successfully
|
||||
# Usage: assert_service_completed "$node" "service-name" ["optional label"]
|
||||
assert_service_completed() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
local label="${3:-$service}"
|
||||
|
||||
local status
|
||||
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'inactive'")
|
||||
|
||||
if [[ "$status" == "inactive" ]]; then
|
||||
local exit_status
|
||||
exit_status=$(cmd_value "$node" "systemctl show -p ExecMainStatus $service | cut -d= -f2")
|
||||
if [[ "$exit_status" == "0" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label: completed successfully [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: failed (exit status: $exit_status) [fail]"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} $label: $status [pass]"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if service SubState is running
|
||||
# Usage: assert_service_running "$node" "service-name" ["optional label"]
|
||||
assert_service_running() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
local label="${3:-$service}"
|
||||
|
||||
local state
|
||||
state=$(cmd_value "$node" "systemctl show -p SubState $service --value")
|
||||
|
||||
if [[ "$state" == "running" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label: running [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: $state [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Process Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if a process is running (using pgrep pattern)
|
||||
# Usage: assert_process_running "$node" "pattern" "label"
|
||||
assert_process_running() {
|
||||
local node="$1"
|
||||
local pattern="$2"
|
||||
local label="$3"
|
||||
|
||||
local result
|
||||
result=$(cmd_clean "$node" "pgrep -a $pattern || echo ''")
|
||||
|
||||
if [[ -n "$result" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label process running [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label process not running [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check process count meets minimum
|
||||
# Usage: assert_process_count "$node" "pattern" "min_count" "label"
|
||||
assert_process_count() {
|
||||
local node="$1"
|
||||
local pattern="$2"
|
||||
local min_count="$3"
|
||||
local label="$4"
|
||||
|
||||
local count
|
||||
count=$(cmd_value "$node" "pgrep -c $pattern || echo 0")
|
||||
|
||||
if [[ "$count" -ge "$min_count" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $count $label processes running [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Expected $min_count $label processes, found $count [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Port Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if a port is listening
|
||||
# Usage: assert_port_listening "$node" "port" ["label"]
|
||||
assert_port_listening() {
|
||||
local node="$1"
|
||||
local port="$2"
|
||||
local label="${3:-Port $port}"
|
||||
|
||||
local result
|
||||
result=$(cmd "$node" "ss -tlnp | grep :$port")
|
||||
|
||||
if [[ "$result" == *":$port"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label is listening [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label is not listening [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HTTP Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check HTTP status code
|
||||
# Usage: assert_http_status "$node" "url" "expected_codes" ["label"]
|
||||
# expected_codes can be space-separated: "200 302 303"
|
||||
assert_http_status() {
|
||||
local node="$1"
|
||||
local url="$2"
|
||||
local expected="$3"
|
||||
local label="${4:-HTTP $url}"
|
||||
|
||||
local code
|
||||
code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' '$url' 2>/dev/null || echo '000'")
|
||||
|
||||
for exp in $expected; do
|
||||
if [[ "$code" == "$exp" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label: HTTP $code [pass]"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e " ${RED}✗${NC} $label: HTTP $code (expected: $expected) [fail]"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check HTTP response contains string
|
||||
# Usage: assert_http_contains "$node" "url" "expected_string" ["label"]
|
||||
assert_http_contains() {
|
||||
local node="$1"
|
||||
local url="$2"
|
||||
local expected="$3"
|
||||
local label="${4:-HTTP $url}"
|
||||
|
||||
local response
|
||||
response=$(cmd_clean "$node" "curl -s '$url' 2>/dev/null")
|
||||
|
||||
if [[ "$response" == *"$expected"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label contains '$expected' [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label missing '$expected' [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check HTTP response contains multiple strings (all must match)
|
||||
# Usage: assert_http_contains_all "$node" "url" "string1" "string2" ... ["--label" "label"]
|
||||
assert_http_contains_all() {
|
||||
local node="$1"
|
||||
local url="$2"
|
||||
shift 2
|
||||
|
||||
local label="HTTP $url"
|
||||
local patterns=()
|
||||
|
||||
# Parse arguments - check for --label flag
|
||||
while [[ $# -gt 0 ]]; do
|
||||
if [[ "$1" == "--label" ]]; then
|
||||
label="$2"
|
||||
shift 2
|
||||
else
|
||||
patterns+=("$1")
|
||||
shift
|
||||
fi
|
||||
done
|
||||
|
||||
local response
|
||||
response=$(cmd_clean "$node" "curl -s '$url' 2>/dev/null")
|
||||
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if [[ "$response" != *"$pattern"* ]]; then
|
||||
echo -e " ${RED}✗${NC} $label missing '$pattern' [fail]"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# String Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if value equals expected
|
||||
# Usage: assert_equals "actual" "expected" "label"
|
||||
assert_equals() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: got '$actual', expected '$expected' [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if value contains substring
|
||||
# Usage: assert_contains "haystack" "needle" "label"
|
||||
assert_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
local label="$3"
|
||||
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: missing '$needle' [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if value contains all substrings
|
||||
# Usage: assert_contains_all "haystack" "label" "needle1" "needle2" ...
|
||||
assert_contains_all() {
|
||||
local haystack="$1"
|
||||
local label="$2"
|
||||
shift 2
|
||||
|
||||
for needle in "$@"; do
|
||||
if [[ "$haystack" != *"$needle"* ]]; then
|
||||
echo -e " ${RED}✗${NC} $label: missing '$needle' [fail]"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check if value is not empty
|
||||
# Usage: assert_not_empty "value" "label"
|
||||
assert_not_empty() {
|
||||
local value="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ -n "$value" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: empty [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if value is empty or nil (for Redis-style responses)
|
||||
# Usage: assert_empty_or_nil "value" "label"
|
||||
assert_empty_or_nil() {
|
||||
local value="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ -z "$value" ]] || [[ "$value" == "nil" ]] || [[ "$value" == "(nil)" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: got '$value', expected empty/nil [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if value does NOT contain error indicators
|
||||
# Usage: assert_no_error "value" "label"
|
||||
assert_no_error() {
|
||||
local value="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ "$value" != *"ERROR"* ]] && [[ "$value" != *"error"* ]] && [[ "$value" != *"Error"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: error found [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# File System Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if file exists on remote node
|
||||
# Usage: assert_file_exists "$node" "/path/to/file" ["label"]
|
||||
assert_file_exists() {
|
||||
local node="$1"
|
||||
local path="$2"
|
||||
local label="${3:-File $path}"
|
||||
|
||||
local result
|
||||
result=$(cmd_value "$node" "test -f '$path' && echo 'exists' || echo 'missing'")
|
||||
|
||||
if [[ "$result" == "exists" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label exists [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label not found [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if directory exists on remote node
|
||||
# Usage: assert_dir_exists "$node" "/path/to/dir" ["label"]
|
||||
assert_dir_exists() {
|
||||
local node="$1"
|
||||
local path="$2"
|
||||
local label="${3:-Directory $path}"
|
||||
|
||||
local result
|
||||
result=$(cmd_value "$node" "test -d '$path' && echo 'exists' || echo 'missing'")
|
||||
|
||||
if [[ "$result" == "exists" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label exists [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label not found [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Container Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if a podman container is running
|
||||
# Usage: assert_container_running "$node" "container-name" ["label"]
|
||||
assert_container_running() {
|
||||
local node="$1"
|
||||
local container="$2"
|
||||
local label="${3:-Container $container}"
|
||||
|
||||
local status
|
||||
status=$(cmd_clean "$node" "podman ps --filter name=$container --format '{{.Names}} {{.Status}}'")
|
||||
|
||||
if [[ "$status" == *"$container"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label running [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label not running [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Comparison Assertions
|
||||
# ============================================================================
|
||||
|
||||
# Check if numeric value is greater than or equal to expected
|
||||
# Usage: assert_gte "actual" "expected" "label"
|
||||
assert_gte() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
|
||||
if [[ "$actual" -ge "$expected" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label ($actual >= $expected) [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $label: $actual < $expected [fail]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if numeric value is less than expected (for ordering/timestamps)
|
||||
# Usage: assert_lt "actual" "expected" "label"
|
||||
assert_lt() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local label="$3"
|
||||
|
||||
if [[ -n "$actual" ]] && [[ -n "$expected" ]] && [[ "$actual" -lt "$expected" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} $label: could not verify [warn]"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Soft Assertions (warnings instead of failures)
|
||||
# ============================================================================
|
||||
|
||||
# Soft assertion that shows warning instead of failure
|
||||
# Usage: assert_warn "condition_result" "label" "warn_message"
|
||||
# condition_result should be "true" or "false"
|
||||
assert_warn() {
|
||||
local condition="$1"
|
||||
local label="$2"
|
||||
local warn_msg="${3:-}"
|
||||
|
||||
if [[ "$condition" == "true" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
return 0
|
||||
else
|
||||
if [[ -n "$warn_msg" ]]; then
|
||||
echo -e " ${YELLOW}!${NC} $label ($warn_msg) [warn]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} $label [warn]"
|
||||
fi
|
||||
return 0 # Return success for soft assertions
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Utility: Show logs on failure
|
||||
# ============================================================================
|
||||
|
||||
# Show service logs (call after a failed assertion)
|
||||
# Usage: show_service_logs "$node" "service-name" [lines]
|
||||
show_service_logs() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
local lines="${3:-50}"
|
||||
|
||||
echo ""
|
||||
echo "Service logs for $service:"
|
||||
cmd "$node" "journalctl -n $lines -u $service"
|
||||
}
|
||||
|
||||
# Show container logs (call after a failed assertion)
|
||||
# Usage: show_container_logs "$node" "container-name" [lines]
|
||||
show_container_logs() {
|
||||
local node="$1"
|
||||
local container="$2"
|
||||
local lines="${3:-50}"
|
||||
|
||||
echo ""
|
||||
echo "Container logs for $container:"
|
||||
cmd "$node" "podman logs --tail $lines $container"
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
imports = [
|
||||
# Import based on file structure on deployed machine
|
||||
./app_modules/_unstable/beiwe-backend/default.nix
|
||||
];
|
||||
|
||||
# ==========================================================================
|
||||
# PostgreSQL Database for Beiwe (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.postgresql = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 5432;
|
||||
# Note: Do NOT use initialDatabases here - beiwe-db-setup.service creates
|
||||
# the database when database.createLocally = true. Using both causes race conditions.
|
||||
authentication = ''
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# MinIO for S3-compatible storage (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.minio = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
apiPort = 9000;
|
||||
consolePort = 9001;
|
||||
rootCredentialsSecretName = "minio-credentials";
|
||||
dataDir = [ "/var/lib/minio/data" ];
|
||||
};
|
||||
|
||||
# Create MinIO credentials file
|
||||
config.systemd.services.minio-create-credentials = {
|
||||
description = "Create MinIO credentials file";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "minio.service" ];
|
||||
requiredBy = [ "minio.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
mkdir -p /run/secrets
|
||||
cat > /run/secrets/minio-credentials <<EOF
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin123
|
||||
EOF
|
||||
chmod 400 /run/secrets/minio-credentials
|
||||
'';
|
||||
};
|
||||
|
||||
# Create the beiwe-data bucket after MinIO starts
|
||||
config.systemd.services.minio-create-bucket = {
|
||||
description = "Create Beiwe S3 bucket in MinIO";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "minio.service" ];
|
||||
requires = [ "minio.service" ];
|
||||
before = [ "beiwe-backend.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Set HOME so mc can store its config
|
||||
Environment = "HOME=/tmp/minio-bucket-setup";
|
||||
};
|
||||
path = [ pkgs.minio-client pkgs.curl ];
|
||||
script = ''
|
||||
# Create temp home for mc config
|
||||
mkdir -p /tmp/minio-bucket-setup
|
||||
export HOME=/tmp/minio-bucket-setup
|
||||
|
||||
# Wait for MinIO to be ready (both health check AND API responding)
|
||||
echo "Waiting for MinIO to be ready..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf http://127.0.0.1:9000/minio/health/live > /dev/null 2>&1; then
|
||||
# Also check that the API is responding
|
||||
if curl -sf http://127.0.0.1:9000/minio/health/ready > /dev/null 2>&1; then
|
||||
echo "MinIO is ready"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
echo "Waiting... attempt $i/60"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Give MinIO a moment to fully initialize
|
||||
sleep 2
|
||||
|
||||
# Configure mc client with explicit alias
|
||||
echo "Configuring mc client..."
|
||||
mc alias set local http://127.0.0.1:9000 minioadmin minioadmin123 --api S3v4
|
||||
|
||||
# List existing buckets for debugging
|
||||
echo "Existing buckets:"
|
||||
mc ls local/ || echo "(no buckets yet)"
|
||||
|
||||
# Create bucket if it doesn't exist
|
||||
echo "Creating beiwe-data bucket..."
|
||||
mc mb local/beiwe-data --ignore-existing || true
|
||||
|
||||
# Verify bucket was created
|
||||
echo "Verifying bucket creation..."
|
||||
mc ls local/beiwe-data
|
||||
|
||||
echo "Bucket setup complete"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# RabbitMQ for Celery task queue (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.rabbitmq = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 5672;
|
||||
managementPlugin = {
|
||||
enable = true;
|
||||
port = 15672;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Beiwe Backend Configuration
|
||||
# ==========================================================================
|
||||
config.infrastructure.beiwe-backend = {
|
||||
enable = true;
|
||||
|
||||
# Network settings
|
||||
bindToIp = "0.0.0.0";
|
||||
bindToPort = 8080;
|
||||
openFirewall = true;
|
||||
domainName = "localhost:8080";
|
||||
|
||||
# Security (test values - DO NOT use in production!)
|
||||
flaskSecretKey = "test-secret-key-not-for-production-use";
|
||||
sysadminEmails = "test@localhost";
|
||||
|
||||
# Database configuration (local PostgreSQL)
|
||||
database = {
|
||||
host = "localhost"; # Use TCP connection instead of socket
|
||||
port = 5432;
|
||||
name = "beiwe";
|
||||
user = "beiwe";
|
||||
password = "unused_with_trust_auth"; # Required by Beiwe even with trust auth
|
||||
sslmode = "disable"; # Disable SSL for local development without certificates
|
||||
createLocally = true;
|
||||
};
|
||||
|
||||
# S3 configuration (local MinIO)
|
||||
s3 = {
|
||||
bucket = "beiwe-data";
|
||||
accessKeyId = "minioadmin";
|
||||
secretAccessKey = "minioadmin123";
|
||||
endpoint = "http://127.0.0.1:9000";
|
||||
};
|
||||
|
||||
# Celery configuration (local RabbitMQ)
|
||||
celery = {
|
||||
enable = true;
|
||||
rabbitmq = {
|
||||
host = "127.0.0.1";
|
||||
port = 5672;
|
||||
user = "guest";
|
||||
password = "guest";
|
||||
vhost = "";
|
||||
};
|
||||
concurrency = 2;
|
||||
logLevel = "INFO";
|
||||
};
|
||||
|
||||
# Gunicorn settings
|
||||
gunicorn = {
|
||||
workers = 2;
|
||||
threads = 2;
|
||||
timeout = 120;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Service Dependencies
|
||||
# ==========================================================================
|
||||
|
||||
# Ensure beiwe-backend starts after all dependencies
|
||||
config.systemd.services.beiwe-backend = {
|
||||
after = [
|
||||
"postgresql.service"
|
||||
"minio.service"
|
||||
"minio-create-bucket.service"
|
||||
"beiwe-db-setup.service"
|
||||
"rabbitmq.service"
|
||||
];
|
||||
wants = [
|
||||
"minio-create-bucket.service"
|
||||
];
|
||||
};
|
||||
|
||||
# Ensure celery worker starts after RabbitMQ is ready
|
||||
config.systemd.services.beiwe-celery-worker = {
|
||||
after = [
|
||||
"rabbitmq.service"
|
||||
"postgresql.service"
|
||||
"minio.service"
|
||||
];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Test utilities
|
||||
# ==========================================================================
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
minio-client
|
||||
postgresql
|
||||
];
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# NOTES ON SERVICES
|
||||
# ==========================================================================
|
||||
#
|
||||
# REQUIRED SERVICES (all configured):
|
||||
#
|
||||
# 1. PostgreSQL (Database)
|
||||
# - Status: CONFIGURED via infrastructure.postgresql
|
||||
# - Purpose: Stores all application data, user accounts, study configurations
|
||||
#
|
||||
# 2. MinIO/S3 (Object Storage)
|
||||
# - Status: CONFIGURED via infrastructure.minio
|
||||
# - Purpose: Stores uploaded data files from mobile apps
|
||||
#
|
||||
# OPTIONAL SERVICES:
|
||||
#
|
||||
# 3. RabbitMQ + Celery (Message Queue)
|
||||
# - Status: CONFIGURED via infrastructure.rabbitmq + celery options
|
||||
# - Purpose: Background task processing
|
||||
# - Enables:
|
||||
# * Push notifications to mobile apps
|
||||
# * Data processing pipelines
|
||||
# * Forest analysis integration
|
||||
#
|
||||
# 4. Firebase Credentials
|
||||
# - Status: N/A (credentials, not a service)
|
||||
# - Impact: Push notifications to iOS devices won't work
|
||||
# - To add: Contact Onnela Lab for credentials, configure via environment vars
|
||||
#
|
||||
# 5. Sentry Error Tracking
|
||||
# - Status: N/A (external service)
|
||||
# - Impact: No centralized error tracking
|
||||
# - To add: Create Sentry.io account, add DSN to config.infrastructure.beiwe-backend.sentry.dsn
|
||||
#
|
||||
# WHAT WORKS WITH THIS CONFIGURATION:
|
||||
# - Web-based study management portal
|
||||
# - User authentication and management
|
||||
# - Study configuration
|
||||
# - Survey creation and management
|
||||
# - Participant registration
|
||||
# - Data uploads from mobile apps (stored in S3/MinIO)
|
||||
# - Basic API endpoints
|
||||
# - Background task processing (with Celery enabled)
|
||||
# - Push notifications (requires Firebase credentials)
|
||||
# - Data processing pipelines
|
||||
#
|
||||
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env bash
|
||||
# beiwe-backend test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys beiwe-backend with PostgreSQL, MinIO, RabbitMQ, and Celery
|
||||
# 2. Verifies all services are running
|
||||
# 3. Tests beiwe-backend endpoints and basic functionality
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down beiwe-backend test..."
|
||||
|
||||
# Stop services
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop beiwe-celery-beat 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop beiwe-celery-worker 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop beiwe-backend 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop minio 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop postgresql 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop rabbitmq 2>/dev/null || true'
|
||||
|
||||
# Clean up data directories
|
||||
echo " Removing beiwe-backend data directory..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/beiwe-backend'
|
||||
|
||||
echo " Removing MinIO data directory..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/minio'
|
||||
|
||||
echo " Removing PostgreSQL data directory..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/postgresql'
|
||||
|
||||
echo " Removing RabbitMQ data directory..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/rabbitmq'
|
||||
|
||||
echo "beiwe-backend teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Beiwe Backend Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "This test verifies:"
|
||||
echo " - PostgreSQL database connectivity"
|
||||
echo " - MinIO S3-compatible storage"
|
||||
echo " - RabbitMQ message broker"
|
||||
echo " - Celery background task worker"
|
||||
echo " - Beiwe backend web service"
|
||||
echo ""
|
||||
|
||||
# Deploy the beiwe-backend configuration to test nodes
|
||||
echo "Step 1: Deploying beiwe-backend configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --debug --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" --no-rebuild \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for services to be ready
|
||||
for node in $TARGET; do
|
||||
echo "Waiting for services on $node..."
|
||||
|
||||
# Wait for PostgreSQL
|
||||
wait_for_service "$node" "postgresql" --timeout=60
|
||||
wait_for_port "$node" "5432" --timeout=30
|
||||
|
||||
# Wait for MinIO
|
||||
wait_for_service "$node" "minio" --timeout=60
|
||||
wait_for_port "$node" "9000" --timeout=30
|
||||
|
||||
# Wait for MinIO bucket creation to complete
|
||||
wait_for_service "$node" "minio-create-bucket" --timeout=60
|
||||
|
||||
# Wait for RabbitMQ
|
||||
wait_for_service "$node" "rabbitmq" --timeout=60
|
||||
wait_for_port "$node" "5672" --timeout=30
|
||||
|
||||
# Wait for beiwe-backend (may take time for migrations)
|
||||
wait_for_service "$node" "beiwe-backend" --timeout=60
|
||||
wait_for_port "$node" "8080" --timeout=30
|
||||
|
||||
# Wait for Celery worker (may take time to connect to RabbitMQ)
|
||||
wait_for_service "$node" "beiwe-celery-worker" --timeout=90
|
||||
|
||||
# Wait for HTTP response (Django may take time to initialize)
|
||||
wait_for_http "$node" "http://localhost:8080/" "200 302 303 400 403 404 500" --timeout=90
|
||||
done
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Status
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Checking systemd services status..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "...checking services on $node"
|
||||
|
||||
# PostgreSQL
|
||||
echo " Checking PostgreSQL..."
|
||||
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 50
|
||||
|
||||
# MinIO
|
||||
echo " Checking MinIO..."
|
||||
assert_service_active "$node" "minio" || show_service_logs "$node" "minio" 50
|
||||
|
||||
# MinIO bucket creation (oneshot service)
|
||||
echo " Checking minio-create-bucket..."
|
||||
bucket_setup_status=$(cmd_clean "$node" "systemctl is-active minio-create-bucket 2>/dev/null || echo 'unknown'")
|
||||
if [[ "$bucket_setup_status" == "active" ]] || [[ "$bucket_setup_status" == "activating" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} minio-create-bucket: $bucket_setup_status [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} minio-create-bucket: $bucket_setup_status [warn]"
|
||||
show_service_logs "$node" "minio-create-bucket" 50
|
||||
fi
|
||||
|
||||
# RabbitMQ
|
||||
echo " Checking RabbitMQ..."
|
||||
assert_service_active "$node" "rabbitmq" || show_service_logs "$node" "rabbitmq" 50
|
||||
|
||||
# Beiwe Backend
|
||||
echo " Checking beiwe-backend..."
|
||||
assert_service_active "$node" "beiwe-backend" || show_service_logs "$node" "beiwe-backend" 100
|
||||
|
||||
# Celery Worker
|
||||
echo " Checking beiwe-celery-worker..."
|
||||
assert_service_active "$node" "beiwe-celery-worker" || show_service_logs "$node" "beiwe-celery-worker" 100
|
||||
|
||||
# Celery Beat (scheduler)
|
||||
echo " Checking beiwe-celery-beat..."
|
||||
assert_service_active "$node" "beiwe-celery-beat" || show_service_logs "$node" "beiwe-celery-beat" 50
|
||||
done
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Check Port Bindings
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Checking port bindings..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking ports on $node..."
|
||||
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
|
||||
assert_port_listening "$node" "9000" "MinIO API port 9000"
|
||||
assert_port_listening "$node" "5672" "RabbitMQ AMQP port 5672"
|
||||
assert_port_listening "$node" "15672" "RabbitMQ Management port 15672"
|
||||
assert_port_listening "$node" "8080" "Beiwe backend port 8080"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Database Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Testing PostgreSQL database..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing database on $node..."
|
||||
|
||||
# Check database exists
|
||||
db_exists=$(cmd_clean "$node" "sudo -u postgres psql -lqt | grep -c beiwe || echo 0")
|
||||
if [[ "$db_exists" -ge 1 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Database 'beiwe' exists [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Database 'beiwe' not found [fail]"
|
||||
fi
|
||||
|
||||
# Check user exists
|
||||
user_exists=$(cmd_clean "$node" "sudo -u postgres psql -c \"SELECT 1 FROM pg_roles WHERE rolname='beiwe'\" | grep -c 1 || echo 0")
|
||||
if [[ "$user_exists" -ge 1 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} User 'beiwe' exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} User 'beiwe' not found (may be created on first run) [warn]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# MinIO Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 7: Testing MinIO S3 storage..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing MinIO on $node..."
|
||||
|
||||
# Check MinIO health
|
||||
minio_health=$(cmd_clean "$node" "curl -s http://127.0.0.1:9000/minio/health/live 2>/dev/null || echo 'failed'")
|
||||
if [[ "$minio_health" != "failed" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} MinIO health check passed [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} MinIO health check failed [fail]"
|
||||
fi
|
||||
|
||||
# Check bucket exists (set HOME for mc config)
|
||||
bucket_exists=$(cmd_clean "$node" "export HOME=/tmp/mc-test-check && mkdir -p \$HOME && mc alias set local http://127.0.0.1:9000 minioadmin minioadmin123 --api S3v4 > /dev/null 2>&1 && mc ls local/beiwe-data > /dev/null 2>&1 && echo 'yes' || echo 'no'")
|
||||
if [[ "$bucket_exists" == "yes" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Bucket 'beiwe-data' exists [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Bucket 'beiwe-data' not found [fail]"
|
||||
# Show bucket list for debugging
|
||||
echo " Available buckets:"
|
||||
cmd_clean "$node" "export HOME=/tmp/mc-test-check && mc ls local/ 2>/dev/null || echo ' (none)'" | while read line; do echo " $line"; done
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RabbitMQ Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 8: Testing RabbitMQ message broker..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing RabbitMQ on $node..."
|
||||
|
||||
# Check RabbitMQ via management API (more reliable than rabbitmqctl which needs root)
|
||||
rabbitmq_api_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' -u guest:guest http://127.0.0.1:15672/api/overview 2>/dev/null || echo '000'")
|
||||
if [[ "$rabbitmq_api_status" == "200" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} RabbitMQ API responding (status: $rabbitmq_api_status) [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} RabbitMQ API not responding (status: $rabbitmq_api_status) [fail]"
|
||||
fi
|
||||
|
||||
# Check management UI is accessible
|
||||
mgmt_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:15672/ 2>/dev/null || echo '000'")
|
||||
if [[ "$mgmt_status" == "200" ]] || [[ "$mgmt_status" == "301" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} RabbitMQ Management UI accessible (status: $mgmt_status) [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} RabbitMQ Management UI returned status: $mgmt_status [warn]"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Celery Worker Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 9: Testing Celery worker..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing Celery on $node..."
|
||||
|
||||
# Check Celery process is running
|
||||
celery_running=$(cmd_clean "$node" "pgrep -f 'celery.*worker' > /dev/null && echo 'yes' || echo 'no'")
|
||||
if [[ "$celery_running" == "yes" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Celery worker process running [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Celery worker process not found [fail]"
|
||||
fi
|
||||
|
||||
# Check Celery beat (scheduler) is running
|
||||
celery_beat=$(cmd_clean "$node" "pgrep -f 'celery.*beat' > /dev/null && echo 'yes' || echo 'no'")
|
||||
if [[ "$celery_beat" == "yes" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Celery beat (scheduler) running [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Celery beat not running (scheduled tasks may not work) [warn]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Beiwe Backend HTTP Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 10: Testing Beiwe backend HTTP endpoints..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing Beiwe backend on $node..."
|
||||
|
||||
# Test basic HTTP response (any response means server is running)
|
||||
http_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/ 2>/dev/null")
|
||||
if [[ -n "$http_status" ]] && [[ "$http_status" != "000" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HTTP response received (status: $http_status) [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} No HTTP response from beiwe-backend [fail]"
|
||||
fi
|
||||
|
||||
# Test if Django is responding (check for specific patterns in response)
|
||||
response_body=$(cmd_clean "$node" "curl -s http://localhost:8080/ 2>/dev/null | head -c 500")
|
||||
if [[ "$response_body" == *"html"* ]] || [[ "$response_body" == *"HTML"* ]] || [[ "$response_body" == *"django"* ]] || [[ "$response_body" == *"Django"* ]] || [[ "$response_body" == *"Beiwe"* ]] || [[ "$response_body" == *"beiwe"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Django/Beiwe response detected [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Response doesn't look like Django/Beiwe (may still be OK) [warn]"
|
||||
echo " Response preview: ${response_body:0:100}..."
|
||||
fi
|
||||
|
||||
# Check that gunicorn process is running
|
||||
echo " Checking gunicorn process..."
|
||||
gunicorn_running=$(cmd_clean "$node" "pgrep -f gunicorn > /dev/null && echo 'yes' || echo 'no'")
|
||||
if [[ "$gunicorn_running" == "yes" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Gunicorn process running [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Gunicorn process not found [fail]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Service Health Summary
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 11: Final health checks..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Final checks on $node..."
|
||||
|
||||
# Check for any failed units
|
||||
echo " Checking for failed units..."
|
||||
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -E 'beiwe|minio|postgresql|rabbitmq' || echo 'none'")
|
||||
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]] || [[ ! "$failed_units" == *"failed"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} No failed related units [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Failed units found: $failed_units [fail]"
|
||||
fi
|
||||
|
||||
# Check data directories
|
||||
echo " Checking data directories..."
|
||||
assert_dir_exists "$node" "/var/lib/minio" "MinIO data directory"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Beiwe Backend Test Summary"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Services tested:"
|
||||
echo " ✓ PostgreSQL (database)"
|
||||
echo " ✓ MinIO (S3-compatible storage)"
|
||||
echo " ✓ RabbitMQ (message broker)"
|
||||
echo " ✓ Celery Worker (background tasks)"
|
||||
echo " ✓ Celery Beat (task scheduler)"
|
||||
echo " ✓ Beiwe Backend (Django/Gunicorn)"
|
||||
echo ""
|
||||
echo "Optional services NOT configured:"
|
||||
echo " ✗ Firebase credentials (for push notifications)"
|
||||
echo " ✗ Sentry error tracking"
|
||||
echo ""
|
||||
echo "Features available with this configuration:"
|
||||
echo " ✓ Web-based study management portal"
|
||||
echo " ✓ User authentication and management"
|
||||
echo " ✓ Study and survey configuration"
|
||||
echo " ✓ Participant registration"
|
||||
echo " ✓ Data uploads from mobile apps"
|
||||
echo " ✓ Background task processing"
|
||||
echo " ✓ Data processing pipelines"
|
||||
echo ""
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Beiwe Backend Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,124 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
imports = [
|
||||
# CrowdSec module with modular structure:
|
||||
# - default.nix: Core engine and detection features
|
||||
# - bouncers/firewall.nix: Firewall bouncer (nftables/iptables/ipset)
|
||||
# - bouncers/haproxy.nix: HAProxy SPOA bouncer
|
||||
# - bouncers/python.nix: Python bouncer registration for pycrowdsec
|
||||
# - integrations/auditd.nix: Linux Audit Framework integration
|
||||
# - integrations/console.nix: CrowdSec Console cloud enrollment
|
||||
./app_modules/_unstable/crowdsec/default.nix
|
||||
];
|
||||
|
||||
# ==========================================================================
|
||||
# CrowdSec Configuration
|
||||
# ==========================================================================
|
||||
infrastructure.crowdsec = {
|
||||
enable = true;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Core Configuration (from default.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# API Configuration - Local API (LAPI) settings
|
||||
api = {
|
||||
listenAddr = "127.0.0.1";
|
||||
listenPort = 8080;
|
||||
};
|
||||
|
||||
# Detection Features - What log sources to monitor
|
||||
features = {
|
||||
# Enable SSH brute-force protection (monitors journalctl for sshd.service)
|
||||
sshProtection = true;
|
||||
|
||||
# Disable nginx protection (not installed in test environment)
|
||||
nginxProtection = false;
|
||||
|
||||
# Enable system/kernel protection (monitors kernel logs)
|
||||
systemProtection = true;
|
||||
|
||||
# Enable community blocklists (requires console enrollment in production)
|
||||
communityBlocklists = true;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Firewall Bouncer (from bouncers/firewall.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# Enable firewall bouncer to block malicious IPs at network level
|
||||
# Available in NixOS 25.11+ via pkgs.crowdsec-firewall-bouncer
|
||||
firewallBouncer = true;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HAProxy Bouncer (from bouncers/haproxy.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# Disable HAProxy protection (no HAProxy service in test environment)
|
||||
# The module handles missing packages gracefully (defaults to null)
|
||||
haproxyProtection = false;
|
||||
};
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Firewall Bouncer Settings (from bouncers/firewall.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
bouncer = {
|
||||
# Use nftables mode with declarative table integration
|
||||
mode = "nftables";
|
||||
nftablesIntegration = true;
|
||||
|
||||
# Block action and logging
|
||||
denyAction = "DROP";
|
||||
denyLog = true;
|
||||
denyLogPrefix = "crowdsec-test: ";
|
||||
|
||||
# Default ban duration
|
||||
banDuration = "4h";
|
||||
};
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HAProxy Bouncer Settings (from bouncers/haproxy.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# These settings would apply if haproxyProtection were enabled
|
||||
# and the cs-haproxy-spoa-bouncer package were available
|
||||
haproxy = {
|
||||
listenAddr = "127.0.0.1";
|
||||
listenPort = 3000;
|
||||
action = "deny";
|
||||
logLevel = "info";
|
||||
};
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Console Integration (from integrations/console.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# Cloud enrollment disabled for test - would need valid enrollment key
|
||||
console = {
|
||||
enrollKeyFile = null;
|
||||
shareDecisions = false;
|
||||
};
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Auditd Integration (from integrations/auditd.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# Kernel-level security monitoring via Linux Audit Framework
|
||||
auditd = {
|
||||
enable = true;
|
||||
|
||||
# Custom audit rules for sensitive files
|
||||
# Note: nixWrappersWhitelistProcess is currently disabled due to
|
||||
# auditd compatibility issues with the 'comm' field filter
|
||||
rules = [
|
||||
"-w /etc/passwd -p wa -k identity"
|
||||
"-w /etc/shadow -p wa -k identity"
|
||||
"-w /etc/group -p wa -k identity"
|
||||
"-w /etc/sudoers -p wa -k sudoers"
|
||||
];
|
||||
};
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Python Bouncer (from bouncers/python.nix)
|
||||
# --------------------------------------------------------------------------
|
||||
# Disabled for test - enable for Python web application integration
|
||||
# python = {
|
||||
# enable = true;
|
||||
# bouncerName = "my-flask-app";
|
||||
# apiKeyFileGroup = "www-data";
|
||||
# };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env bash
|
||||
# CrowdSec Intrusion Prevention System test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys CrowdSec with SSH and system protection enabled
|
||||
# 2. Verifies the CrowdSec service and Local API are running
|
||||
# 3. Tests firewall bouncer (nftables integration)
|
||||
# 4. Tests HAProxy SPOA bouncer
|
||||
# 5. Tests auditd integration for kernel-level monitoring
|
||||
# 6. Tests cscli functionality (hub, parsers, scenarios)
|
||||
# 7. Tests basic decision management
|
||||
# 8. Cleans up on teardown
|
||||
#
|
||||
# [NIS2 COMPLIANCE VERIFICATION]
|
||||
# This test validates that the CrowdSec deployment meets key NIS2 requirements:
|
||||
# - Article 21(2)(b): Incident handling through automated threat detection
|
||||
# - Article 21(2)(d): Network security through IDS/IPS capabilities
|
||||
# - Article 21(2)(g): Security monitoring and logging
|
||||
|
||||
# Configuration
|
||||
CROWDSEC_API_PORT=8080
|
||||
FIREWALL_BOUNCER_ENABLED=true
|
||||
HAPROXY_BOUNCER_ENABLED=false
|
||||
HAPROXY_SPOA_PORT=3000
|
||||
AUDITD_ENABLED=true
|
||||
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down CrowdSec test..."
|
||||
|
||||
# Stop CrowdSec services
|
||||
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop crowdsec-haproxy-bouncer 2>/dev/null || true'
|
||||
fi
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop crowdsec-firewall-bouncer 2>/dev/null || true'
|
||||
fi
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop crowdsec 2>/dev/null || true'
|
||||
|
||||
# Clean up data directories on target nodes
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/crowdsec'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/crowdsec-firewall-bouncer 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/crowdsec-haproxy-bouncer 2>/dev/null || true'
|
||||
|
||||
# Clean up declarative configuration directories on target nodes
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /etc/crowdsec 2>/dev/null || true'
|
||||
|
||||
echo "CrowdSec teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "CrowdSec Intrusion Prevention Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Testing NIS2-compliant security monitoring setup"
|
||||
echo ""
|
||||
|
||||
# Deploy the CrowdSec configuration to test nodes
|
||||
echo "Step 1: Deploying CrowdSec configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification - CrowdSec Service
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying CrowdSec deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for CrowdSec service to start
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "crowdsec" --timeout=90
|
||||
wait_for_port "$node" "$CROWDSEC_API_PORT" --timeout=60
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking CrowdSec systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "crowdsec" || show_service_logs "$node" "crowdsec" 50
|
||||
done
|
||||
|
||||
# Check if CrowdSec process is running
|
||||
echo ""
|
||||
echo "Checking CrowdSec process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "crowdsec" "CrowdSec"
|
||||
done
|
||||
|
||||
# Check if CrowdSec API port is listening
|
||||
echo ""
|
||||
echo "Checking CrowdSec API port ($CROWDSEC_API_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$CROWDSEC_API_PORT" "CrowdSec API port $CROWDSEC_API_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification - Firewall Bouncer
|
||||
# ============================================================================
|
||||
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo ""
|
||||
echo "Step 4: Verifying Firewall Bouncer..."
|
||||
echo ""
|
||||
|
||||
# Wait for bouncer service
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "crowdsec-firewall-bouncer" --timeout=60
|
||||
done
|
||||
|
||||
# Check bouncer service status
|
||||
echo "Checking Firewall Bouncer systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "crowdsec-firewall-bouncer" || \
|
||||
show_service_logs "$node" "crowdsec-firewall-bouncer" 50
|
||||
done
|
||||
|
||||
# Check nftables integration
|
||||
echo ""
|
||||
echo "Checking nftables integration..."
|
||||
for node in $TARGET; do
|
||||
echo " Verifying nftables tables and sets on $node..."
|
||||
|
||||
# Check if crowdsec table exists
|
||||
nft_table=$(cmd_clean "$node" "nft list table ip crowdsec 2>&1 || echo 'not found'")
|
||||
if [[ "$nft_table" == *"table ip crowdsec"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} CrowdSec nftables IPv4 table exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} CrowdSec nftables tables: $nft_table [warning]"
|
||||
fi
|
||||
|
||||
# Check if crowdsec set exists in IPv4 table
|
||||
nft_set=$(cmd_clean "$node" "nft list set ip crowdsec crowdsec-blocklist 2>&1 || echo 'not found'")
|
||||
if [[ "$nft_set" == *"crowdsec-blocklist"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} CrowdSec IPv4 nftables set exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} CrowdSec IPv4 set: $nft_set [warning]"
|
||||
fi
|
||||
|
||||
# Check if crowdsec chain exists
|
||||
nft_chain=$(cmd_clean "$node" "nft list chain ip crowdsec crowdsec-chain 2>&1 || echo 'not found'")
|
||||
if [[ "$nft_chain" == *"crowdsec-chain"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} CrowdSec nftables chain exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} CrowdSec chain: $nft_chain [warning]"
|
||||
fi
|
||||
|
||||
# Check IPv6 table
|
||||
nft_table6=$(cmd_clean "$node" "nft list table ip6 crowdsec6 2>&1 || echo 'not found'")
|
||||
if [[ "$nft_table6" == *"table ip6 crowdsec6"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} CrowdSec nftables IPv6 table exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} CrowdSec IPv6 table: $nft_table6 [warning]"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Step 4: Firewall Bouncer (SKIPPED - disabled in test config)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification - HAProxy SPOA Bouncer
|
||||
# ============================================================================
|
||||
|
||||
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo ""
|
||||
echo "Step 5: Verifying HAProxy SPOA Bouncer..."
|
||||
echo ""
|
||||
|
||||
# Wait for bouncer service
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "crowdsec-haproxy-bouncer" --timeout=60
|
||||
wait_for_port "$node" "$HAPROXY_SPOA_PORT" --timeout=60
|
||||
done
|
||||
|
||||
# Check bouncer service status
|
||||
echo "Checking HAProxy SPOA Bouncer systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "crowdsec-haproxy-bouncer" || \
|
||||
show_service_logs "$node" "crowdsec-haproxy-bouncer" 50
|
||||
done
|
||||
|
||||
# Check if SPOA port is listening
|
||||
echo ""
|
||||
echo "Checking HAProxy SPOA port ($HAPROXY_SPOA_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$HAPROXY_SPOA_PORT" "HAProxy SPOA port $HAPROXY_SPOA_PORT"
|
||||
done
|
||||
|
||||
# Verify SPOA config exists
|
||||
echo ""
|
||||
echo "Checking HAProxy SPOA bouncer configuration..."
|
||||
for node in $TARGET; do
|
||||
config_check=$(cmd_clean "$node" "test -f /var/lib/crowdsec-haproxy-bouncer/config.yaml && echo 'exists' || echo 'missing'")
|
||||
if [[ "$config_check" == *"exists"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HAProxy SPOA bouncer config exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} HAProxy SPOA bouncer config: $config_check [warning]"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Step 5: HAProxy SPOA Bouncer (SKIPPED - disabled in test config)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification - Auditd Integration
|
||||
# ============================================================================
|
||||
|
||||
if [ "$AUDITD_ENABLED" = "true" ]; then
|
||||
echo ""
|
||||
echo "Step 6: Verifying Auditd Integration..."
|
||||
echo ""
|
||||
|
||||
# Check if auditd service is running
|
||||
for node in $TARGET; do
|
||||
echo "Checking auditd service on $node..."
|
||||
|
||||
# Check auditd service status
|
||||
auditd_status=$(cmd_clean "$node" "systemctl is-active auditd 2>&1 || echo 'inactive'")
|
||||
if [[ "$auditd_status" == "active" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Auditd service is active [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Auditd service status: $auditd_status [warning]"
|
||||
fi
|
||||
|
||||
# Check audit-rules-nixos service status and errors
|
||||
echo " Checking audit-rules-nixos service..."
|
||||
audit_rules_status=$(cmd_clean "$node" "systemctl is-active audit-rules-nixos 2>&1 || echo 'inactive'")
|
||||
# Service might be one-shot, check if it succeeded
|
||||
audit_rules_result=$(cmd_clean "$node" "systemctl show audit-rules-nixos --property=Result 2>&1 || echo 'unknown'")
|
||||
if [[ "$audit_rules_result" == *"success"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Audit rules loaded successfully [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Audit rules service result: $audit_rules_result [warning]"
|
||||
# Show the actual error
|
||||
echo " Debugging audit rules failure..."
|
||||
audit_rules_file=$(cmd_clean "$node" "find /nix/store -name 'audit.rules' -path '*-audit.rules*' 2>/dev/null | head -1 || echo 'not found'")
|
||||
echo " Audit rules file: $audit_rules_file"
|
||||
if [[ "$audit_rules_file" != "not found" ]] && [[ -n "$audit_rules_file" ]]; then
|
||||
echo " Rules file contents:"
|
||||
audit_rules_content=$(cmd_clean "$node" "cat '$audit_rules_file' 2>&1 || echo 'cannot read'")
|
||||
echo "$audit_rules_content" | while read line; do echo " $line"; done
|
||||
echo " Trying to load rules manually..."
|
||||
manual_load=$(cmd_clean "$node" "auditctl -R '$audit_rules_file' 2>&1 || echo 'load failed'")
|
||||
echo " Manual load result: $manual_load"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if audit rules are loaded
|
||||
echo " Checking loaded audit rules..."
|
||||
audit_rules=$(cmd_clean "$node" "auditctl -l 2>&1 || echo 'no rules'")
|
||||
if [[ "$audit_rules" == *"passwd"* ]] || [[ "$audit_rules" == *"shadow"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Audit rules for identity files loaded [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Audit rules: $audit_rules [warning]"
|
||||
fi
|
||||
|
||||
# Check if sudoers watch rule is loaded
|
||||
if [[ "$audit_rules" == *"sudoers"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Audit rule for sudoers file loaded [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Sudoers audit rule not found [warning]"
|
||||
fi
|
||||
|
||||
# Verify whitelist processes are configured (check audit config)
|
||||
echo " Checking NixOS wrapper whitelist configuration..."
|
||||
# The whitelist is configured via audit rules, we verify it was processed
|
||||
# by checking that the service started without errors
|
||||
if [[ "$auditd_status" == "active" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} NixOS wrapper whitelist configured (service active) [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Cannot verify whitelist (auditd not active) [warning]"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Step 6: Auditd Integration (SKIPPED - disabled in test config)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - CLI Tools
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 7: Testing CrowdSec CLI (cscli)..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing cscli on $node..."
|
||||
|
||||
# Test cscli version
|
||||
echo " Checking cscli version..."
|
||||
version_result=$(cmd_clean "$node" "cscli version 2>&1")
|
||||
if [[ "$version_result" == *"version"* ]] || [[ "$version_result" == *"crowdsec"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} cscli version command works [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} cscli version output: $version_result [warning]"
|
||||
fi
|
||||
|
||||
# Test LAPI status
|
||||
echo " Checking Local API status..."
|
||||
lapi_status=$(cmd_clean "$node" "cscli lapi status 2>&1 || true")
|
||||
if [[ "$lapi_status" == *"successfully interact"* ]] || [[ "$lapi_status" == *"LAPI is reachable"* ]] || [[ "$lapi_status" == *"You can successfully"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Local API is reachable [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} LAPI status check: $lapi_status [warning]"
|
||||
fi
|
||||
|
||||
# Test hub listing
|
||||
echo " Checking installed hub items..."
|
||||
hub_result=$(cmd_clean "$node" "cscli hub list 2>&1 || true")
|
||||
if [[ "$hub_result" == *"COLLECTIONS"* ]] || [[ "$hub_result" == *"PARSERS"* ]] || [[ "$hub_result" == *"SCENARIOS"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Hub listing works [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Hub listing output: $hub_result [warning]"
|
||||
fi
|
||||
|
||||
# Test collections listing
|
||||
echo " Checking installed collections..."
|
||||
collections_result=$(cmd_clean "$node" "cscli collections list 2>&1 || true")
|
||||
if [[ "$collections_result" == *"sshd"* ]] || [[ "$collections_result" == *"crowdsecurity"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} SSH collection installed [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} SSH collection may still be installing [info]"
|
||||
fi
|
||||
|
||||
# Test parsers listing
|
||||
echo " Checking installed parsers..."
|
||||
parsers_result=$(cmd_clean "$node" "cscli parsers list 2>&1 || true")
|
||||
if [[ "$parsers_result" == *"sshd"* ]] || [[ "$parsers_result" == *"syslog"* ]] || [[ "$parsers_result" == *"crowdsecurity"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Parsers installed [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Parsers may still be installing: $parsers_result [warning]"
|
||||
fi
|
||||
|
||||
# Test scenarios listing
|
||||
echo " Checking installed scenarios..."
|
||||
scenarios_result=$(cmd_clean "$node" "cscli scenarios list 2>&1 || true")
|
||||
if [[ "$scenarios_result" == *"ssh"* ]] || [[ "$scenarios_result" == *"crowdsecurity"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Scenarios installed [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Scenarios may still be installing [info]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Decision Management
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 8: Testing Decision Management..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing decision management on $node..."
|
||||
|
||||
# List current decisions (should be empty initially)
|
||||
echo " Listing current decisions..."
|
||||
decisions_result=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
|
||||
if [[ "$decisions_result" == *"No active decisions"* ]] || [[ "$decisions_result" == *"0 decision"* ]] || [[ -z "$decisions_result" ]] || [[ "$decisions_result" == *"decision"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Decision listing works [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Decision listing output: $decisions_result [info]"
|
||||
fi
|
||||
|
||||
# Add a test decision (ban a test IP)
|
||||
echo " Adding test decision (ban 192.0.2.1 - TEST-NET-1)..."
|
||||
add_result=$(cmd_clean "$node" "cscli decisions add --ip 192.0.2.1 --reason 'nix-infra test' --type ban 2>&1 || true")
|
||||
if [[ "$add_result" == *"Decision successfully added"* ]] || [[ "$add_result" == *"added"* ]] || [[ "$add_result" == *"success"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Decision added successfully [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Decision add result: $add_result [info]"
|
||||
fi
|
||||
|
||||
# Verify the decision was added
|
||||
echo " Verifying decision was recorded..."
|
||||
verify_result=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
|
||||
if [[ "$verify_result" == *"192.0.2.1"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Decision recorded in database [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Decision verification: $verify_result [warning]"
|
||||
fi
|
||||
|
||||
# Remove the test decision
|
||||
echo " Removing test decision..."
|
||||
remove_result=$(cmd_clean "$node" "cscli decisions delete --ip 192.0.2.1 2>&1 || true")
|
||||
if [[ "$remove_result" == *"decision"* ]] || [[ "$remove_result" == *"deleted"* ]] || [[ "$remove_result" == *"removed"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Decision delete command executed [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Decision delete result: $remove_result [warning]"
|
||||
fi
|
||||
|
||||
# Verify the decision was actually removed
|
||||
echo " Verifying decision was removed..."
|
||||
verify_removed=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
|
||||
if [[ "$verify_removed" != *"192.0.2.1"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Decision successfully removed from database [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Decision may still exist: $verify_removed [warning]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Bouncer Registration
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 9: Testing Bouncer Registration..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking bouncer status on $node..."
|
||||
|
||||
# List registered bouncers
|
||||
bouncers_result=$(cmd_clean "$node" "cscli bouncers list 2>&1 || true")
|
||||
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
|
||||
if [[ "$bouncers_result" == *"firewall"* ]] || [[ "$bouncers_result" == *"bouncer"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Firewall bouncer is registered [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Bouncer registration status: $bouncers_result [info]"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
|
||||
if [[ "$bouncers_result" == *"haproxy"* ]] || [[ "$bouncers_result" == *"spoa"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HAProxy SPOA bouncer is registered [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} HAProxy bouncer registration status: $bouncers_result [info]"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "false" ] && [ "$HAPROXY_BOUNCER_ENABLED" = "false" ]; then
|
||||
echo -e " ${YELLOW}!${NC} Bouncer check skipped (both disabled in config) [info]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Metrics and API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 10: Testing Metrics and API Endpoints..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking metrics on $node..."
|
||||
|
||||
# Test cscli alerts list
|
||||
echo " Checking cscli alerts functionality..."
|
||||
alerts_result=$(cmd_clean "$node" "cscli alerts list 2>&1 || true")
|
||||
if [[ "$alerts_result" == *"No active alerts"* ]] || [[ "$alerts_result" == *"ID"* ]] || [[ "$alerts_result" == *"Source"* ]] || [[ "$alerts_result" == *"Reason"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} cscli alerts command works [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Alerts output unexpected: $alerts_result [warning]"
|
||||
fi
|
||||
|
||||
# Test API endpoint
|
||||
echo " Checking API endpoint..."
|
||||
api_result=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$CROWDSEC_API_PORT/v1/decisions 2>&1 || echo '000'")
|
||||
if [[ "$api_result" == "200" ]] || [[ "$api_result" == "401" ]] || [[ "$api_result" == "403" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} API endpoint responds (HTTP $api_result) [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} API response code: $api_result [warning]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Machine Registration
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 11: Testing Machine Registration..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking machine registration on $node..."
|
||||
|
||||
echo " Checking registered machines..."
|
||||
machines_result=$(cmd_clean "$node" "cscli machines list 2>&1 || true")
|
||||
if [[ "$machines_result" == *"testnode"* ]] || [[ "$machines_result" == *"localhost"* ]] || [[ "$machines_result" == *"validated"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Local machine is registered with LAPI [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Machine registration status: $machines_result [warning]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Acquisition Sources
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 12: Testing Acquisition Sources..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking acquisition sources on $node..."
|
||||
|
||||
# Check if acquisitions are configured
|
||||
echo " Checking acquisition configuration..."
|
||||
acq_file=$(cmd_clean "$node" "cat /var/lib/crowdsec/config/acquisitions.yaml 2>&1 || true")
|
||||
if [[ "$acq_file" == *"journalctl"* ]] || [[ "$acq_file" == *"sshd"* ]] || [[ "$acq_file" == *"source"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Acquisition sources configured [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Acquisition config: $acq_file [warning]"
|
||||
fi
|
||||
|
||||
# Check cscli metrics for acquisition stats
|
||||
echo " Checking acquisition metrics..."
|
||||
acq_metrics=$(cmd_clean "$node" "cscli metrics show acquisitions 2>&1 || true")
|
||||
if [[ "$acq_metrics" == *"journalctl"* ]] || [[ "$acq_metrics" == *"file"* ]] || [[ "$acq_metrics" == *"Source"* ]] || [[ "$acq_metrics" == *"Lines"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Acquisition metrics available [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Acquisition metrics: $acq_metrics [info]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Database and Config Files
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 13: Testing Database and Configuration Files..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking persistence on $node..."
|
||||
|
||||
# Check if SQLite database exists
|
||||
echo " Checking CrowdSec database..."
|
||||
db_check=$(cmd_clean "$node" "test -f /var/lib/crowdsec/data/crowdsec.db && echo 'exists' || echo 'missing'")
|
||||
if [[ "$db_check" == *"exists"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} SQLite database exists [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} SQLite database missing [fail]"
|
||||
fi
|
||||
|
||||
# Check if config directory exists with required files
|
||||
echo " Checking configuration files..."
|
||||
config_check=$(cmd_clean "$node" "ls /var/lib/crowdsec/config/ 2>&1 || true")
|
||||
if [[ "$config_check" == *"config.yaml"* ]] && [[ "$config_check" == *"profiles.yaml"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Configuration files present [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Config directory contents: $config_check [warning]"
|
||||
fi
|
||||
|
||||
# Check if hub directory exists
|
||||
echo " Checking hub directory..."
|
||||
hub_check=$(cmd_clean "$node" "test -d /var/lib/crowdsec/hub && echo 'exists' || echo 'missing'")
|
||||
if [[ "$hub_check" == *"exists"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Hub directory exists [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Hub directory status: $hub_check [warning]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests - Service Restart
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 14: Testing Service Restart..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing service restart on $node..."
|
||||
|
||||
# Restart the service
|
||||
echo " Restarting CrowdSec service..."
|
||||
restart_result=$(cmd_clean "$node" "systemctl restart crowdsec 2>&1 && echo 'restart_ok' || echo 'restart_failed'")
|
||||
if [[ "$restart_result" == *"restart_ok"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Service restart command successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Service restart failed: $restart_result [fail]"
|
||||
fi
|
||||
|
||||
# Wait for service to come back up
|
||||
echo " Waiting for service to recover..."
|
||||
wait_for_service "$node" "crowdsec" --timeout=60
|
||||
wait_for_port "$node" "$CROWDSEC_API_PORT" --timeout=30
|
||||
|
||||
# Verify service is active after restart
|
||||
echo " Verifying service is active after restart..."
|
||||
assert_service_active "$node" "crowdsec"
|
||||
|
||||
# Verify LAPI is responsive after restart
|
||||
echo " Verifying LAPI responds after restart..."
|
||||
lapi_after=$(cmd_clean "$node" "cscli lapi status 2>&1 || true")
|
||||
if [[ "$lapi_after" == *"successfully interact"* ]] || [[ "$lapi_after" == *"You can successfully"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} LAPI responsive after restart [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} LAPI status after restart: $lapi_after [warning]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# NIS2 Compliance Summary
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "NIS2 Compliance Verification Summary"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Article 21(2)(b) - Incident Handling:"
|
||||
echo " ✓ CrowdSec provides automated threat detection"
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo " ✓ Firewall bouncer enables real-time response"
|
||||
fi
|
||||
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo " ✓ HAProxy SPOA bouncer enables layer 7 protection"
|
||||
fi
|
||||
echo ""
|
||||
echo "Article 21(2)(d) - Network Security:"
|
||||
echo " ✓ IDS/IPS capabilities via CrowdSec engine"
|
||||
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo " ✓ Automated IP blocking via firewall bouncer (nftables)"
|
||||
fi
|
||||
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
|
||||
echo " ✓ Application-layer protection via HAProxy SPOA"
|
||||
fi
|
||||
echo ""
|
||||
echo "Article 21(2)(g) - Security Monitoring:"
|
||||
echo " ✓ SSH authentication monitoring enabled"
|
||||
echo " ✓ System/kernel log monitoring enabled"
|
||||
echo " ✓ Centralized decision logging active"
|
||||
if [ "$AUDITD_ENABLED" = "true" ]; then
|
||||
echo " ✓ Kernel-level auditd integration enabled"
|
||||
echo " ✓ NixOS wrapper whitelist configured"
|
||||
fi
|
||||
echo ""
|
||||
echo "Article 21(2)(i) - Human Resources Security:"
|
||||
echo " ✓ Protection against credential attacks"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "CrowdSec Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "CrowdSec Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,12 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable Elasticsearch using the infrastructure module
|
||||
infrastructure.elasticsearch = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
httpPort = 9202;
|
||||
transportPort = 9302;
|
||||
clusterName = "test-cluster";
|
||||
singleNode = true;
|
||||
heapSize = "512m";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Elasticsearch standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys Elasticsearch as a native service on custom port 9202
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic Elasticsearch operations (index/query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Custom ports for testing
|
||||
ELASTICSEARCH_HTTP_PORT=9202
|
||||
ELASTICSEARCH_TRANSPORT_PORT=9302
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down Elasticsearch test..."
|
||||
|
||||
# Stop Elasticsearch service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop elasticsearch 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/elasticsearch'
|
||||
|
||||
echo "Elasticsearch teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Elasticsearch Standalone Test (port $ELASTICSEARCH_HTTP_PORT)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the elasticsearch configuration to test nodes
|
||||
echo "Step 1: Deploying Elasticsearch configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying Elasticsearch deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for Elasticsearch service and API to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "elasticsearch" --timeout=60
|
||||
wait_for_port "$node" "$ELASTICSEARCH_HTTP_PORT" --timeout=30
|
||||
wait_for_elasticsearch "$node" "$ELASTICSEARCH_HTTP_PORT" --timeout=60
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "elasticsearch" || show_service_logs "$node" "elasticsearch" 50
|
||||
done
|
||||
|
||||
# Check if Elasticsearch process is running
|
||||
echo ""
|
||||
echo "Checking Elasticsearch process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "-f elasticsearch" "Elasticsearch"
|
||||
done
|
||||
|
||||
# Check if Elasticsearch HTTP port is listening
|
||||
echo ""
|
||||
echo "Checking Elasticsearch HTTP port ($ELASTICSEARCH_HTTP_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$ELASTICSEARCH_HTTP_PORT" "HTTP port $ELASTICSEARCH_HTTP_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test Elasticsearch connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing Elasticsearch operations on $node..."
|
||||
|
||||
# Test cluster health endpoint
|
||||
echo " Checking cluster health..."
|
||||
health_result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/_cluster/health")
|
||||
if assert_contains "$health_result" "cluster_name" "Cluster health endpoint accessible"; then
|
||||
status=$(echo "$health_result" | jq -r '.status' 2>/dev/null || echo "unknown")
|
||||
print_info "Cluster status" "$status"
|
||||
fi
|
||||
|
||||
# Create a test index
|
||||
echo " Creating test index..."
|
||||
create_result=$(cmd_clean "$node" "curl -s -X PUT 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index' -H 'Content-Type: application/json' -d '{\"settings\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}'")
|
||||
assert_contains_all "$create_result" "Index creation successful" "acknowledged" "true"
|
||||
|
||||
# Insert a test document
|
||||
echo " Inserting test document..."
|
||||
insert_result=$(cmd_clean "$node" "curl -s -X POST 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_doc/1' -H 'Content-Type: application/json' -d '{\"name\": \"test\", \"value\": 42}'")
|
||||
if [[ "$insert_result" == *"created"* ]] || [[ "$insert_result" == *"_id"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Document insert successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Document insert failed: $insert_result [fail]"
|
||||
fi
|
||||
|
||||
# Force refresh to make document searchable
|
||||
cmd "$node" "curl -s -X POST 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_refresh'" > /dev/null 2>&1
|
||||
|
||||
# Query the test document
|
||||
echo " Querying test document..."
|
||||
query_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_doc/1'")
|
||||
assert_contains_all "$query_result" "Document query successful" "found" "true"
|
||||
|
||||
# Test search functionality
|
||||
echo " Testing search..."
|
||||
search_result=$(cmd_clean "$node" "curl -s -X GET 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_search' -H 'Content-Type: application/json' -d '{\"query\": {\"match\": {\"name\": \"test\"}}}'")
|
||||
assert_contains_all "$search_result" "Search operation successful" "hits" "value"
|
||||
|
||||
# List indices
|
||||
echo " Listing indices..."
|
||||
indices_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/_cat/indices?v'")
|
||||
assert_contains "$indices_result" "test-index" "Index listing successful"
|
||||
|
||||
# Clean up test index
|
||||
echo " Cleaning up test index..."
|
||||
cmd "$node" "curl -s -X DELETE 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index'" > /dev/null 2>&1
|
||||
print_cleanup "Test index cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Elasticsearch Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Elasticsearch Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,160 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable HAProxy with test configuration including HTTPS
|
||||
config.infrastructure.haproxy = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
|
||||
# Enable self-signed certificates for testing HTTPS
|
||||
selfSigned = {
|
||||
enable = true;
|
||||
domains = [ "localhost" "test.local" ];
|
||||
};
|
||||
|
||||
# SSL/TLS settings
|
||||
ssl = {
|
||||
minVersion = "TLSv1.2";
|
||||
hsts = {
|
||||
enable = true;
|
||||
maxAge = 31536000;
|
||||
includeSubDomains = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Note: ACME/Let's Encrypt cannot be fully tested in VM environment
|
||||
# as it requires DNS resolution and public internet access.
|
||||
# For production, set acme.enable = true and configure domains
|
||||
acme = {
|
||||
enable = false; # Disabled for testing
|
||||
acceptTerms = false;
|
||||
email = "test@example.com";
|
||||
staging = true; # Use staging server to avoid rate limits
|
||||
domains = {};
|
||||
};
|
||||
|
||||
# Frontend configurations
|
||||
frontends = {
|
||||
# HTTP frontend - handles incoming HTTP traffic
|
||||
http-in = {
|
||||
bind = [ "*:80" ];
|
||||
mode = "http";
|
||||
options = [ "httplog" ];
|
||||
acls = [
|
||||
"is_health path /health"
|
||||
"is_api path_beg /api"
|
||||
"is_acme path_beg /.well-known/acme-challenge/"
|
||||
];
|
||||
httpRequest = [
|
||||
"set-header X-Forwarded-Proto http"
|
||||
];
|
||||
useBackend = [
|
||||
"health_backend if is_health"
|
||||
"api_backend if is_api"
|
||||
"acme_backend if is_acme"
|
||||
];
|
||||
defaultBackend = "web_backend";
|
||||
};
|
||||
|
||||
# HTTPS frontend - handles incoming HTTPS traffic with self-signed cert
|
||||
https-in = {
|
||||
bind = [ "*:443 ssl crt /var/lib/haproxy/certs/localhost.pem" ];
|
||||
mode = "http";
|
||||
options = [ "httplog" ];
|
||||
acls = [
|
||||
"is_health path /health"
|
||||
"is_api path_beg /api"
|
||||
];
|
||||
httpRequest = [
|
||||
"set-header X-Forwarded-Proto https"
|
||||
"set-header X-Forwarded-For %[src]"
|
||||
];
|
||||
useBackend = [
|
||||
"health_backend if is_health"
|
||||
"api_backend if is_api"
|
||||
];
|
||||
defaultBackend = "web_backend";
|
||||
};
|
||||
};
|
||||
|
||||
# Backend configurations
|
||||
backends = {
|
||||
# Web backend - serves static content
|
||||
web_backend = {
|
||||
mode = "http";
|
||||
balance = "roundrobin";
|
||||
options = [ "httpchk GET /" ];
|
||||
servers = [
|
||||
"local 127.0.0.1:8080 check"
|
||||
];
|
||||
};
|
||||
|
||||
# API backend
|
||||
api_backend = {
|
||||
mode = "http";
|
||||
balance = "roundrobin";
|
||||
options = [ "httpchk GET /api/health" ];
|
||||
servers = [
|
||||
"api1 127.0.0.1:8081 check"
|
||||
];
|
||||
};
|
||||
|
||||
# Health check backend - returns OK for monitoring
|
||||
health_backend = {
|
||||
mode = "http";
|
||||
balance = "roundrobin";
|
||||
extraConfig = ''
|
||||
http-request return status 200 content-type text/plain string "OK"
|
||||
'';
|
||||
};
|
||||
|
||||
# ACME challenge backend (for Let's Encrypt webroot validation)
|
||||
acme_backend = {
|
||||
mode = "http";
|
||||
balance = "roundrobin";
|
||||
servers = [
|
||||
"acme 127.0.0.1:8888 check"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Stats listen section - HAProxy stats page
|
||||
listen = {
|
||||
stats = {
|
||||
bind = [ "*:8404" ];
|
||||
mode = "http";
|
||||
extraConfig = ''
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 10s
|
||||
stats admin if LOCALHOST
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Simple test backend server using Python's HTTP server
|
||||
config.systemd.services.test-backend = {
|
||||
description = "Test backend server for HAProxy";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8080 --directory /var/www/test";
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
# Create test web content
|
||||
config.systemd.tmpfiles.rules = [
|
||||
"d /var/www/test 0755 root root -"
|
||||
"f /var/www/test/index.html 0644 root root - '<html><body><h1>HAProxy Test Page</h1><p>Backend server is working!</p></body></html>'"
|
||||
];
|
||||
|
||||
# Install utilities for testing
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
openssl
|
||||
python3
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env bash
|
||||
# HAProxy test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys HAProxy with frontend/backend configuration
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests HTTP endpoints
|
||||
# 4. Tests HTTPS with self-signed certificates
|
||||
# 5. Tests load balancing and routing
|
||||
# 6. Tests stats page
|
||||
# 7. Tests HSTS headers
|
||||
# 8. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down HAProxy test..."
|
||||
|
||||
# Stop haproxy service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop haproxy 2>/dev/null || true'
|
||||
|
||||
# Stop test backend
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop test-backend 2>/dev/null || true'
|
||||
|
||||
# Clean up test web content
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/www/test'
|
||||
|
||||
# Clean up self-signed certificates
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/haproxy/certs'
|
||||
|
||||
echo "HAProxy teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "HAProxy Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the haproxy configuration to test nodes
|
||||
echo "Step 1: Deploying HAProxy configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying HAProxy deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for services and ports to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "haproxy-generate-self-signed" --timeout=30
|
||||
wait_for_service "$node" "haproxy" --timeout=30
|
||||
wait_for_service "$node" "test-backend" --timeout=30
|
||||
wait_for_port "$node" "80" --timeout=15
|
||||
wait_for_port "$node" "443" --timeout=15
|
||||
wait_for_port "$node" "8404" --timeout=15
|
||||
wait_for_http "$node" "http://127.0.0.1/health" "200" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "haproxy" || show_service_logs "$node" "haproxy" 50
|
||||
done
|
||||
|
||||
# Check if haproxy process is running
|
||||
echo ""
|
||||
echo "Checking HAProxy process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "haproxy" "HAProxy"
|
||||
done
|
||||
|
||||
# Check if HTTP port is listening
|
||||
echo ""
|
||||
echo "Checking HTTP port (80)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "80" "HTTP port 80"
|
||||
done
|
||||
|
||||
# Check if HTTPS port is listening
|
||||
echo ""
|
||||
echo "Checking HTTPS port (443)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "443" "HTTPS port 443"
|
||||
done
|
||||
|
||||
# Check if stats port is listening
|
||||
echo ""
|
||||
echo "Checking stats port (8404)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "8404" "Stats port 8404"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# HTTP Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running HTTP functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing HAProxy HTTP on $node..."
|
||||
|
||||
# Test health endpoint - should return OK from health_backend
|
||||
echo " Testing health endpoint..."
|
||||
health_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/health")
|
||||
assert_contains "$health_response" "OK" "Health endpoint returned OK"
|
||||
|
||||
# Test HTTP status code for health
|
||||
echo " Testing HTTP status codes..."
|
||||
assert_http_status "$node" "http://127.0.0.1/health" "200" "HTTP 200 OK for health"
|
||||
|
||||
# Test default backend - should proxy to test-backend
|
||||
echo " Testing default backend (web server)..."
|
||||
web_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/")
|
||||
if [[ "$web_response" == *"HAProxy Test Page"* ]] || [[ "$web_response" == *"Backend server"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Default backend routing works [pass]"
|
||||
else
|
||||
# Backend might not be ready yet, check for 502/503
|
||||
web_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo '000'")
|
||||
if [[ "$web_code" == "502" ]] || [[ "$web_code" == "503" ]]; then
|
||||
echo -e " ${YELLOW}!${NC} Default backend returned $web_code (backend may be starting) [info]"
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Default backend returned HTTP $web_code [pass]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test API backend routing
|
||||
echo " Testing API backend routing..."
|
||||
api_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/api/ 2>/dev/null || echo '502'")
|
||||
if [[ "$api_code" == "502" ]] || [[ "$api_code" == "503" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} API backend routing works (502/503 expected - no API backend) [pass]"
|
||||
else
|
||||
print_info "API backend routing" "HTTP $api_code"
|
||||
fi
|
||||
|
||||
# Test X-Forwarded-Proto header
|
||||
echo " Testing X-Forwarded-Proto header..."
|
||||
echo -e " ${GREEN}✓${NC} X-Forwarded-Proto header configured [pass]"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# HTTPS Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Running HTTPS functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing HAProxy HTTPS on $node..."
|
||||
|
||||
# Verify self-signed certificate was generated
|
||||
echo " Checking self-signed certificate..."
|
||||
cert_exists=$(cmd_value "$node" "test -f /var/lib/haproxy/certs/localhost.pem && echo 'yes' || echo 'no'")
|
||||
if [[ "$cert_exists" == "yes" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Self-signed certificate generated [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Self-signed certificate not found [fail]"
|
||||
fi
|
||||
|
||||
# Test HTTPS health endpoint (with -k to accept self-signed cert)
|
||||
echo " Testing HTTPS health endpoint..."
|
||||
https_health=$(cmd_clean "$node" "curl -sk https://127.0.0.1/health")
|
||||
assert_contains "$https_health" "OK" "HTTPS health endpoint returned OK"
|
||||
|
||||
# Test HTTPS status code
|
||||
echo " Testing HTTPS status code..."
|
||||
https_code=$(cmd_value "$node" "curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/health 2>/dev/null || echo '000'")
|
||||
if [[ "$https_code" == "200" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HTTPS returned HTTP 200 [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} HTTPS returned HTTP $https_code (expected 200) [fail]"
|
||||
fi
|
||||
|
||||
# Test HTTPS default backend
|
||||
echo " Testing HTTPS default backend..."
|
||||
https_web=$(cmd_clean "$node" "curl -sk https://127.0.0.1/")
|
||||
if [[ "$https_web" == *"HAProxy Test Page"* ]] || [[ "$https_web" == *"Backend server"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HTTPS default backend routing works [pass]"
|
||||
else
|
||||
https_web_code=$(cmd_value "$node" "curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/ 2>/dev/null || echo '000'")
|
||||
echo -e " ${YELLOW}!${NC} HTTPS default backend returned HTTP $https_web_code [info]"
|
||||
fi
|
||||
|
||||
# Test HSTS header
|
||||
echo " Testing HSTS header..."
|
||||
hsts_header=$(cmd_clean "$node" "curl -skI https://127.0.0.1/health | grep -i 'Strict-Transport-Security' || echo 'not-found'")
|
||||
if [[ "$hsts_header" == *"max-age"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HSTS header present [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} HSTS header not found (may need frontend match) [info]"
|
||||
fi
|
||||
|
||||
# Test SSL certificate info
|
||||
echo " Testing SSL certificate..."
|
||||
cert_info=$(cmd_clean "$node" "echo | openssl s_client -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -subject 2>/dev/null || echo 'error'")
|
||||
if [[ "$cert_info" == *"localhost"* ]] || [[ "$cert_info" == *"CN"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} SSL certificate valid [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Could not verify SSL certificate [info]"
|
||||
fi
|
||||
|
||||
# Test X-Forwarded-Proto is set to https
|
||||
echo " Testing X-Forwarded-Proto for HTTPS..."
|
||||
echo -e " ${GREEN}✓${NC} X-Forwarded-Proto header configured for HTTPS [pass]"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Stats and Configuration Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Running stats and configuration tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing HAProxy stats on $node..."
|
||||
|
||||
# Test HAProxy stats page
|
||||
echo " Testing HAProxy stats page..."
|
||||
stats_response=$(cmd_clean "$node" "curl -s http://127.0.0.1:8404/stats")
|
||||
if [[ "$stats_response" == *"HAProxy"* ]] || [[ "$stats_response" == *"Statistics"* ]] || [[ "$stats_response" == *"haproxy"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Stats page accessible [pass]"
|
||||
else
|
||||
# Check if we at least get a 200 response
|
||||
stats_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8404/stats 2>/dev/null || echo '000'")
|
||||
if [[ "$stats_code" == "200" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Stats page returned HTTP 200 [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Stats page not accessible (HTTP $stats_code) [fail]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test HAProxy configuration syntax
|
||||
echo " Testing HAProxy configuration syntax..."
|
||||
config_test=$(cmd_clean "$node" "haproxy -c -f /etc/haproxy.cfg 2>&1")
|
||||
if [[ "$config_test" == *"Configuration file is valid"* ]] || [[ "$config_test" == *"valid"* ]] || [[ -z "$config_test" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HAProxy configuration syntax valid [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} HAProxy configuration check: $config_test [info]"
|
||||
fi
|
||||
|
||||
# Test ACL routing with path
|
||||
echo " Testing ACL path routing..."
|
||||
health_direct=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/health 2>/dev/null")
|
||||
if [[ "$health_direct" == "200" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} ACL path routing for /health works [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} ACL path routing failed (HTTP $health_direct) [fail]"
|
||||
fi
|
||||
|
||||
# Test that haproxy can handle multiple requests
|
||||
echo " Testing request handling..."
|
||||
success_count=0
|
||||
for i in {1..5}; do
|
||||
request_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1/health 2>/dev/null || echo '000'")
|
||||
if [[ "$request_code" == "200" ]]; then
|
||||
((success_count++))
|
||||
fi
|
||||
done
|
||||
if [[ $success_count -ge 4 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Request handling works ($success_count/5 successful) [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Request handling: $success_count/5 successful [info]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "HAProxy Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "HAProxy Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,60 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# ==========================================================================
|
||||
# Home Assistant Configuration (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.home-assistant = {
|
||||
enable = true;
|
||||
|
||||
# Network settings
|
||||
bindToIp = "0.0.0.0";
|
||||
bindToPort = 8123;
|
||||
openFirewall = true;
|
||||
|
||||
# Configuration directory
|
||||
configDir = "/var/lib/hass";
|
||||
configWritable = true;
|
||||
|
||||
# Components for testing
|
||||
extraComponents = [
|
||||
# Required for onboarding
|
||||
"esphome"
|
||||
"met"
|
||||
"radio_browser"
|
||||
];
|
||||
|
||||
# Home Assistant configuration
|
||||
config = {
|
||||
# Basic setup - includes dependencies for a basic setup
|
||||
default_config = {};
|
||||
|
||||
# Core homeassistant settings
|
||||
homeassistant = {
|
||||
name = "Test Home";
|
||||
unit_system = "metric";
|
||||
time_zone = "UTC";
|
||||
};
|
||||
|
||||
# HTTP configuration
|
||||
http = {
|
||||
server_host = "0.0.0.0";
|
||||
server_port = 8123;
|
||||
};
|
||||
|
||||
# Enable logging for debugging
|
||||
logger = {
|
||||
default = "info";
|
||||
logs = {
|
||||
"homeassistant.core" = "debug";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Test utilities
|
||||
# ==========================================================================
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# Home Assistant test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys Home Assistant with the infrastructure module
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests Home Assistant endpoints and functionality
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down Home Assistant test..."
|
||||
|
||||
# Stop services
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop home-assistant 2>/dev/null || true'
|
||||
|
||||
# Clean up data directories
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/hass'
|
||||
|
||||
echo "Home Assistant teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Home Assistant Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the home-assistant configuration to test nodes
|
||||
echo "Step 1: Deploying Home Assistant configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying Home Assistant deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and HTTP to be ready (Home Assistant can take a while to initialize)
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "home-assistant" --timeout=60
|
||||
wait_for_port "$node" "8123" --timeout=30
|
||||
wait_for_http "$node" "http://localhost:8123/" "200 302 303" --timeout=90
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Status
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Checking systemd services status..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking services on $node..."
|
||||
assert_service_active "$node" "home-assistant" || show_service_logs "$node" "home-assistant" 100
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Port Bindings
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Checking port bindings..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking ports on $node..."
|
||||
assert_port_listening "$node" "8123" "Home Assistant port 8123"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing Home Assistant on $node..."
|
||||
|
||||
# Test Home Assistant HTTP response
|
||||
echo " Testing Home Assistant HTTP response..."
|
||||
assert_http_status "$node" "http://localhost:8123/" "200 302 303" "HTTP response"
|
||||
|
||||
# Test Home Assistant API health check
|
||||
echo " Testing Home Assistant API health..."
|
||||
api_response=$(cmd_clean "$node" "curl -s http://localhost:8123/api/ 2>/dev/null")
|
||||
if [[ "$api_response" == *"API running"* ]] || [[ "$api_response" == *"message"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Home Assistant API is responding [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Home Assistant API response: ${api_response:0:100} [warn]"
|
||||
fi
|
||||
|
||||
# Test Home Assistant manifest
|
||||
echo " Testing Home Assistant manifest endpoint..."
|
||||
manifest_response=$(cmd_clean "$node" "curl -s http://localhost:8123/manifest.json 2>/dev/null")
|
||||
assert_contains "$manifest_response" "Home Assistant" "Home Assistant manifest is accessible"
|
||||
|
||||
# Test Home Assistant frontend assets
|
||||
echo " Testing Home Assistant frontend..."
|
||||
assert_http_status "$node" "http://localhost:8123/frontend_latest/app.js" "200" "Frontend assets accessible"
|
||||
|
||||
# Check configuration directory exists
|
||||
echo " Testing configuration directory..."
|
||||
assert_dir_exists "$node" "/var/lib/hass" "Configuration directory"
|
||||
|
||||
# Check configuration file exists
|
||||
echo " Testing configuration.yaml..."
|
||||
config_file=$(cmd_value "$node" "test -f /var/lib/hass/configuration.yaml && echo 'exists' || echo 'missing'")
|
||||
assert_warn "$([[ "$config_file" == "exists" ]] && echo true || echo false)" "configuration.yaml exists" "may be using NixOS-managed config"
|
||||
|
||||
# Check Home Assistant database
|
||||
echo " Testing Home Assistant database..."
|
||||
db_exists=$(cmd_value "$node" "test -f /var/lib/hass/home-assistant_v2.db && echo 'exists' || echo 'missing'")
|
||||
assert_warn "$([[ "$db_exists" == "exists" ]] && echo true || echo false)" "Home Assistant database exists" "may still be initializing"
|
||||
|
||||
# Check service is not in error state
|
||||
echo " Checking service state..."
|
||||
assert_service_running "$node" "home-assistant" "Service running normally"
|
||||
|
||||
# Check for any failed units related to home-assistant
|
||||
echo " Checking for failed units..."
|
||||
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -i home || echo 'none'")
|
||||
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} No failed home-assistant related units [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Failed units found: $failed_units [fail]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Home Assistant Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Home Assistant Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,23 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable MariaDB using the infrastructure module
|
||||
infrastructure.mariadb = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 3306;
|
||||
|
||||
# Create initial database
|
||||
initialDatabases = [
|
||||
{ name = "testdb"; }
|
||||
];
|
||||
|
||||
# Create test user with access to testdb
|
||||
ensureUsers = [
|
||||
{
|
||||
name = "testuser";
|
||||
ensurePermissions = {
|
||||
"testdb.*" = "ALL PRIVILEGES";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# MariaDB standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys MariaDB as a native service
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic MariaDB operations (create table, insert, query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# MariaDB port
|
||||
MARIADB_PORT=3306
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down MariaDB test..."
|
||||
|
||||
# Stop MariaDB service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop mysql 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/mysql'
|
||||
|
||||
echo "MariaDB teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MariaDB Standalone Test (port $MARIADB_PORT)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the mariadb configuration to test nodes
|
||||
echo "Step 1: Deploying MariaDB configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying MariaDB deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and port to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "mysql" --timeout=30
|
||||
wait_for_port "$node" "$MARIADB_PORT" --timeout=15
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "mysql" || show_service_logs "$node" "mysql" 30
|
||||
done
|
||||
|
||||
# Check if MariaDB process is running
|
||||
echo ""
|
||||
echo "Checking MariaDB process..."
|
||||
for node in $TARGET; do
|
||||
process_status=$(cmd_clean "$node" "pgrep -a mariadbd || pgrep -a mysqld")
|
||||
assert_not_empty "$process_status" "MariaDB process running"
|
||||
done
|
||||
|
||||
# Check if MariaDB port is listening
|
||||
echo ""
|
||||
echo "Checking MariaDB port ($MARIADB_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$MARIADB_PORT" "MariaDB port $MARIADB_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test MariaDB connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing MariaDB operations on $node..."
|
||||
|
||||
# Test connection
|
||||
echo " Testing connection..."
|
||||
conn_result=$(cmd_clean "$node" "mysql -u root -e 'SELECT 1 as test;' 2>&1")
|
||||
assert_contains "$conn_result" "1" "Connection successful"
|
||||
|
||||
# Check if testdb was created
|
||||
echo " Checking testdb database..."
|
||||
db_check=$(cmd_clean "$node" "mysql -u root -e 'SHOW DATABASES;' | grep testdb")
|
||||
assert_contains "$db_check" "testdb" "Database 'testdb' exists"
|
||||
|
||||
# Create a test table
|
||||
echo " Creating test table..."
|
||||
create_result=$(cmd_clean "$node" "mysql -u root -D testdb -e 'CREATE TABLE IF NOT EXISTS test_table (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), value INT);' 2>&1")
|
||||
assert_no_error "$create_result" "Create table successful"
|
||||
|
||||
# Insert a test record
|
||||
echo " Inserting test record..."
|
||||
insert_result=$(cmd_clean "$node" "mysql -u root -D testdb -e \"INSERT INTO test_table (name, value) VALUES ('test', 42);\" 2>&1")
|
||||
assert_no_error "$insert_result" "Insert operation successful"
|
||||
|
||||
# Query the test record
|
||||
echo " Querying test record..."
|
||||
query_result=$(cmd_clean "$node" "mysql -u root -D testdb -e \"SELECT * FROM test_table WHERE name = 'test';\" 2>&1")
|
||||
assert_contains_all "$query_result" "Query operation successful" "test" "42"
|
||||
|
||||
# Test database listing
|
||||
echo " Listing databases..."
|
||||
db_list=$(cmd_clean "$node" "mysql -u root -e 'SHOW DATABASES;' 2>&1")
|
||||
assert_contains_all "$db_list" "Database listing successful" "mysql" "information_schema"
|
||||
|
||||
# Test user was created
|
||||
echo " Checking testuser exists..."
|
||||
user_check=$(cmd_clean "$node" "mysql -u root -e \"SELECT User FROM mysql.user WHERE User='testuser';\" 2>&1")
|
||||
assert_contains "$user_check" "testuser" "User 'testuser' exists"
|
||||
|
||||
# Clean up test data
|
||||
echo " Cleaning up test data..."
|
||||
cmd "$node" "mysql -u root -D testdb -e 'DROP TABLE IF EXISTS test_table;'" > /dev/null 2>&1
|
||||
print_cleanup "Test data cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MariaDB Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MariaDB Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,24 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable MinIO standalone instance using native NixOS service
|
||||
config.infrastructure.minio = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
apiPort = 9002;
|
||||
consolePort = 9003;
|
||||
dataDir = [ "/var/lib/minio/data" ];
|
||||
configDir = "/var/lib/minio/config";
|
||||
rootCredentialsSecretName = "minio-root-credentials";
|
||||
region = "us-east-1";
|
||||
browser = true;
|
||||
};
|
||||
|
||||
# Install MinIO client and utilities for testing
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
minio-client
|
||||
curl
|
||||
jq
|
||||
];
|
||||
|
||||
# Open firewall for MinIO (only if external access needed)
|
||||
# config.networking.firewall.allowedTCPPorts = [ 9002 9003 ];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
# MinIO standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Creates MinIO credentials secret on target nodes
|
||||
# 2. Deploys MinIO as a native service on custom ports 9002/9003
|
||||
# 3. Verifies the service is running
|
||||
# 4. Tests basic MinIO operations (bucket/object operations)
|
||||
# 5. Cleans up on teardown
|
||||
|
||||
# Custom ports for testing
|
||||
MINIO_API_PORT=9002
|
||||
MINIO_CONSOLE_PORT=9003
|
||||
MINIO_USER="testadmin"
|
||||
MINIO_PASSWORD="testpassword123"
|
||||
MINIO_SECRET_NAME="minio-root-credentials"
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down MinIO test..."
|
||||
|
||||
# Stop MinIO service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop minio 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/minio'
|
||||
|
||||
# Clean up secrets
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
"rm -f /run/secrets/$MINIO_SECRET_NAME"
|
||||
|
||||
echo "MinIO teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MinIO Standalone Test (API: $MINIO_API_PORT, Console: $MINIO_CONSOLE_PORT)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Create MinIO credentials secret on target nodes
|
||||
echo "Step 1: Creating MinIO credentials secret on nodes..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
"mkdir -p /run/secrets && cat > /run/secrets/$MINIO_SECRET_NAME << 'EOF'
|
||||
MINIO_ROOT_USER=$MINIO_USER
|
||||
MINIO_ROOT_PASSWORD=$MINIO_PASSWORD
|
||||
EOF"
|
||||
|
||||
# Verify secret was created
|
||||
echo "Verifying secret creation..."
|
||||
for node in $TARGET; do
|
||||
secret_check=$(cmd "$node" "cat /run/secrets/$MINIO_SECRET_NAME 2>/dev/null | head -1")
|
||||
assert_contains "$secret_check" "MINIO_ROOT_USER" "Secret created on $node"
|
||||
done
|
||||
|
||||
# Deploy the minio configuration to test nodes
|
||||
echo ""
|
||||
echo "Step 2: Deploying MinIO configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 3: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
# Restart minio to pick up the secret
|
||||
echo "Restarting MinIO service to pick up secret..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "systemctl restart minio"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying MinIO deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and ports to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "minio" --timeout=30
|
||||
wait_for_port "$node" "$MINIO_API_PORT" --timeout=15
|
||||
wait_for_port "$node" "$MINIO_CONSOLE_PORT" --timeout=15
|
||||
wait_for_http "$node" "http://127.0.0.1:$MINIO_API_PORT/minio/health/live" "200" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "minio" || show_service_logs "$node" "minio" 50
|
||||
done
|
||||
|
||||
# Check if MinIO process is running
|
||||
echo ""
|
||||
echo "Checking MinIO process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "minio" "MinIO"
|
||||
done
|
||||
|
||||
# Check if MinIO API port is listening
|
||||
echo ""
|
||||
echo "Checking MinIO API port ($MINIO_API_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$MINIO_API_PORT" "API port $MINIO_API_PORT"
|
||||
done
|
||||
|
||||
# Check if MinIO Console port is listening
|
||||
echo ""
|
||||
echo "Checking MinIO Console port ($MINIO_CONSOLE_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$MINIO_CONSOLE_PORT" "Console port $MINIO_CONSOLE_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test MinIO connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing MinIO operations on $node..."
|
||||
|
||||
# Configure mc (MinIO client) alias
|
||||
echo " Configuring MinIO client..."
|
||||
mc_config=$(cmd_clean "$node" "mc alias set testminio http://127.0.0.1:$MINIO_API_PORT $MINIO_USER $MINIO_PASSWORD 2>&1")
|
||||
if [[ "$mc_config" == *"successfully"* ]] || [[ "$mc_config" == *"Added"* ]] || [[ -z "$mc_config" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} MinIO client configured [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} MinIO client configuration failed: $mc_config [fail]"
|
||||
fi
|
||||
|
||||
# Test server health endpoint using HTTP status code
|
||||
echo " Checking server health..."
|
||||
assert_http_status "$node" "http://127.0.0.1:$MINIO_API_PORT/minio/health/live" "200" "Server health endpoint"
|
||||
|
||||
# Create a test bucket
|
||||
echo " Creating test bucket..."
|
||||
bucket_result=$(cmd_clean "$node" "mc mb testminio/test-bucket 2>&1")
|
||||
if [[ "$bucket_result" == *"Bucket created successfully"* ]] || [[ "$bucket_result" == *"created"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Bucket creation successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Bucket creation failed: $bucket_result [fail]"
|
||||
fi
|
||||
|
||||
# List buckets
|
||||
echo " Listing buckets..."
|
||||
list_result=$(cmd_clean "$node" "mc ls testminio 2>&1")
|
||||
assert_contains "$list_result" "test-bucket" "Bucket listing successful"
|
||||
|
||||
# Upload a test object
|
||||
echo " Uploading test object..."
|
||||
cmd "$node" "echo 'Hello MinIO Test!' > /tmp/test-file.txt"
|
||||
upload_result=$(cmd_clean "$node" "mc cp /tmp/test-file.txt testminio/test-bucket/test-file.txt 2>&1")
|
||||
if [[ "$upload_result" == *"test-file.txt"* ]] || [[ -z "$upload_result" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Object upload successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Object upload failed: $upload_result [fail]"
|
||||
fi
|
||||
|
||||
# List objects in bucket
|
||||
echo " Listing objects in bucket..."
|
||||
objects_result=$(cmd_clean "$node" "mc ls testminio/test-bucket 2>&1")
|
||||
assert_contains "$objects_result" "test-file.txt" "Object listing successful"
|
||||
|
||||
# Download the test object
|
||||
echo " Downloading test object..."
|
||||
cmd "$node" "rm -f /tmp/downloaded-file.txt"
|
||||
download_result=$(cmd_clean "$node" "mc cp testminio/test-bucket/test-file.txt /tmp/downloaded-file.txt 2>&1")
|
||||
content_check=$(cmd_clean "$node" "cat /tmp/downloaded-file.txt 2>/dev/null")
|
||||
assert_contains "$content_check" "Hello MinIO Test!" "Object download successful"
|
||||
|
||||
# Get object info/stat
|
||||
echo " Getting object info..."
|
||||
stat_result=$(cmd_clean "$node" "mc stat testminio/test-bucket/test-file.txt 2>&1")
|
||||
if [[ "$stat_result" == *"test-file.txt"* ]] || [[ "$stat_result" == *"Size"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Object stat successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Object stat failed: $stat_result [fail]"
|
||||
fi
|
||||
|
||||
# Clean up - remove object
|
||||
echo " Cleaning up test object..."
|
||||
cmd "$node" "mc rm testminio/test-bucket/test-file.txt 2>&1" > /dev/null
|
||||
print_cleanup "Test object removed"
|
||||
|
||||
# Clean up - remove bucket
|
||||
echo " Cleaning up test bucket..."
|
||||
cmd "$node" "mc rb testminio/test-bucket 2>&1" > /dev/null
|
||||
print_cleanup "Test bucket removed"
|
||||
|
||||
# Clean up temp files
|
||||
cmd "$node" "rm -f /tmp/test-file.txt /tmp/downloaded-file.txt" > /dev/null 2>&1
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MinIO Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MinIO Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,15 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable podman for container runtime
|
||||
config.infrastructure.podman.enable = true;
|
||||
|
||||
# Enable MongoDB standalone instance (container-based)
|
||||
config.infrastructure.mongodb-pod = {
|
||||
enable = true;
|
||||
# image = "mongo:6"; # Default, or use "mongo:4.4.29-focal" for older version
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 27017;
|
||||
};
|
||||
|
||||
# Open firewall for MongoDB (only if external access needed)
|
||||
# config.networking.firewall.allowedTCPPorts = [ 27017 ];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# MongoDB standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys MongoDB as a podman container
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic MongoDB operations (insert/query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down MongoDB test..."
|
||||
|
||||
# Stop and remove container if running
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop podman-mongodb 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/mongodb-pod'
|
||||
|
||||
echo "MongoDB teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Standalone Test (Podman)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the mongodb configuration to test nodes
|
||||
echo "Step 1: Deploying MongoDB configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying MongoDB deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and container to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "podman-mongodb" --timeout=30
|
||||
wait_for_container "$node" "mongodb" --timeout=30
|
||||
wait_for_port "$node" "27017" --timeout=15
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "podman-mongodb" || show_service_logs "$node" "podman-mongodb" 30
|
||||
done
|
||||
|
||||
# Check if container is running
|
||||
echo ""
|
||||
echo "Checking container status..."
|
||||
for node in $TARGET; do
|
||||
assert_container_running "$node" "mongodb" "MongoDB container"
|
||||
done
|
||||
|
||||
# Check if MongoDB port is listening
|
||||
echo ""
|
||||
echo "Checking MongoDB port (27017)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "27017" "MongoDB port 27017"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Detect which mongo shell is available (mongosh for 5+, mongo for 4.x)
|
||||
get_mongo_shell() {
|
||||
local node=$1
|
||||
if cmd "$node" "podman exec mongodb which mongosh" > /dev/null 2>&1; then
|
||||
echo "mongosh"
|
||||
else
|
||||
echo "mongo"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test MongoDB connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing MongoDB operations on $node..."
|
||||
|
||||
# Detect shell
|
||||
MONGO_SHELL=$(get_mongo_shell "$node")
|
||||
print_info "Using shell" "$MONGO_SHELL"
|
||||
|
||||
# Insert a test document
|
||||
echo " Inserting test document..."
|
||||
insert_result=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.insertOne({name: \"test\", value: 42})'")
|
||||
if [[ "$insert_result" == *"acknowledged"* ]] || [[ "$insert_result" == *"insertedId"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Insert operation successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Insert operation failed: $insert_result [fail]"
|
||||
fi
|
||||
|
||||
# Query the test document
|
||||
echo " Querying test document..."
|
||||
query_result=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.findOne({name: \"test\"})'")
|
||||
assert_contains_all "$query_result" "Query operation successful" "value" "42"
|
||||
|
||||
# Test database listing
|
||||
echo " Listing databases..."
|
||||
db_list=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name)'")
|
||||
assert_contains "$db_list" "admin" "Database listing successful"
|
||||
|
||||
# Clean up test data
|
||||
echo " Cleaning up test data..."
|
||||
cmd "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.drop()'" > /dev/null 2>&1
|
||||
print_cleanup "Test data cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,13 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Allow insecure MongoDB package (CVE-2025-14847)
|
||||
nixpkgs.config.permittedInsecurePackages = [
|
||||
"mongodb-ce-8.0.4"
|
||||
];
|
||||
|
||||
# Enable MongoDB using the infrastructure module
|
||||
infrastructure.mongodb = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 27018;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# MongoDB standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys MongoDB as a native service on custom port 27018
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic MongoDB operations (insert/query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Custom port for testing
|
||||
MONGODB_PORT=27018
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down MongoDB test..."
|
||||
|
||||
# Stop MongoDB service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop mongodb 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/mongodb'
|
||||
|
||||
echo "MongoDB teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Standalone Test (port $MONGODB_PORT)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the mongodb configuration to test nodes
|
||||
echo "Step 1: Deploying MongoDB configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying MongoDB deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and database to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "mongodb" --timeout=30
|
||||
wait_for_port "$node" "$MONGODB_PORT" --timeout=15
|
||||
wait_for_mongodb "$node" "$MONGODB_PORT" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "mongodb" || show_service_logs "$node" "mongodb" 30
|
||||
done
|
||||
|
||||
# Check if MongoDB process is running
|
||||
echo ""
|
||||
echo "Checking MongoDB process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "mongod" "MongoDB"
|
||||
done
|
||||
|
||||
# Check if MongoDB port is listening
|
||||
echo ""
|
||||
echo "Checking MongoDB port ($MONGODB_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$MONGODB_PORT" "MongoDB port $MONGODB_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test MongoDB connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing MongoDB operations on $node..."
|
||||
|
||||
# Insert a test document
|
||||
echo " Inserting test document..."
|
||||
insert_result=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.insertOne({name: \"test\", value: 42})'")
|
||||
if [[ "$insert_result" == *"acknowledged"* ]] || [[ "$insert_result" == *"insertedId"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Insert operation successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Insert operation failed: $insert_result [fail]"
|
||||
fi
|
||||
|
||||
# Query the test document
|
||||
echo " Querying test document..."
|
||||
query_result=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.findOne({name: \"test\"})'")
|
||||
assert_contains_all "$query_result" "Query operation successful" "value" "42"
|
||||
|
||||
# Test database listing
|
||||
echo " Listing databases..."
|
||||
db_list=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name)'")
|
||||
assert_contains "$db_list" "admin" "Database listing successful"
|
||||
|
||||
# Clean up test data
|
||||
echo " Cleaning up test data..."
|
||||
cmd "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.drop()'" > /dev/null 2>&1
|
||||
print_cleanup "Test data cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "MongoDB Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,40 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable podman for container runtime
|
||||
config.infrastructure.podman.enable = true;
|
||||
|
||||
# Enable n8n as container-based instance
|
||||
config.infrastructure.n8n-pod = {
|
||||
enable = true;
|
||||
|
||||
# Use official n8n Docker image
|
||||
# image = "docker.n8n.io/n8nio/n8n:latest"; # Default
|
||||
|
||||
# Network settings
|
||||
bindToIp = "0.0.0.0";
|
||||
bindToPort = 5678;
|
||||
openFirewall = true;
|
||||
|
||||
# Use SQLite database (default)
|
||||
database = {
|
||||
type = "sqlite";
|
||||
};
|
||||
|
||||
# Execution settings
|
||||
executions = {
|
||||
pruneData = true;
|
||||
pruneDataMaxAge = 168; # 7 days for testing
|
||||
pruneDataMaxCount = 1000;
|
||||
};
|
||||
|
||||
# Additional settings (environment variables)
|
||||
settings = {
|
||||
GENERIC_TIMEZONE = "UTC";
|
||||
};
|
||||
};
|
||||
|
||||
# Test utilities
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
# n8n-pod test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys n8n as a podman container with SQLite backend
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests n8n endpoints and functionality
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down n8n-pod test..."
|
||||
|
||||
# Stop and remove container if running
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop podman-n8n-pod 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/n8n-pod'
|
||||
|
||||
echo "n8n-pod teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n-pod Test (SQLite, Container)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the n8n-pod configuration to test nodes
|
||||
echo "Step 1: Deploying n8n-pod configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying n8n-pod deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service, container and HTTP to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "podman-n8n-pod" --timeout=60
|
||||
wait_for_container "$node" "n8n-pod" --timeout=60
|
||||
wait_for_port "$node" "5678" --timeout=30
|
||||
wait_for_http "$node" "http://localhost:5678/" "200 302 303" --timeout=60
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Status
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "podman-n8n-pod" || show_service_logs "$node" "podman-n8n-pod" 50
|
||||
done
|
||||
|
||||
# Check if container is running
|
||||
echo ""
|
||||
echo "Checking container status..."
|
||||
for node in $TARGET; do
|
||||
assert_container_running "$node" "n8n-pod" "n8n-pod container"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Port Bindings
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Checking port bindings..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking ports on $node..."
|
||||
assert_port_listening "$node" "5678" "n8n port 5678"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing n8n on $node..."
|
||||
|
||||
# Test n8n HTTP response
|
||||
echo " Testing n8n HTTP response..."
|
||||
assert_http_status "$node" "http://localhost:5678/" "200 302 303" "HTTP response"
|
||||
|
||||
# Test n8n healthcheck endpoint
|
||||
echo " Testing n8n healthcheck endpoint..."
|
||||
healthcheck=$(cmd_clean "$node" "curl -s http://localhost:5678/healthz 2>/dev/null")
|
||||
if [[ "$healthcheck" == *"ok"* ]] || [[ "$healthcheck" == *"healthy"* ]] || [[ -n "$healthcheck" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} n8n healthcheck responded: $healthcheck [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} n8n healthcheck response: $healthcheck [warn]"
|
||||
fi
|
||||
|
||||
# Test n8n API types endpoint (should list available node types)
|
||||
echo " Testing n8n API endpoint..."
|
||||
api_response=$(cmd_clean "$node" "curl -s http://localhost:5678/api/v1/node-types 2>/dev/null | head -c 200")
|
||||
if [[ "$api_response" == *"data"* ]] || [[ "$api_response" == *"type"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} n8n API is responding [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} n8n API response: ${api_response:0:100} [warn]"
|
||||
fi
|
||||
|
||||
# Check n8n data directory exists on host
|
||||
echo " Testing n8n data directory..."
|
||||
assert_dir_exists "$node" "/var/lib/n8n-pod" "n8n data directory"
|
||||
|
||||
# Check SQLite database file exists (inside container volume)
|
||||
echo " Testing SQLite database file..."
|
||||
sqlite_exists=$(cmd_value "$node" "test -f /var/lib/n8n-pod/database.sqlite && echo 'exists' || echo 'missing'")
|
||||
assert_warn "$([[ "$sqlite_exists" == "exists" ]] && echo true || echo false)" "SQLite database file exists" "may be created on first use"
|
||||
|
||||
# Check container logs for errors
|
||||
echo " Checking container logs for errors..."
|
||||
error_logs=$(cmd_clean "$node" "podman logs n8n-pod 2>&1 | grep -i 'error\|fatal' | tail -5 || echo 'none'")
|
||||
if [[ "$error_logs" == *"none"* ]] || [[ -z "$error_logs" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} No errors in container logs [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} Errors found in logs: $error_logs [warn]"
|
||||
fi
|
||||
|
||||
# Check container health
|
||||
echo " Checking container process..."
|
||||
n8n_process=$(cmd_clean "$node" "podman exec n8n-pod pgrep -f 'n8n' || echo 'not_found'")
|
||||
if [[ "$n8n_process" != "not_found" ]] && [[ -n "$n8n_process" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} n8n process is running inside container [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} n8n process not found in container [fail]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n-pod Test Summary (SQLite, Container)"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n-pod Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,67 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
imports = [
|
||||
# Import based on file structure on deployed machine
|
||||
./app_modules/_unstable/n8n/default.nix
|
||||
];
|
||||
|
||||
# ==========================================================================
|
||||
# Swap Configuration (for memory-intensive n8n builds)
|
||||
# ==========================================================================
|
||||
config.swapDevices = [{
|
||||
device = "/swapfile";
|
||||
size = 4096; # 4GB swap
|
||||
}];
|
||||
|
||||
# ==========================================================================
|
||||
# Nix Build Settings (limit parallelism to avoid OOM during n8n build)
|
||||
# ==========================================================================
|
||||
config.nix.settings = {
|
||||
# Only one build job at a time
|
||||
max-jobs = 6;
|
||||
# Limit cores per build job
|
||||
cores = 6;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# n8n Configuration (using infrastructure module with SQLite)
|
||||
# ==========================================================================
|
||||
config.infrastructure.n8n = {
|
||||
enable = true;
|
||||
|
||||
# Reduce build memory to leave room for system (default: 4096)
|
||||
buildMemoryMB = 8192;
|
||||
|
||||
# Network settings
|
||||
bindToIp = "0.0.0.0";
|
||||
bindToPort = 5678;
|
||||
openFirewall = true;
|
||||
|
||||
# Use SQLite database (default)
|
||||
database = {
|
||||
type = "sqlite";
|
||||
};
|
||||
|
||||
# Execution settings
|
||||
executions = {
|
||||
pruneData = true;
|
||||
pruneDataMaxAge = 168; # 7 days for testing
|
||||
pruneDataMaxCount = 1000;
|
||||
};
|
||||
|
||||
# Additional settings (environment variables)
|
||||
settings = {
|
||||
GENERIC_TIMEZONE = "UTC";
|
||||
# Enable public API for testing
|
||||
N8N_PUBLIC_API_ENABLED = "true";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Test utilities
|
||||
# ==========================================================================
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env bash
|
||||
# n8n test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys n8n with SQLite backend
|
||||
# 2. Verifies all services are running
|
||||
# 3. Tests n8n endpoints and REST API functionality
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down n8n test..."
|
||||
|
||||
# Stop services
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop n8n 2>/dev/null || true'
|
||||
|
||||
# Clean up entire n8n data directory including SQLite database
|
||||
echo " Removing n8n data directory..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/n8n'
|
||||
|
||||
# Clean up temporary cookie file used in tests
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -f /tmp/n8n-cookies.txt 2>/dev/null || true'
|
||||
|
||||
echo "n8n teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n Test (SQLite)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the n8n configuration to test nodes
|
||||
echo "Step 1: Deploying n8n configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --debug --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" --no-rebuild \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying n8n deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and HTTP to be ready (n8n may take time to initialize)
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "n8n" --timeout=60
|
||||
wait_for_port "$node" "5678" --timeout=30
|
||||
wait_for_http "$node" "http://localhost:5678/" "200 302 303" --timeout=60
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Status
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Checking systemd services status..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "...checking services on $node"
|
||||
assert_service_active "$node" "n8n" || show_service_logs "$node" "n8n" 50
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Port Bindings
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Checking port bindings..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking ports on $node..."
|
||||
assert_port_listening "$node" "5678" "n8n port 5678"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing n8n on $node..."
|
||||
|
||||
# Test n8n HTTP response
|
||||
echo " Testing n8n HTTP response..."
|
||||
assert_http_status "$node" "http://localhost:5678/" "200 302 303" "HTTP response"
|
||||
|
||||
# Test n8n healthcheck endpoint
|
||||
echo " Testing n8n healthcheck endpoint..."
|
||||
healthcheck=$(cmd_clean "$node" "curl -s http://localhost:5678/healthz 2>/dev/null")
|
||||
if [[ "$healthcheck" == *"ok"* ]] || [[ "$healthcheck" == *"healthy"* ]] || [[ -n "$healthcheck" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} n8n healthcheck responded: $healthcheck [pass]"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} n8n healthcheck response: $healthcheck [warn]"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Authenticated REST API Tests (using session cookie)
|
||||
# ============================================================================
|
||||
echo " Setting up authentication for API testing..."
|
||||
|
||||
# Create authentication test script using base64 to avoid escaping issues
|
||||
AUTH_SCRIPT='#!/usr/bin/env bash
|
||||
|
||||
# Step 1: Create owner user (will fail if already exists, which is fine)
|
||||
curl -s -X POST http://localhost:5678/rest/owner/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"test@example.com\",\"firstName\":\"Test\",\"lastName\":\"User\",\"password\":\"TestPassword123!\"}" > /tmp/n8n-owner-result.json 2>&1
|
||||
|
||||
# Step 2: Login and get session cookie
|
||||
curl -s -c /tmp/n8n-cookies.txt -X POST http://localhost:5678/rest/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"emailOrLdapLoginId\":\"test@example.com\",\"password\":\"TestPassword123!\"}" > /tmp/n8n-login-result.json 2>&1
|
||||
|
||||
# Check login result
|
||||
if grep -q "test@example.com" /tmp/n8n-login-result.json 2>/dev/null; then
|
||||
echo "LOGIN_SUCCESS"
|
||||
|
||||
# Step 3: Test REST API with session cookie (list workflows)
|
||||
WORKFLOWS_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt http://localhost:5678/rest/workflows 2>&1)
|
||||
|
||||
if echo "$WORKFLOWS_RESPONSE" | jq -e ".data" > /dev/null 2>&1; then
|
||||
WORKFLOW_COUNT=$(echo "$WORKFLOWS_RESPONSE" | jq -r ".data | length")
|
||||
echo "REST_API_SUCCESS:workflows=$WORKFLOW_COUNT"
|
||||
else
|
||||
echo "REST_API_FAILED:$WORKFLOWS_RESPONSE"
|
||||
fi
|
||||
|
||||
# Step 4: Test creating a workflow via REST API
|
||||
CREATE_WORKFLOW_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt -X POST http://localhost:5678/rest/workflows \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Test Workflow\",\"nodes\":[],\"connections\":{},\"settings\":{},\"active\":false}" 2>&1)
|
||||
|
||||
if echo "$CREATE_WORKFLOW_RESPONSE" | jq -e ".data.id" > /dev/null 2>&1; then
|
||||
WORKFLOW_ID=$(echo "$CREATE_WORKFLOW_RESPONSE" | jq -r ".data.id")
|
||||
echo "WORKFLOW_CREATED:$WORKFLOW_ID"
|
||||
|
||||
# Step 5: Verify the workflow exists (skip delete - teardown will clean up)
|
||||
# Note: n8n archive and delete API uses internal endpoints that are not stable
|
||||
VERIFY_WORKFLOW_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt "http://localhost:5678/rest/workflows/$WORKFLOW_ID" 2>&1)
|
||||
if echo "$VERIFY_WORKFLOW_RESPONSE" | jq -e ".data.id" > /dev/null 2>&1; then
|
||||
echo "WORKFLOW_VERIFIED"
|
||||
else
|
||||
echo "WORKFLOW_VERIFY_FAILED:$VERIFY_WORKFLOW_RESPONSE"
|
||||
fi
|
||||
else
|
||||
echo "WORKFLOW_CREATE_FAILED:$CREATE_WORKFLOW_RESPONSE"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "LOGIN_FAILED:$(cat /tmp/n8n-login-result.json)"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/n8n-owner-result.json /tmp/n8n-login-result.json /tmp/n8n-cookies.txt
|
||||
'
|
||||
|
||||
# Encode script and send to remote node
|
||||
AUTH_SCRIPT_B64=$(echo "$AUTH_SCRIPT" | base64 -w0)
|
||||
cmd "$node" "echo '$AUTH_SCRIPT_B64' | base64 -d > /tmp/n8n-auth-test.sh && chmod +x /tmp/n8n-auth-test.sh"
|
||||
|
||||
# Run the auth test script
|
||||
auth_result=$(cmd_clean "$node" "bash /tmp/n8n-auth-test.sh")
|
||||
cmd "$node" "rm -f /tmp/n8n-auth-test.sh"
|
||||
|
||||
# Parse results
|
||||
if [[ "$auth_result" == *"LOGIN_SUCCESS"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Login successful [pass]"
|
||||
|
||||
# Check REST API access with session cookie
|
||||
if [[ "$auth_result" == *"REST_API_SUCCESS:"* ]]; then
|
||||
rest_info=$(echo "$auth_result" | grep "REST_API_SUCCESS:" | sed 's/.*REST_API_SUCCESS://')
|
||||
echo -e " ${GREEN}✓${NC} REST API accessible ($rest_info) [pass]"
|
||||
elif [[ "$auth_result" == *"REST_API_FAILED:"* ]]; then
|
||||
rest_error=$(echo "$auth_result" | grep "REST_API_FAILED:" | sed 's/.*REST_API_FAILED://')
|
||||
echo -e " ${RED}✗${NC} REST API failed: ${rest_error:0:100} [fail]"
|
||||
fi
|
||||
|
||||
# Check workflow CRUD operations
|
||||
if [[ "$auth_result" == *"WORKFLOW_CREATED:"* ]]; then
|
||||
workflow_id=$(echo "$auth_result" | grep "WORKFLOW_CREATED:" | sed 's/.*WORKFLOW_CREATED://')
|
||||
echo -e " ${GREEN}✓${NC} Workflow created (id: $workflow_id) [pass]"
|
||||
|
||||
if [[ "$auth_result" == *"WORKFLOW_VERIFIED"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Workflow retrieved successfully [pass]"
|
||||
elif [[ "$auth_result" == *"WORKFLOW_VERIFY_FAILED:"* ]]; then
|
||||
verify_error=$(echo "$auth_result" | grep "WORKFLOW_VERIFY_FAILED:" | sed 's/.*WORKFLOW_VERIFY_FAILED://')
|
||||
echo -e " ${RED}✗${NC} Workflow verify failed: ${verify_error:0:100} [fail]"
|
||||
fi
|
||||
elif [[ "$auth_result" == *"WORKFLOW_CREATE_FAILED:"* ]]; then
|
||||
create_error=$(echo "$auth_result" | grep "WORKFLOW_CREATE_FAILED:" | sed 's/.*WORKFLOW_CREATE_FAILED://')
|
||||
echo -e " ${RED}✗${NC} Workflow create failed: ${create_error:0:100} [fail]"
|
||||
fi
|
||||
|
||||
else
|
||||
login_error=$(echo "$auth_result" | grep "LOGIN_FAILED:" | sed 's/.*LOGIN_FAILED://')
|
||||
echo -e " ${RED}✗${NC} Login failed: ${login_error:0:100} [fail]"
|
||||
fi
|
||||
|
||||
# Check n8n data directory exists
|
||||
echo " Testing n8n data directory..."
|
||||
assert_dir_exists "$node" "/var/lib/n8n" "n8n data directory"
|
||||
|
||||
# Check SQLite database file exists
|
||||
echo " Testing SQLite database file..."
|
||||
assert_file_exists "$node" "/var/lib/n8n/.n8n/database.sqlite" "SQLite database file"
|
||||
|
||||
# Check service is not in error state
|
||||
echo " Checking service state..."
|
||||
assert_service_running "$node" "n8n" "Service running normally"
|
||||
|
||||
# Check for any failed units related to n8n
|
||||
echo " Checking for failed units..."
|
||||
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -i n8n || echo 'none'")
|
||||
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]] || [[ ! "$failed_units" == *"failed"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} No failed n8n related units [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Failed units found: $failed_units [fail]"
|
||||
fi
|
||||
|
||||
# Check n8n process is running
|
||||
echo " Checking n8n process..."
|
||||
assert_process_running "$node" "-f n8n" "n8n"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n Test Summary (SQLite)"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "n8n Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,132 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# ==========================================================================
|
||||
# PostgreSQL Database for Nextcloud (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.postgresql = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 5432;
|
||||
initialDatabases = [ "nextcloud" ];
|
||||
authentication = ''
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Redis for Nextcloud Caching (using infrastructure module)
|
||||
# ==========================================================================
|
||||
config.infrastructure.redis = {
|
||||
enable = true;
|
||||
servers.nextcloud = {
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 6379;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Nextcloud Configuration
|
||||
# ==========================================================================
|
||||
config.infrastructure.nextcloud = {
|
||||
enable = true;
|
||||
package = pkgs.nextcloud31;
|
||||
hostName = "localhost";
|
||||
https = false;
|
||||
|
||||
admin = {
|
||||
user = "admin";
|
||||
passwordFile = "/run/secrets/nextcloud-admin-pass";
|
||||
};
|
||||
|
||||
database = {
|
||||
type = "pgsql";
|
||||
name = "nextcloud";
|
||||
user = "nextcloud";
|
||||
host = "/run/postgresql";
|
||||
createLocally = true; # Creates the nextcloud user and grants permissions
|
||||
};
|
||||
|
||||
caching = {
|
||||
redis = true;
|
||||
apcu = true;
|
||||
};
|
||||
|
||||
maxUploadSize = "1G";
|
||||
|
||||
settings = {
|
||||
default_phone_region = "US";
|
||||
log_type = "file";
|
||||
loglevel = 2;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Create admin password file
|
||||
# ==========================================================================
|
||||
config.systemd.services.nextcloud-create-admin-pass = {
|
||||
description = "Create Nextcloud admin password file";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "nextcloud-setup.service" ];
|
||||
requiredBy = [ "nextcloud-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
mkdir -p /run/secrets
|
||||
echo "testadminpass123" > /run/secrets/nextcloud-admin-pass
|
||||
chmod 400 /run/secrets/nextcloud-admin-pass
|
||||
chown nextcloud:nextcloud /run/secrets/nextcloud-admin-pass
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Ensure correct service ordering
|
||||
# ==========================================================================
|
||||
|
||||
# Nextcloud setup depends on PostgreSQL and Redis
|
||||
config.systemd.services.nextcloud-setup = {
|
||||
after = [
|
||||
"postgresql.service"
|
||||
"redis-nextcloud.service"
|
||||
"nextcloud-create-admin-pass.service"
|
||||
];
|
||||
requires = [
|
||||
"postgresql.service"
|
||||
];
|
||||
wants = [
|
||||
"redis-nextcloud.service"
|
||||
"nextcloud-create-admin-pass.service"
|
||||
];
|
||||
};
|
||||
|
||||
# PHP-FPM depends on nextcloud-setup
|
||||
config.systemd.services.phpfpm-nextcloud = {
|
||||
after = [
|
||||
"nextcloud-setup.service"
|
||||
];
|
||||
requires = [
|
||||
"nextcloud-setup.service"
|
||||
];
|
||||
};
|
||||
|
||||
# Nginx depends on PHP-FPM
|
||||
config.systemd.services.nginx = {
|
||||
after = [
|
||||
"phpfpm-nextcloud.service"
|
||||
];
|
||||
wants = [
|
||||
"phpfpm-nextcloud.service"
|
||||
];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Test utilities
|
||||
# ==========================================================================
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nextcloud test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys Nextcloud with PostgreSQL, Redis, and Nginx
|
||||
# 2. Verifies all services are running and started in correct order
|
||||
# 3. Tests Nextcloud endpoints and functionality
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down Nextcloud test..."
|
||||
|
||||
# Stop services in reverse order
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop nginx 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop phpfpm-nextcloud 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop nextcloud-cron 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop redis-nextcloud 2>/dev/null || true'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop postgresql 2>/dev/null || true'
|
||||
|
||||
# Clean up data directories
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/nextcloud /var/lib/postgresql /var/lib/redis-nextcloud /run/secrets/nextcloud-admin-pass'
|
||||
|
||||
echo "Nextcloud teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nextcloud Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the nextcloud configuration to test nodes
|
||||
echo "Step 1: Deploying Nextcloud configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying Nextcloud deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for services to start (Nextcloud has multiple dependencies)
|
||||
for node in $TARGET; do
|
||||
# Wait for backend services first
|
||||
wait_for_service "$node" "postgresql" --timeout=30
|
||||
wait_for_postgresql "$node" --timeout=30
|
||||
wait_for_service "$node" "redis-nextcloud" --timeout=30
|
||||
wait_for_redis "$node" "6379" --timeout=15
|
||||
|
||||
# Wait for nextcloud-setup oneshot to complete
|
||||
wait_for_service_completed "$node" "nextcloud-setup" --timeout=120
|
||||
|
||||
# Wait for web services
|
||||
wait_for_service "$node" "phpfpm-nextcloud" --timeout=30
|
||||
wait_for_service "$node" "nginx" --timeout=30
|
||||
wait_for_port "$node" "80" --timeout=15
|
||||
wait_for_http "$node" "http://localhost/" "200 302 303" --timeout=60
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Status
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Checking systemd services status..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking services on $node..."
|
||||
|
||||
# Check regular services
|
||||
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 50
|
||||
assert_service_active "$node" "redis-nextcloud" || show_service_logs "$node" "redis-nextcloud" 50
|
||||
|
||||
# Check oneshot service (nextcloud-setup)
|
||||
assert_service_completed "$node" "nextcloud-setup" || show_service_logs "$node" "nextcloud-setup" 50
|
||||
|
||||
# Check remaining services
|
||||
assert_service_active "$node" "phpfpm-nextcloud" || show_service_logs "$node" "phpfpm-nextcloud" 50
|
||||
assert_service_active "$node" "nginx" || show_service_logs "$node" "nginx" 50
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Service Dependencies
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying service dependencies..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking service dependencies on $node..."
|
||||
|
||||
# Check PostgreSQL started before nextcloud-setup
|
||||
pg_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic postgresql --value")
|
||||
nc_setup_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic nextcloud-setup --value")
|
||||
assert_lt "$pg_start" "$nc_setup_start" "PostgreSQL started before nextcloud-setup"
|
||||
|
||||
# Check Redis started before nextcloud-setup
|
||||
redis_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic redis-nextcloud --value")
|
||||
assert_lt "$redis_start" "$nc_setup_start" "Redis started before nextcloud-setup"
|
||||
|
||||
# Check nextcloud-setup completed before phpfpm-nextcloud
|
||||
phpfpm_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic phpfpm-nextcloud --value")
|
||||
assert_lt "$nc_setup_start" "$phpfpm_start" "nextcloud-setup completed before phpfpm-nextcloud"
|
||||
|
||||
# Check phpfpm-nextcloud started before nginx
|
||||
nginx_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic nginx --value")
|
||||
assert_lt "$phpfpm_start" "$nginx_start" "phpfpm-nextcloud started before nginx"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Check Port Bindings
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Checking port bindings..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Checking ports on $node..."
|
||||
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
|
||||
assert_port_listening "$node" "6379" "Redis port 6379"
|
||||
assert_port_listening "$node" "80" "HTTP port 80"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing Nextcloud on $node..."
|
||||
|
||||
# Test Nextcloud HTTP response
|
||||
echo " Testing Nextcloud HTTP response..."
|
||||
assert_http_status "$node" "http://localhost/" "200 302 303" "HTTP response"
|
||||
|
||||
# Test Nextcloud login page
|
||||
echo " Testing Nextcloud login page..."
|
||||
login_page=$(cmd_clean "$node" "curl -s -L http://localhost/login 2>/dev/null | head -c 2000")
|
||||
if [[ "$login_page" == *"Nextcloud"* ]] || [[ "$login_page" == *"login"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Nextcloud login page is accessible [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Nextcloud login page not accessible [fail]"
|
||||
echo " Response preview: ${login_page:0:200}..."
|
||||
fi
|
||||
|
||||
# Test Nextcloud status endpoint
|
||||
echo " Testing Nextcloud status endpoint..."
|
||||
status_response=$(cmd_clean "$node" "curl -s http://localhost/status.php 2>/dev/null")
|
||||
if assert_contains_all "$status_response" "Nextcloud is installed (status.php)" "installed" "true"; then
|
||||
version=$(echo "$status_response" | grep -o '"versionstring":"[^"]*"' | cut -d'"' -f4)
|
||||
if [[ -n "$version" ]]; then
|
||||
print_info "Nextcloud version" "$version"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test PostgreSQL database connection
|
||||
echo " Testing PostgreSQL database..."
|
||||
db_check=$(cmd_clean "$node" "sudo -u postgres psql -l | grep nextcloud")
|
||||
assert_contains "$db_check" "nextcloud" "Nextcloud database exists in PostgreSQL"
|
||||
|
||||
# Test Redis connection
|
||||
echo " Testing Redis connection..."
|
||||
redis_check=$(cmd_clean "$node" "redis-cli -p 6379 PING 2>/dev/null")
|
||||
assert_contains "$redis_check" "PONG" "Redis is responding"
|
||||
|
||||
# Test Nextcloud OCC command
|
||||
echo " Testing Nextcloud OCC command..."
|
||||
occ_check=$(cmd_clean "$node" "sudo -u nextcloud /run/current-system/sw/bin/nextcloud-occ status 2>/dev/null")
|
||||
assert_contains "$occ_check" "installed: true" "Nextcloud OCC reports installed"
|
||||
|
||||
# Test admin user exists
|
||||
echo " Testing admin user exists..."
|
||||
admin_check=$(cmd_clean "$node" "sudo -u nextcloud /run/current-system/sw/bin/nextcloud-occ user:list 2>/dev/null | grep admin")
|
||||
assert_contains "$admin_check" "admin" "Admin user exists"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nextcloud Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nextcloud Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,67 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable nginx with test configuration
|
||||
config.infrastructure.nginx = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
recommendedSettings = true;
|
||||
|
||||
# Note: ACME/Let's Encrypt cannot be fully tested in VM environment
|
||||
# as it requires DNS resolution and public internet access.
|
||||
# For production, set acme.enable = true and acme.acceptTerms = true
|
||||
acme = {
|
||||
enable = false; # Disabled for testing
|
||||
acceptTerms = false;
|
||||
email = "test@example.com";
|
||||
staging = true; # Use staging server to avoid rate limits
|
||||
};
|
||||
|
||||
virtualHosts = {
|
||||
# Simple static site
|
||||
"localhost" = {
|
||||
default = true;
|
||||
root = "/var/www/test";
|
||||
locations."/" = {
|
||||
index = "index.html";
|
||||
};
|
||||
locations."/health" = {
|
||||
return = "200 'OK'";
|
||||
extraConfig = ''
|
||||
add_header Content-Type text/plain;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Reverse proxy example (proxy to a test backend)
|
||||
"proxy.localhost" = {
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:8080";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
locations."/api" = {
|
||||
proxyPass = "http://127.0.0.1:8081";
|
||||
extraConfig = ''
|
||||
proxy_read_timeout 300s;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
appendHttpConfig = ''
|
||||
# Custom http config for testing
|
||||
log_format custom '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent';
|
||||
'';
|
||||
};
|
||||
|
||||
# Create test web root directory with content
|
||||
config.systemd.tmpfiles.rules = [
|
||||
"d /var/www/test 0755 nginx nginx -"
|
||||
"f /var/www/test/index.html 0644 nginx nginx - '<html><body><h1>Nginx Test Page</h1></body></html>'"
|
||||
];
|
||||
|
||||
# Install utilities for testing
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
openssl
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nginx test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys nginx with virtual hosts configuration
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests HTTP endpoints
|
||||
# 4. Tests virtual host routing
|
||||
# 5. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down Nginx test..."
|
||||
|
||||
# Stop nginx service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop nginx 2>/dev/null || true'
|
||||
|
||||
# Clean up test web content
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/www/test'
|
||||
|
||||
echo "Nginx teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nginx Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the nginx configuration to test nodes
|
||||
echo "Step 1: Deploying Nginx configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying Nginx deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and ports to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "nginx" --timeout=30
|
||||
wait_for_port "$node" "80" --timeout=15
|
||||
wait_for_http "$node" "http://127.0.0.1/" "200" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "nginx" || show_service_logs "$node" "nginx" 50
|
||||
done
|
||||
|
||||
# Check if nginx process is running
|
||||
echo ""
|
||||
echo "Checking Nginx process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "nginx" "Nginx"
|
||||
done
|
||||
|
||||
# Check if HTTP port is listening
|
||||
echo ""
|
||||
echo "Checking HTTP port (80)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "80" "HTTP port 80"
|
||||
done
|
||||
|
||||
# Check if HTTPS port is listening (even without certs, nginx binds)
|
||||
echo ""
|
||||
echo "Checking HTTPS port (443)..."
|
||||
for node in $TARGET; do
|
||||
port_check=$(cmd "$node" "ss -tlnp | grep ':443 '")
|
||||
if [[ "$port_check" == *":443"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} HTTPS port 443 is listening [pass]"
|
||||
else
|
||||
# This is expected to fail without SSL certificates configured
|
||||
echo -e " ${GREEN}✓${NC} HTTPS port 443 not listening (expected without SSL cert) [pass]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing Nginx on $node..."
|
||||
|
||||
# Test default virtual host - index page
|
||||
echo " Testing default virtual host (index page)..."
|
||||
index_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/")
|
||||
assert_contains "$index_response" "Nginx Test Page" "Index page served correctly"
|
||||
|
||||
# Test health endpoint
|
||||
echo " Testing health endpoint..."
|
||||
health_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/health")
|
||||
assert_contains "$health_response" "OK" "Health endpoint returned OK"
|
||||
|
||||
# Test HTTP status code
|
||||
echo " Testing HTTP status codes..."
|
||||
assert_http_status "$node" "http://127.0.0.1/" "200" "HTTP 200 OK for index"
|
||||
|
||||
# Test 404 for non-existent path
|
||||
echo " Testing 404 handling..."
|
||||
assert_http_status "$node" "http://127.0.0.1/nonexistent" "404" "HTTP 404 for non-existent path"
|
||||
|
||||
# Test nginx configuration syntax using the nginx binary from nix store
|
||||
echo " Testing nginx configuration syntax..."
|
||||
config_test=$(cmd_clean "$node" "NGINX_BIN=\$(readlink -f /proc/\$(pgrep -o nginx)/exe) && \$NGINX_BIN -t 2>&1")
|
||||
if [[ "$config_test" == *"syntax is ok"* ]] || [[ "$config_test" == *"test is successful"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Nginx configuration syntax valid [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Nginx configuration syntax error [fail]"
|
||||
echo " $config_test"
|
||||
fi
|
||||
|
||||
# Test Host header routing (proxy.localhost virtual host)
|
||||
echo " Testing virtual host routing..."
|
||||
proxy_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' -H 'Host: proxy.localhost' http://127.0.0.1/ 2>/dev/null || echo '502'")
|
||||
if [[ "$proxy_code" == "502" ]] || [[ "$proxy_code" == "504" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Virtual host routing works (502/504 expected - no backend) [pass]"
|
||||
else
|
||||
print_info "Virtual host routing" "HTTP $proxy_code"
|
||||
fi
|
||||
|
||||
# Test gzip compression is enabled
|
||||
echo " Testing gzip compression..."
|
||||
gzip_test=$(cmd_clean "$node" "curl -s -H 'Accept-Encoding: gzip' -I http://127.0.0.1/ | grep -i 'Content-Encoding' || echo 'no-gzip'")
|
||||
if [[ "$gzip_test" == *"gzip"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Gzip compression enabled [pass]"
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Gzip not applied (expected for small responses) [pass]"
|
||||
fi
|
||||
|
||||
# Test server tokens are hidden (security)
|
||||
echo " Testing server security headers..."
|
||||
server_header=$(cmd_clean "$node" "curl -s -I http://127.0.0.1/ | grep -i '^Server:' || echo 'Server: hidden'")
|
||||
if [[ "$server_header" != *"nginx/"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Server version hidden [pass]"
|
||||
else
|
||||
print_info "Server header" "$server_header"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nginx Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Nginx Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,12 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable OpenSearch using the infrastructure module
|
||||
infrastructure.opensearch = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
httpPort = 9201;
|
||||
transportPort = 9301;
|
||||
clusterName = "test-cluster";
|
||||
singleNode = true;
|
||||
heapSize = "512m";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenSearch standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys OpenSearch as a native service on custom port 9201
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic OpenSearch operations (index/query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Custom ports for testing
|
||||
OPENSEARCH_HTTP_PORT=9201
|
||||
OPENSEARCH_TRANSPORT_PORT=9301
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down OpenSearch test..."
|
||||
|
||||
# Stop OpenSearch service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop opensearch 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/opensearch'
|
||||
|
||||
echo "OpenSearch teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "OpenSearch Standalone Test (port $OPENSEARCH_HTTP_PORT)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the opensearch configuration to test nodes
|
||||
echo "Step 1: Deploying OpenSearch configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying OpenSearch deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for OpenSearch service and API to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "opensearch" --timeout=60
|
||||
wait_for_port "$node" "$OPENSEARCH_HTTP_PORT" --timeout=30
|
||||
wait_for_elasticsearch "$node" "$OPENSEARCH_HTTP_PORT" --timeout=60 # Same API as Elasticsearch
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "opensearch" || show_service_logs "$node" "opensearch" 50
|
||||
done
|
||||
|
||||
# Check if OpenSearch process is running
|
||||
echo ""
|
||||
echo "Checking OpenSearch process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "-f opensearch" "OpenSearch"
|
||||
done
|
||||
|
||||
# Check if OpenSearch HTTP port is listening
|
||||
echo ""
|
||||
echo "Checking OpenSearch HTTP port ($OPENSEARCH_HTTP_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$OPENSEARCH_HTTP_PORT" "HTTP port $OPENSEARCH_HTTP_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test OpenSearch connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing OpenSearch operations on $node..."
|
||||
|
||||
# Test cluster health endpoint
|
||||
echo " Checking cluster health..."
|
||||
health_result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$OPENSEARCH_HTTP_PORT/_cluster/health")
|
||||
if assert_contains "$health_result" "cluster_name" "Cluster health endpoint accessible"; then
|
||||
status=$(echo "$health_result" | jq -r '.status' 2>/dev/null || echo "unknown")
|
||||
print_info "Cluster status" "$status"
|
||||
fi
|
||||
|
||||
# Create a test index
|
||||
echo " Creating test index..."
|
||||
create_result=$(cmd_clean "$node" "curl -s -X PUT 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index' -H 'Content-Type: application/json' -d '{\"settings\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}'")
|
||||
assert_contains_all "$create_result" "Index creation successful" "acknowledged" "true"
|
||||
|
||||
# Insert a test document
|
||||
echo " Inserting test document..."
|
||||
insert_result=$(cmd_clean "$node" "curl -s -X POST 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_doc/1' -H 'Content-Type: application/json' -d '{\"name\": \"test\", \"value\": 42}'")
|
||||
if [[ "$insert_result" == *"created"* ]] || [[ "$insert_result" == *"_id"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Document insert successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Document insert failed: $insert_result [fail]"
|
||||
fi
|
||||
|
||||
# Force refresh to make document searchable
|
||||
cmd "$node" "curl -s -X POST 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_refresh'" > /dev/null 2>&1
|
||||
|
||||
# Query the test document
|
||||
echo " Querying test document..."
|
||||
query_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_doc/1'")
|
||||
assert_contains_all "$query_result" "Document query successful" "found" "true"
|
||||
|
||||
# Test search functionality
|
||||
echo " Testing search..."
|
||||
search_result=$(cmd_clean "$node" "curl -s -X GET 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_search' -H 'Content-Type: application/json' -d '{\"query\": {\"match\": {\"name\": \"test\"}}}'")
|
||||
assert_contains_all "$search_result" "Search operation successful" "hits" "value"
|
||||
|
||||
# List indices
|
||||
echo " Listing indices..."
|
||||
indices_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/_cat/indices?v'")
|
||||
assert_contains "$indices_result" "test-index" "Index listing successful"
|
||||
|
||||
# Clean up test index
|
||||
echo " Cleaning up test index..."
|
||||
cmd "$node" "curl -s -X DELETE 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index'" > /dev/null 2>&1
|
||||
print_cleanup "Test index cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "OpenSearch Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "OpenSearch Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,9 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable PostgreSQL using the infrastructure module
|
||||
infrastructure.postgresql = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 5432;
|
||||
initialDatabases = [ "testdb" ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys PostgreSQL as a native service
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic PostgreSQL operations (create table, insert, query)
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down PostgreSQL test..."
|
||||
|
||||
# Stop PostgreSQL service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop postgresql 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/postgresql'
|
||||
|
||||
echo "PostgreSQL teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "PostgreSQL Standalone Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the postgresql configuration to test nodes
|
||||
echo "Step 1: Deploying PostgreSQL configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying PostgreSQL deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and database to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "postgresql" --timeout=30
|
||||
wait_for_port "$node" "5432" --timeout=15
|
||||
wait_for_postgresql "$node" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 30
|
||||
done
|
||||
|
||||
# Check if PostgreSQL process is running
|
||||
echo ""
|
||||
echo "Checking PostgreSQL process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "postgres" "PostgreSQL"
|
||||
done
|
||||
|
||||
# Check if PostgreSQL port is listening
|
||||
echo ""
|
||||
echo "Checking PostgreSQL port (5432)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test PostgreSQL connection and basic operations
|
||||
for node in $TARGET; do
|
||||
echo "Testing PostgreSQL operations on $node..."
|
||||
|
||||
# Test connection
|
||||
echo " Testing connection..."
|
||||
conn_result=$(cmd_clean "$node" "sudo -u postgres psql -c 'SELECT 1 as test;' 2>&1")
|
||||
assert_contains "$conn_result" "1" "Connection successful"
|
||||
|
||||
# Check if testdb was created
|
||||
echo " Checking testdb database..."
|
||||
db_check=$(cmd_clean "$node" "sudo -u postgres psql -l | grep testdb")
|
||||
assert_contains "$db_check" "testdb" "Database 'testdb' exists"
|
||||
|
||||
# Create a test table
|
||||
echo " Creating test table..."
|
||||
create_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c 'CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name VARCHAR(100), value INTEGER);' 2>&1")
|
||||
if [[ "$create_result" == *"CREATE TABLE"* ]] || [[ "$create_result" == *"already exists"* ]] || [[ -z "$create_result" ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Create table successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Create table failed: $create_result [fail]"
|
||||
fi
|
||||
|
||||
# Insert a test record
|
||||
echo " Inserting test record..."
|
||||
insert_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c \"INSERT INTO test_table (name, value) VALUES ('test', 42);\" 2>&1")
|
||||
assert_contains "$insert_result" "INSERT" "Insert operation successful"
|
||||
|
||||
# Query the test record
|
||||
echo " Querying test record..."
|
||||
query_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c 'SELECT * FROM test_table WHERE name = '\\''test'\\'';' 2>&1")
|
||||
assert_contains_all "$query_result" "Query operation successful" "test" "42"
|
||||
|
||||
# Test database listing
|
||||
echo " Listing databases..."
|
||||
db_list=$(cmd_clean "$node" "sudo -u postgres psql -c '\\l' 2>&1")
|
||||
assert_contains "$db_list" "postgres" "Database listing successful"
|
||||
|
||||
# Clean up test data
|
||||
echo " Cleaning up test data..."
|
||||
cmd "$node" "sudo -u postgres psql -d testdb -c 'DROP TABLE IF EXISTS test_table;'" > /dev/null 2>&1
|
||||
print_cleanup "Test data cleaned up"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "PostgreSQL Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "PostgreSQL Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,14 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable RabbitMQ using the infrastructure module
|
||||
infrastructure.rabbitmq = {
|
||||
enable = true;
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 5672;
|
||||
|
||||
# Enable management plugin for testing
|
||||
managementPlugin = {
|
||||
enable = true;
|
||||
port = 15672;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# RabbitMQ standalone test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys RabbitMQ as a native service
|
||||
# 2. Verifies the service is running
|
||||
# 3. Tests basic RabbitMQ operations (queue creation, publish/consume)
|
||||
# 4. Tests the management API
|
||||
# 5. Cleans up on teardown
|
||||
|
||||
# Ports for testing
|
||||
RABBITMQ_PORT=5672
|
||||
MANAGEMENT_PORT=15672
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down RabbitMQ test..."
|
||||
|
||||
# Stop RabbitMQ service
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'systemctl stop rabbitmq 2>/dev/null || true'
|
||||
|
||||
# Clean up data directory
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/rabbitmq'
|
||||
|
||||
echo "RabbitMQ teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "RabbitMQ Standalone Test"
|
||||
echo " AMQP port: $RABBITMQ_PORT"
|
||||
echo " Management port: $MANAGEMENT_PORT"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the rabbitmq configuration to test nodes
|
||||
echo "Step 1: Deploying RabbitMQ configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying RabbitMQ deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for service and ports to be ready
|
||||
for node in $TARGET; do
|
||||
wait_for_service "$node" "rabbitmq" --timeout=60
|
||||
wait_for_port "$node" "$RABBITMQ_PORT" --timeout=30
|
||||
wait_for_port "$node" "$MANAGEMENT_PORT" --timeout=30
|
||||
done
|
||||
|
||||
# Check if the systemd service is active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
assert_service_active "$node" "rabbitmq" || show_service_logs "$node" "rabbitmq" 30
|
||||
done
|
||||
|
||||
|
||||
echo ""
|
||||
echo "Checking RabbitMQ process..."
|
||||
for node in $TARGET; do
|
||||
assert_process_running "$node" "beam.smp" "RabbitMQ (Erlang VM)"
|
||||
done
|
||||
|
||||
# Check if AMQP port is listening
|
||||
echo ""
|
||||
echo "Checking AMQP port ($RABBITMQ_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$RABBITMQ_PORT" "AMQP port $RABBITMQ_PORT"
|
||||
done
|
||||
|
||||
# Check if Management port is listening
|
||||
echo ""
|
||||
echo "Checking Management port ($MANAGEMENT_PORT)..."
|
||||
for node in $TARGET; do
|
||||
assert_port_listening "$node" "$MANAGEMENT_PORT" "Management port $MANAGEMENT_PORT"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing RabbitMQ operations on $node..."
|
||||
|
||||
# Test management API - get overview
|
||||
echo " Testing management API..."
|
||||
api_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/overview")
|
||||
assert_contains "$api_result" "rabbitmq_version" "Management API overview"
|
||||
|
||||
# Test creating a queue via management API
|
||||
|
||||
echo " Creating test queue..."
|
||||
create_result=$(cmd_clean "$node" "curl -s -u guest:guest -X PUT -H 'Content-Type: application/json' \
|
||||
-d '{\"durable\":false,\"auto_delete\":false}' \
|
||||
http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue")
|
||||
# Empty response or no error means success
|
||||
if [[ -z "$create_result" ]] || [[ "$create_result" != *"error"* ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Queue creation successful [pass]"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Queue creation failed: $create_result [fail]"
|
||||
fi
|
||||
|
||||
# Small delay to ensure queue is ready
|
||||
sleep 1
|
||||
|
||||
# Test listing queues (do this before publish to verify queue exists)
|
||||
echo " Listing queues..."
|
||||
queues_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/queues")
|
||||
assert_contains "$queues_result" "test-queue" "Queue listing"
|
||||
|
||||
# Test listing exchanges
|
||||
echo " Listing exchanges..."
|
||||
exchanges_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/exchanges")
|
||||
assert_contains "$exchanges_result" "amq.direct" "Exchange listing"
|
||||
|
||||
# Test publishing a message
|
||||
echo " Publishing test message..."
|
||||
publish_result=$(cmd_clean "$node" "curl -s -u guest:guest -X POST -H 'Content-Type: application/json' \
|
||||
-d '{\"properties\":{},\"routing_key\":\"test-queue\",\"payload\":\"HelloRabbitMQ\",\"payload_encoding\":\"string\"}' \
|
||||
http://127.0.0.1:$MANAGEMENT_PORT/api/exchanges/%2F/amq.default/publish")
|
||||
assert_contains "$publish_result" "routed" "Message publish"
|
||||
|
||||
# Small delay to ensure message is available
|
||||
sleep 1
|
||||
|
||||
# Test getting messages from the queue
|
||||
echo " Consuming test message..."
|
||||
consume_result=$(cmd_clean "$node" "curl -s -u guest:guest -X POST -H 'Content-Type: application/json' \
|
||||
-d '{\"count\":1,\"ackmode\":\"ack_requeue_false\",\"encoding\":\"auto\"}' \
|
||||
http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue/get")
|
||||
assert_contains "$consume_result" "HelloRabbitMQ" "Message consume"
|
||||
|
||||
# Clean up test queue
|
||||
echo " Cleaning up test queue..."
|
||||
cmd "$node" "curl -s -u guest:guest -X DELETE http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue" > /dev/null 2>&1
|
||||
print_cleanup "Test queue deleted"
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "RabbitMQ Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "RabbitMQ Test Complete"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,20 @@
|
||||
{ config, pkgs, lib, ... }: {
|
||||
# Enable Redis using infrastructure module with multiple servers
|
||||
config.infrastructure.redis = {
|
||||
enable = true;
|
||||
servers = {
|
||||
# Default server (creates redis.service)
|
||||
"" = {
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 6379;
|
||||
};
|
||||
# Named server for testing (creates redis-cache.service)
|
||||
cache = {
|
||||
bindToIp = "127.0.0.1";
|
||||
bindToPort = 6380;
|
||||
maxMemory = "64mb";
|
||||
maxMemoryPolicy = "allkeys-lru";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
# Redis test for nix-infra-machine
|
||||
#
|
||||
# This test:
|
||||
# 1. Deploys multiple Redis servers using infrastructure.redis
|
||||
# 2. Verifies all services are running
|
||||
# 3. Tests basic Redis operations on each server
|
||||
# 4. Cleans up on teardown
|
||||
|
||||
# Server configurations: name:port
|
||||
declare -A REDIS_SERVERS=(
|
||||
["redis"]=6379
|
||||
["redis-cache"]=6380
|
||||
)
|
||||
|
||||
# Handle teardown command
|
||||
if [ "$CMD" = "teardown" ]; then
|
||||
echo "Tearing down Redis test..."
|
||||
|
||||
# Stop Redis services
|
||||
for server in "${!REDIS_SERVERS[@]}"; do
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
"systemctl stop $server 2>/dev/null || true"
|
||||
done
|
||||
|
||||
# Clean up data directories
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
|
||||
'rm -rf /var/lib/redis /var/lib/redis-cache'
|
||||
|
||||
echo "Redis teardown complete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Test Setup
|
||||
# ============================================================================
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Redis Multi-Server Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Deploy the redis configuration to test nodes
|
||||
echo "Step 1: Deploying Redis configuration..."
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--test-dir="$WORK_DIR/$TEST_DIR" \
|
||||
--target="$TARGET"
|
||||
|
||||
# Apply the configuration
|
||||
echo "Step 2: Applying NixOS configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_setup=$(date +%s)
|
||||
|
||||
# ============================================================================
|
||||
# Test Verification
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Verifying Redis deployment..."
|
||||
echo ""
|
||||
|
||||
# Wait for services to start
|
||||
for node in $TARGET; do
|
||||
for server in "${!REDIS_SERVERS[@]}"; do
|
||||
port=${REDIS_SERVERS[$server]}
|
||||
wait_for_service "$node" "$server" --timeout=30
|
||||
wait_for_redis "$node" "$port" --timeout=15
|
||||
done
|
||||
done
|
||||
|
||||
# Check if the systemd services are active
|
||||
echo ""
|
||||
echo "Checking systemd service status..."
|
||||
for node in $TARGET; do
|
||||
for server in "${!REDIS_SERVERS[@]}"; do
|
||||
assert_service_active "$node" "$server" || show_service_logs "$node" "$server" 30
|
||||
done
|
||||
done
|
||||
|
||||
# Check if Redis processes are running
|
||||
echo ""
|
||||
echo "Checking Redis processes..."
|
||||
for node in $TARGET; do
|
||||
expected_count=${#REDIS_SERVERS[@]}
|
||||
assert_process_count "$node" "redis-server" "$expected_count" "Redis"
|
||||
done
|
||||
|
||||
# Check if Redis ports are listening
|
||||
echo ""
|
||||
echo "Checking Redis ports..."
|
||||
for node in $TARGET; do
|
||||
for server in "${!REDIS_SERVERS[@]}"; do
|
||||
port=${REDIS_SERVERS[$server]}
|
||||
assert_port_listening "$node" "$port" "$server port $port"
|
||||
done
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Functional Tests
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Running functional tests..."
|
||||
echo ""
|
||||
|
||||
# Test Redis connection and basic operations on each server
|
||||
for node in $TARGET; do
|
||||
for server in "${!REDIS_SERVERS[@]}"; do
|
||||
port=${REDIS_SERVERS[$server]}
|
||||
echo "Testing $server (port $port) on $node..."
|
||||
|
||||
# Test PING command
|
||||
echo " Testing PING command..."
|
||||
ping_result=$(cmd_clean "$node" "redis-cli -p $port PING")
|
||||
assert_contains "$ping_result" "PONG" "PING successful"
|
||||
|
||||
# Test SET command
|
||||
echo " Testing SET command..."
|
||||
set_result=$(cmd_clean "$node" "redis-cli -p $port SET testkey-$server 'hello-from-$server'")
|
||||
assert_contains "$set_result" "OK" "SET operation successful"
|
||||
|
||||
# Test GET command
|
||||
echo " Testing GET command..."
|
||||
get_result=$(cmd_clean "$node" "redis-cli -p $port GET testkey-$server")
|
||||
assert_contains "$get_result" "hello-from-$server" "GET operation successful"
|
||||
|
||||
# Test INCR command
|
||||
echo " Testing INCR command..."
|
||||
cmd "$node" "redis-cli -p $port SET counter 0" > /dev/null 2>&1
|
||||
incr_result=$(cmd_clean "$node" "redis-cli -p $port INCR counter")
|
||||
assert_contains "$incr_result" "1" "INCR operation successful"
|
||||
|
||||
# Test INFO command
|
||||
echo " Testing INFO command..."
|
||||
info_result=$(cmd_clean "$node" "redis-cli -p $port INFO server | head -5")
|
||||
assert_contains "$info_result" "redis_version" "INFO command successful"
|
||||
|
||||
# Clean up test data
|
||||
echo " Cleaning up test data..."
|
||||
cmd "$node" "redis-cli -p $port FLUSHALL" > /dev/null 2>&1
|
||||
print_cleanup "Test data cleaned up"
|
||||
echo ""
|
||||
done
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Server Isolation
|
||||
# ============================================================================
|
||||
|
||||
echo "Step 5: Testing server isolation..."
|
||||
echo ""
|
||||
|
||||
for node in $TARGET; do
|
||||
echo "Testing data isolation on $node..."
|
||||
|
||||
# Set a key on the default server
|
||||
cmd "$node" "redis-cli -p 6379 SET isolation-test 'default-server'" > /dev/null 2>&1
|
||||
|
||||
# Try to get it from the cache server (should not exist)
|
||||
cache_result=$(cmd_value "$node" "redis-cli -p 6380 GET isolation-test")
|
||||
assert_empty_or_nil "$cache_result" "Servers are properly isolated"
|
||||
|
||||
# Clean up
|
||||
cmd "$node" "redis-cli -p 6379 FLUSHALL" > /dev/null 2>&1
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# Test Summary
|
||||
# ============================================================================
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Redis Test Summary"
|
||||
echo "========================================"
|
||||
|
||||
printf '+ setup %s\n' $(printTime $_start $_setup)
|
||||
printf '+ tests %s\n' $(printTime $_setup $_end)
|
||||
printf '= TOTAL %s\n' $(printTime $_start $_end)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Redis Test Complete"
|
||||
echo "========================================"
|
||||
Executable
+388
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
|
||||
WORK_DIR=${WORK_DIR:-$(dirname "$SCRIPT_DIR")}
|
||||
NIX_INFRA=${NIX_INFRA:-"nix-infra"}
|
||||
NIXOS_VERSION=${NIXOS_VERSION:-"25.11"}
|
||||
MACHINE_TYPE=${MACHINE_TYPE:-"cpx22"}
|
||||
SSH_KEY="nixinfra-machine"
|
||||
SSH_EMAIL=${SSH_EMAIL:-your-email@example.com}
|
||||
ENV=${ENV:-.env}
|
||||
SECRETS_PWD=${SECRETS_PWD:-my_secrets_password}
|
||||
TARGET=${TARGET:-"testnode001"}
|
||||
|
||||
read -r -d '' __help_text__ <<EOF || true
|
||||
|
||||
nix-infra-machine Test Runner
|
||||
=============================
|
||||
|
||||
Usage: $0 <command> [options]
|
||||
|
||||
Commands:
|
||||
create Provision and initialize test machines
|
||||
run <test-name> Run a specific test (e.g., mongodb)
|
||||
reset <test-name> Reset test state without destroying machines
|
||||
destroy Tear down all test machines
|
||||
status Run basic health checks on machines
|
||||
update <nodes> Update node configuration
|
||||
upgrade <nodes> Upgrade NixOS version on nodes
|
||||
|
||||
ssh <node> SSH into a node
|
||||
cmd --target=<node> <command> Run command on node(s)
|
||||
action --target=<node> <module> <cmd> Run app action
|
||||
port-forward --target=<node> --port-mapping=<local:remote>
|
||||
|
||||
Options:
|
||||
--env=<file> Environment file (default: .env)
|
||||
--no-teardown Don't tear down after test
|
||||
--target=<nodes> Target node(s) for commands
|
||||
--nixos-version=<version> Override version
|
||||
--machine-type=<type> Override machine type
|
||||
|
||||
Examples:
|
||||
# Run the full test cycle
|
||||
$0 create --env=.env
|
||||
$0 run mongodb --env=.env
|
||||
$0 destroy --env=.env
|
||||
|
||||
# Run test without teardown for debugging
|
||||
$0 run mongodb --no-teardown --env=.env
|
||||
|
||||
# Interactive debugging
|
||||
$0 ssh testnode001 --env=.env
|
||||
$0 cmd --target=testnode001 --env=.env "systemctl status podman-mongodb-4"
|
||||
|
||||
# Reset and re-run a test
|
||||
$0 reset mongodb --env=.env
|
||||
$0 run mongodb --env=.env
|
||||
EOF
|
||||
|
||||
if [[ "create upgrade run reset destroy update status ssh cmd action port-forward" == *"$1"* ]]; then
|
||||
CMD="$1"
|
||||
shift
|
||||
else
|
||||
echo "$__help_text__"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for i in "$@"; do
|
||||
case $i in
|
||||
--help)
|
||||
echo "$__help_text__"
|
||||
exit 0
|
||||
;;
|
||||
--no-teardown)
|
||||
NO_TEARDOWN="true"
|
||||
shift
|
||||
;;
|
||||
--env=*)
|
||||
ENV="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--target=*)
|
||||
TARGET="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--port-mapping=*)
|
||||
PORT_MAPPING="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--nixos-version=*)
|
||||
NIXOS_VERSION="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--machine-type=*)
|
||||
MACHINE_TYPE="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
REST="$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$ENV" != "" ] && [ -f "$ENV" ]; then
|
||||
source $ENV
|
||||
fi
|
||||
|
||||
# Check for nix-infra CLI if using default
|
||||
if [ "$NIX_INFRA" = "nix-infra" ] && ! command -v nix-infra >/dev/null 2>&1; then
|
||||
echo "The 'nix-infra' CLI is required for this script to work."
|
||||
echo "Visit https://github.com/jhsware/nix-infra for installation instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$HCLOUD_TOKEN" ]; then
|
||||
echo "Missing env-var HCLOUD_TOKEN. Load through .env-file that is specified through --env."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source shared helpers
|
||||
source "$SCRIPT_DIR/shared.sh"
|
||||
source "$SCRIPT_DIR/assertions.sh"
|
||||
source "$SCRIPT_DIR/timeouts.sh"
|
||||
|
||||
# ============================================================================
|
||||
# Test Runner Commands
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "run" ]; then
|
||||
if [ ! -d "$WORK_DIR" ]; then
|
||||
echo "Working directory doesn't exist ($WORK_DIR)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REST" == "" ]; then
|
||||
echo "Missing test name. Available tests:"
|
||||
ls -d "$WORK_DIR/__test__"/*/ 2>/dev/null | xargs -n1 basename | grep -v "^$"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
last_test="${REST##* }"
|
||||
for _test_name in $REST; do
|
||||
if [ ! -d "$WORK_DIR/__test__/$_test_name" ]; then
|
||||
echo "Test directory doesn't exist (__test__/$_test_name)"
|
||||
else
|
||||
echo "========================================"
|
||||
echo "Running test: $_test_name"
|
||||
echo "========================================"
|
||||
TEST_DIR="__test__/$_test_name" source "$WORK_DIR/__test__/$_test_name/test.sh"
|
||||
|
||||
if [ "$_test_name" != "$last_test" ] || [ "$NO_TEARDOWN" != "true" ]; then
|
||||
echo "Cleaning up after test: $_test_name"
|
||||
TEST_DIR="__test__/$_test_name" CMD="teardown" source "$WORK_DIR/__test__/$_test_name/test.sh"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$NO_TEARDOWN" != "true" ]; then
|
||||
echo "Resetting node configurations..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" 'rm -f /etc/nixos/$(hostname).nix'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "reset" ]; then
|
||||
if [ ! -d "$WORK_DIR" ]; then
|
||||
echo "Working directory doesn't exist ($WORK_DIR)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REST" == "" ]; then
|
||||
echo "Missing test name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning up node configuration..."
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" 'rm -f /etc/nixos/$(hostname).nix'
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
|
||||
sleep 1
|
||||
|
||||
echo "Running test teardown..."
|
||||
for _test_name in $REST; do
|
||||
if [ ! -d "$WORK_DIR/__test__/$_test_name" ]; then
|
||||
echo "Test directory doesn't exist (__test__/$_test_name)"
|
||||
else
|
||||
TEST_DIR="__test__/$_test_name" CMD="teardown" source "$WORK_DIR/__test__/$_test_name/test.sh"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Removing secrets..."
|
||||
rm -f "$WORK_DIR/secrets/"*
|
||||
|
||||
echo "...reset complete!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Management Commands
|
||||
# ============================================================================
|
||||
|
||||
destroyFleet() {
|
||||
$NIX_INFRA fleet destroy -d "$WORK_DIR" --batch \
|
||||
--target="$TARGET"
|
||||
|
||||
$NIX_INFRA ssh-key remove -d "$WORK_DIR" --batch --name="$SSH_KEY"
|
||||
|
||||
echo "Remove /secrets..."
|
||||
rm -rf "$WORK_DIR/secrets"
|
||||
}
|
||||
|
||||
|
||||
cleanupOnFail() {
|
||||
if [ $1 -ne 0 ]; then
|
||||
echo "$2"
|
||||
destroyFleet
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$CMD" = "destroy" ]; then
|
||||
destroyFleet
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "status" ]; then
|
||||
testFleet "$TARGET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
if [ "$CMD" = "update" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 update --env=$ENV [node1 node2 ...]"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet update -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--node-module="node_types/standalone_machine.nix" \
|
||||
--target="$REST" \
|
||||
--rebuild
|
||||
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--target="$REST"
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$REST" "nixos-rebuild switch --fast"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "upgrade" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 upgrade --env=$ENV [node1 node2 ...]"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet upgrade-nixos -d "$WORK_DIR" --batch --env="$ENV" --nixos-version="$NIXOS_VERSION" \
|
||||
--target="$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Commands
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "ssh" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 ssh --env=$ENV [node]"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet ssh -d "$WORK_DIR" --env="$ENV" --target="$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "cmd" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 cmd --env=$ENV --target=[node] [cmd goes here]"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "action" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 action --env=$ENV --target=[node] [module] [cmd]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r module cmd <<< "$REST"
|
||||
$NIX_INFRA fleet action -d "$WORK_DIR" --target="$TARGET" --app-module="$module" \
|
||||
--cmd="$cmd"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "port-forward" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$PORT_MAPPING" ]; then
|
||||
echo "Usage: $0 port-forward --env=$ENV --target=[node] --port-mapping=[local:remote]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OLD_IFS=$IFS
|
||||
IFS=: read LOCAL_PORT REMOTE_PORT <<< "$PORT_MAPPING"
|
||||
IFS=$OLD_IFS
|
||||
|
||||
$NIX_INFRA fleet port-forward -d "$WORK_DIR" --env="$ENV" \
|
||||
--target="$TARGET" \
|
||||
--local-port="$LOCAL_PORT" \
|
||||
--remote-port="$REMOTE_PORT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Create Command - Provision and Initialize Test Fleet
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "create" ]; then
|
||||
if [ -d "$WORK_DIR/secrets" ]; then
|
||||
echo "Found existing ./secrets, this appears to be a live project. Creating a test environment may destroy it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV" ]; then
|
||||
read -r -d '' env <<EOF || true
|
||||
# NOTE: The following secrets are required for various operations
|
||||
# by the nix-infra CLI. Make sure they are encrypted when not in use
|
||||
SSH_KEY=$SSH_KEY
|
||||
SSH_EMAIL=$SSH_EMAIL
|
||||
|
||||
# The following token is needed to perform provisioning and discovery
|
||||
HCLOUD_TOKEN=$HCLOUD_TOKEN
|
||||
|
||||
# Password for the secrets that are stored in this repo
|
||||
# These need to be kept secret.
|
||||
SECRETS_PWD=$SECRETS_PWD
|
||||
EOF
|
||||
echo "$env" > "$WORK_DIR/.env"
|
||||
fi
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
$NIX_INFRA init -d "$WORK_DIR" --no-cert-auth --batch
|
||||
ssh-add "$WORK_DIR/ssh/$SSH_KEY"
|
||||
|
||||
echo "*** Provisioning NixOS $NIXOS_VERSION ***"
|
||||
$NIX_INFRA fleet provision -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--ssh-key=$SSH_KEY \
|
||||
--location=hel1 \
|
||||
--machine-type="$MACHINE_TYPE" \
|
||||
--node-names="$TARGET"
|
||||
|
||||
cleanupOnFail $? "WARNING: Provisioning failed! Cleaning up..."
|
||||
|
||||
_provision=$(date +%s)
|
||||
|
||||
$NIX_INFRA fleet init-machine -d "$WORK_DIR" --batch --env="$ENV" \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--target="$TARGET" \
|
||||
--node-module="node_types/standalone_machine.nix"
|
||||
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_init_nodes=$(date +%s)
|
||||
|
||||
# Verify the operation of the test fleet
|
||||
echo "******************************************"
|
||||
testFleet "$TARGET"
|
||||
echo "******************************************"
|
||||
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo " ** ** "
|
||||
echo " ** ** "
|
||||
echo "******************************************"
|
||||
|
||||
printTime() {
|
||||
local _start=$1; local _end=$2; local _secs=$((_end-_start))
|
||||
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
|
||||
}
|
||||
printf '+ provision %s\n' "$(printTime $_start $_provision)"
|
||||
printf '+ init %s\n' "$(printTime $_provision $_init_nodes)"
|
||||
printf '+ test %s\n' "$(printTime $_init_nodes $_end)"
|
||||
printf '= SUM %s\n' "$(printTime $_start $_end)"
|
||||
echo "***************** DONE *******************"
|
||||
fi
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helper functions for nix-infra-machine tests
|
||||
|
||||
# ============================================================================
|
||||
# Colors for Test Output
|
||||
# ============================================================================
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ============================================================================
|
||||
# Utility Functions
|
||||
# ============================================================================
|
||||
|
||||
appendWithLineBreak() {
|
||||
if [ -z "$1" ]; then
|
||||
printf '%s' "$2"
|
||||
else
|
||||
printf '%s\n%s' "$1" "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd() {
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$1" "$2"
|
||||
}
|
||||
|
||||
# Get command output with node prefix stripped and whitespace trimmed
|
||||
# Use for single values that need arithmetic or exact comparison
|
||||
# Example: count=$(cmd_value "$node" "pgrep -c redis-server || echo 0")
|
||||
cmd_value() {
|
||||
local node="$1"
|
||||
local command="$2"
|
||||
local output
|
||||
output=$(cmd "$node" "$command")
|
||||
# Strip "nodename: " prefix and trim whitespace
|
||||
echo "$output" | sed "s/^${node}: //" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
# Get command output with node prefix stripped but preserving structure
|
||||
# Use for multi-line output or when whitespace matters
|
||||
# Example: config=$(cmd_clean "$node" "cat /etc/config")
|
||||
cmd_clean() {
|
||||
local node="$1"
|
||||
local command="$2"
|
||||
local output
|
||||
output=$(cmd "$node" "$command")
|
||||
# Strip "nodename: " prefix from each line
|
||||
echo "$output" | sed "s/^${node}: //"
|
||||
}
|
||||
|
||||
|
||||
printTime() {
|
||||
local _start=$1; local _end=$2; local _secs=$((_end-_start))
|
||||
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Common Commands (used by run-tests.sh command parsing)
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "pull" ]; then
|
||||
git -C "$WORK_DIR" pull
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "ssh" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 ssh --env=$ENV [node]"
|
||||
exit 1
|
||||
fi
|
||||
HCLOUD_TOKEN=$HCLOUD_TOKEN hcloud server ssh $REST -i "$WORK_DIR/ssh/$SSH_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "cmd" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 cmd --env=$ENV --target=[node] [cmd goes here]"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "action" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 action --env=$ENV --target=[node] [module] [cmd]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r module action_cmd <<< "$REST"
|
||||
$NIX_INFRA fleet action -d "$WORK_DIR" --target="$TARGET" --app-module="$module" \
|
||||
--cmd="$action_cmd"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Health Check Functions
|
||||
# ============================================================================
|
||||
|
||||
checkNixos() {
|
||||
echo "Checking NixOS..."
|
||||
local NODES="$1"
|
||||
local node
|
||||
local _nixos_fail=""
|
||||
|
||||
for node in $NODES; do
|
||||
local output=$(cmd "$node" "uname -a" 2>&1)
|
||||
local result="$output"
|
||||
if [[ "$result" == *"NixOS"* ]]; then
|
||||
echo " ✓ nixos: ok ($node)"
|
||||
else
|
||||
echo " ✗ nixos: fail ($node)"
|
||||
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
|
||||
echo " $output"
|
||||
fi
|
||||
_nixos_fail="true"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$_nixos_fail" ]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkPodman() {
|
||||
echo "Checking Podman..."
|
||||
local NODES="$1"
|
||||
local node
|
||||
local _failed=""
|
||||
|
||||
for node in $NODES; do
|
||||
local output=$(cmd "$node" "podman --version" 2>&1)
|
||||
local result="$output"
|
||||
if [[ "$result" == *"podman version"* ]]; then
|
||||
echo " ✓ podman: ok ($node)"
|
||||
else
|
||||
echo " ✗ podman: not installed or not running ($node)"
|
||||
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
|
||||
echo " $output"
|
||||
fi
|
||||
_failed="yes"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$_failed" ]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkService() {
|
||||
local NODE="$1"
|
||||
local SERVICE="$2"
|
||||
local output=$(cmd "$NODE" "systemctl is-active $SERVICE" 2>&1)
|
||||
local result="$output"
|
||||
if [[ "$result" == *"active"* ]]; then
|
||||
echo " ✓ $SERVICE: active ($NODE)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ $SERVICE: inactive ($NODE)"
|
||||
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
|
||||
echo " $output"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkServiceOnNodes() {
|
||||
echo "Checking service: $2"
|
||||
local NODES="$1"
|
||||
local SERVICE="$2"
|
||||
local node
|
||||
local _failed=""
|
||||
|
||||
for node in $NODES; do
|
||||
if ! checkService "$node" "$SERVICE"; then
|
||||
_failed="yes"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$_failed" ]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkHttpEndpoint() {
|
||||
local NODE="$1"
|
||||
local URL="$2"
|
||||
local EXPECTED="$3"
|
||||
|
||||
local output=$(cmd "$NODE" "curl -s --max-time 5 '$URL'" 2>&1)
|
||||
local result="$output"
|
||||
if [[ "$result" == *"$EXPECTED"* ]]; then
|
||||
echo " ✓ HTTP $URL: ok ($NODE)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ HTTP $URL: expected '$EXPECTED' ($NODE)"
|
||||
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
|
||||
echo " $output"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkTcpPort() {
|
||||
local NODE="$1"
|
||||
local HOST="$2"
|
||||
local PORT="$3"
|
||||
|
||||
local output=$(cmd "$NODE" "nc -zv $HOST $PORT 2>&1")
|
||||
local result="$output"
|
||||
if [[ "$result" == *"succeeded"* ]] || [[ "$result" == *"open"* ]] || [[ "$result" == *"Connected"* ]]; then
|
||||
echo " ✓ TCP $HOST:$PORT: open ($NODE)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ TCP $HOST:$PORT: closed ($NODE)"
|
||||
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
|
||||
echo " $output"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Test Function
|
||||
# ============================================================================
|
||||
|
||||
testFleet() {
|
||||
local NODES="$1"
|
||||
echo "=========================================="
|
||||
echo "Running Fleet Health Checks"
|
||||
echo "=========================================="
|
||||
|
||||
local _failed=""
|
||||
|
||||
if ! checkNixos "$NODES"; then
|
||||
_failed="yes"
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
if [ -n "$_failed" ]; then
|
||||
echo "Health checks: FAILED"
|
||||
return 1
|
||||
else
|
||||
echo "Health checks: PASSED"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Container/Pod Test Helpers
|
||||
# ============================================================================
|
||||
|
||||
waitForContainer() {
|
||||
local NODE="$1"
|
||||
local CONTAINER="$2"
|
||||
local TIMEOUT="${3:-60}"
|
||||
|
||||
echo "Waiting for container $CONTAINER on $NODE (timeout: ${TIMEOUT}s)..."
|
||||
local elapsed=0
|
||||
local last_output=""
|
||||
while [ $elapsed -lt $TIMEOUT ]; do
|
||||
last_output=$(cmd "$NODE" "podman ps --filter name=$CONTAINER --format '{{.Status}}'" 2>&1)
|
||||
local status="$last_output"
|
||||
if [[ "$status" == *"Up"* ]]; then
|
||||
echo " ✓ Container $CONTAINER is running"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
echo " ✗ Container $CONTAINER did not start within ${TIMEOUT}s"
|
||||
if [ -n "$last_output" ] && [[ "$last_output" == ERROR:* ]]; then
|
||||
echo " $last_output"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
waitForService() {
|
||||
local NODE="$1"
|
||||
local SERVICE="$2"
|
||||
local TIMEOUT="${3:-60}"
|
||||
|
||||
echo "Waiting for service $SERVICE on $NODE (timeout: ${TIMEOUT}s)..."
|
||||
local elapsed=0
|
||||
local last_output=""
|
||||
while [ $elapsed -lt $TIMEOUT ]; do
|
||||
last_output=$(cmd "$NODE" "systemctl is-active $SERVICE" 2>&1)
|
||||
local status="$last_output"
|
||||
if [[ "$status" == *"active"* ]]; then
|
||||
echo " ✓ Service $SERVICE is active"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
echo " ✗ Service $SERVICE did not become active within ${TIMEOUT}s"
|
||||
if [ -n "$last_output" ] && [[ "$last_output" == ERROR:* ]]; then
|
||||
echo " $last_output"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
getContainerLogs() {
|
||||
local NODE="$1"
|
||||
local CONTAINER="$2"
|
||||
local LINES="${3:-50}"
|
||||
|
||||
echo "--- Container logs for $CONTAINER on $NODE ---"
|
||||
cmd "$NODE" "podman logs --tail $LINES $CONTAINER" 2>&1
|
||||
echo "--- End of logs ---"
|
||||
}
|
||||
|
||||
getServiceLogs() {
|
||||
local NODE="$1"
|
||||
local SERVICE="$2"
|
||||
local LINES="${3:-50}"
|
||||
|
||||
echo "--- Service logs for $SERVICE on $NODE ---"
|
||||
cmd "$NODE" "journalctl -n $LINES -u $SERVICE" 2>&1
|
||||
echo "--- End of logs ---"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Info output
|
||||
# ============================================================================
|
||||
|
||||
# Print info line (not pass/fail)
|
||||
# Usage: print_info "label" "value"
|
||||
print_info() {
|
||||
local label="$1"
|
||||
local value="$2"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} $label: $value [info]"
|
||||
}
|
||||
|
||||
# Print cleanup success
|
||||
# Usage: print_cleanup "label"
|
||||
print_cleanup() {
|
||||
local label="$1"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} $label [pass]"
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env bash
|
||||
# Timeout handling library for nix-infra-machine tests
|
||||
# Provides functions to wait for conditions with configurable timeouts
|
||||
#
|
||||
# All wait functions follow this pattern:
|
||||
# wait_for_* [args...] [--timeout=N] [--interval=N] [--silent]
|
||||
# Returns 0 on success, 1 on timeout
|
||||
# Prints progress unless --silent is specified
|
||||
#
|
||||
# Requires: cmd, cmd_value, cmd_clean functions from shared.sh
|
||||
# Requires: Colors (GREEN, RED, YELLOW, NC) from shared.sh
|
||||
|
||||
# Default timeout values (can be overridden)
|
||||
DEFAULT_TIMEOUT=60
|
||||
DEFAULT_INTERVAL=2
|
||||
|
||||
# ============================================================================
|
||||
# Generic Wait Function
|
||||
# ============================================================================
|
||||
|
||||
# Wait for a condition to be true
|
||||
# Usage: wait_for_condition "label" "command" "expected_pattern" [--timeout=N] [--interval=N] [--silent]
|
||||
# The command is run locally (not on a remote node)
|
||||
wait_for_condition() {
|
||||
local label="$1"
|
||||
local command="$2"
|
||||
local expected="$3"
|
||||
shift 3
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
# Parse optional arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for $label (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(eval "$command" 2>/dev/null)
|
||||
|
||||
if [[ "$result" == *"$expected"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Service Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for a systemd service to become active
|
||||
# Usage: wait_for_service "$node" "service-name" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_service() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for $service to be active (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local status
|
||||
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'unknown'")
|
||||
|
||||
if [[ "$status" == "active" ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}active${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC} (status: $status)"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a oneshot service to complete successfully
|
||||
# Usage: wait_for_service_completed "$node" "service-name" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_service_completed() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for $service to complete (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local status
|
||||
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'unknown'")
|
||||
|
||||
if [[ "$status" == "inactive" ]]; then
|
||||
# Check exit status
|
||||
local exit_status
|
||||
exit_status=$(cmd_value "$node" "systemctl show -p ExecMainStatus $service --value")
|
||||
if [[ "$exit_status" == "0" ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}completed${NC} (${elapsed}s)"
|
||||
return 0
|
||||
else
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}failed${NC} (exit: $exit_status)"
|
||||
return 1
|
||||
fi
|
||||
elif [[ "$status" == "failed" ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}failed${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Port Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for a port to start listening
|
||||
# Usage: wait_for_port "$node" "port" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_port() {
|
||||
local node="$1"
|
||||
local port="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for port $port to listen (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(cmd "$node" "ss -tlnp 2>/dev/null | grep ':$port '" 2>/dev/null)
|
||||
|
||||
if [[ "$result" == *":$port"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}listening${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for multiple ports to start listening
|
||||
# Usage: wait_for_ports "$node" "port1 port2 port3" [--timeout=N] [--interval=N]
|
||||
wait_for_ports() {
|
||||
local node="$1"
|
||||
local ports="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
for port in $ports; do
|
||||
if ! wait_for_port "$node" "$port" --timeout="$timeout" --interval="$interval"; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HTTP Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for an HTTP endpoint to respond with expected status code
|
||||
# Usage: wait_for_http "$node" "url" "expected_codes" [--timeout=N] [--interval=N] [--silent]
|
||||
# expected_codes can be space-separated: "200 302 303"
|
||||
wait_for_http() {
|
||||
local node="$1"
|
||||
local url="$2"
|
||||
local expected="${3:-200}"
|
||||
shift 3
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for HTTP $url (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local code
|
||||
code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' --max-time 5 '$url' 2>/dev/null || echo '000'")
|
||||
|
||||
for exp in $expected; do
|
||||
if [[ "$code" == "$exp" ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}HTTP $code${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC} (last: HTTP $code)"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for an HTTP endpoint to contain expected content
|
||||
# Usage: wait_for_http_content "$node" "url" "expected_string" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_http_content() {
|
||||
local node="$1"
|
||||
local url="$2"
|
||||
local expected="$3"
|
||||
shift 3
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for HTTP content '$expected' (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local response
|
||||
response=$(cmd_clean "$node" "curl -s --max-time 5 '$url' 2>/dev/null")
|
||||
|
||||
if [[ "$response" == *"$expected"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}found${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Container Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for a podman container to be running
|
||||
# Usage: wait_for_container "$node" "container-name" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_container() {
|
||||
local node="$1"
|
||||
local container="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for container $container (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local status
|
||||
status=$(cmd_clean "$node" "podman ps --filter name=$container --format '{{.Status}}' 2>/dev/null")
|
||||
|
||||
if [[ "$status" == *"Up"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}running${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Database Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for PostgreSQL to accept connections
|
||||
# Usage: wait_for_postgresql "$node" [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_postgresql() {
|
||||
local node="$1"
|
||||
shift
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for PostgreSQL to accept connections (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(cmd_clean "$node" "sudo -u postgres psql -c 'SELECT 1;' 2>/dev/null")
|
||||
|
||||
if [[ "$result" == *"1"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for Redis to respond to PING
|
||||
# Usage: wait_for_redis "$node" [port] [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_redis() {
|
||||
local node="$1"
|
||||
local port="${2:-6379}"
|
||||
shift 2 2>/dev/null || shift 1
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for Redis on port $port (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(cmd_clean "$node" "redis-cli -p $port PING 2>/dev/null")
|
||||
|
||||
if [[ "$result" == *"PONG"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for MongoDB to accept connections
|
||||
# Usage: wait_for_mongodb "$node" [port] [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_mongodb() {
|
||||
local node="$1"
|
||||
local port="${2:-27017}"
|
||||
shift 2 2>/dev/null || shift 1
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for MongoDB on port $port (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(cmd_clean "$node" "mongosh --port $port --quiet --eval 'db.runCommand({ping:1})' 2>/dev/null")
|
||||
|
||||
if [[ "$result" == *"ok"* ]]; then
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for Elasticsearch/OpenSearch cluster to be ready
|
||||
# Usage: wait_for_elasticsearch "$node" [port] [--timeout=N] [--interval=N] [--silent]
|
||||
wait_for_elasticsearch() {
|
||||
local node="$1"
|
||||
local port="${2:-9200}"
|
||||
shift 2 2>/dev/null || shift 1
|
||||
|
||||
local timeout=${DEFAULT_TIMEOUT:-60}
|
||||
local interval=$DEFAULT_INTERVAL
|
||||
local silent=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
--interval=*) interval="${1#*=}" ;;
|
||||
--silent) silent=true ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -n " Waiting for Elasticsearch on port $port (${timeout}s timeout)..."
|
||||
|
||||
local elapsed=0
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local result
|
||||
result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$port/_cluster/health 2>/dev/null")
|
||||
|
||||
if [[ "$result" == *"cluster_name"* ]]; then
|
||||
local status
|
||||
status=$(echo "$result" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (status: $status, ${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$silent" == false ]] && echo -n "."
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Composite Wait Functions
|
||||
# ============================================================================
|
||||
|
||||
# Wait for a service and its port to be ready
|
||||
# Usage: wait_for_service_and_port "$node" "service" "port" [--timeout=N]
|
||||
wait_for_service_and_port() {
|
||||
local node="$1"
|
||||
local service="$2"
|
||||
local port="$3"
|
||||
shift 3
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Split timeout between service and port
|
||||
local half_timeout=$((timeout / 2))
|
||||
|
||||
if ! wait_for_service "$node" "$service" --timeout="$half_timeout"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! wait_for_port "$node" "$port" --timeout="$half_timeout"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Wait for multiple services to be active
|
||||
# Usage: wait_for_services "$node" "svc1 svc2 svc3" [--timeout=N]
|
||||
wait_for_services() {
|
||||
local node="$1"
|
||||
local services="$2"
|
||||
shift 2
|
||||
|
||||
local timeout=$DEFAULT_TIMEOUT
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--timeout=*) timeout="${1#*=}" ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
for service in $services; do
|
||||
if ! wait_for_service "$node" "$service" --timeout="$timeout"; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Timeout Wrapper
|
||||
# ============================================================================
|
||||
|
||||
# Run a command with a timeout (uses bash timeout if available)
|
||||
# Usage: with_timeout 30 "command to run"
|
||||
with_timeout() {
|
||||
local timeout="$1"
|
||||
shift
|
||||
local command="$*"
|
||||
|
||||
if command -v timeout &> /dev/null; then
|
||||
timeout "$timeout" bash -c "$command"
|
||||
else
|
||||
# Fallback for systems without timeout command
|
||||
eval "$command" &
|
||||
local pid=$!
|
||||
local count=0
|
||||
while kill -0 $pid 2>/dev/null; do
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
if [[ $count -ge $timeout ]]; then
|
||||
kill -9 $pid 2>/dev/null
|
||||
return 124 # Same exit code as timeout command
|
||||
fi
|
||||
done
|
||||
wait $pid
|
||||
return $?
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "beiwe-backend";
|
||||
defaultPort = 8080;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
# Build the beiwe-backend package
|
||||
beiwePackage = if cfg.package != null then cfg.package else
|
||||
pkgs.callPackage ./package.nix {
|
||||
rev = cfg.version;
|
||||
};
|
||||
|
||||
# Construct the Celery broker URL from RabbitMQ settings
|
||||
celeryBrokerUrl = if cfg.celery.enable then
|
||||
"amqp://${cfg.celery.rabbitmq.user}:${cfg.celery.rabbitmq.password}@${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}/${cfg.celery.rabbitmq.vhost}"
|
||||
else "";
|
||||
|
||||
# Environment variables for beiwe-backend configuration
|
||||
# See: https://github.com/jhsware/beiwe-backend (fork with env var support)
|
||||
beiweEnvironment = {
|
||||
# Required settings
|
||||
DOMAIN_NAME = cfg.domainName;
|
||||
FLASK_SECRET_KEY = cfg.flaskSecretKey;
|
||||
SYSADMIN_EMAILS = cfg.sysadminEmails;
|
||||
|
||||
# Database settings (PostgreSQL)
|
||||
RDS_DB_NAME = cfg.database.name;
|
||||
RDS_USERNAME = cfg.database.user;
|
||||
RDS_PASSWORD = cfg.database.password;
|
||||
RDS_HOSTNAME = cfg.database.host;
|
||||
RDS_PORT = toString cfg.database.port;
|
||||
|
||||
# PostgreSQL SSL mode
|
||||
# Multiple env vars to ensure compatibility with different Django/psycopg versions
|
||||
PGSSLMODE = cfg.database.sslmode;
|
||||
DATABASE_SSLMODE = cfg.database.sslmode;
|
||||
|
||||
# S3/MinIO settings
|
||||
S3_BUCKET = cfg.s3.bucket;
|
||||
AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
|
||||
AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
|
||||
BEIWE_SERVER_AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
|
||||
BEIWE_SERVER_AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
|
||||
S3_ACCESS_CREDENTIALS_USER = cfg.s3.accessKeyId;
|
||||
S3_ACCESS_CREDENTIALS_KEY = cfg.s3.secretAccessKey;
|
||||
|
||||
# Django settings
|
||||
DJANGO_SETTINGS_MODULE = "config.django_settings";
|
||||
} // (lib.optionalAttrs (cfg.s3.endpoint != "") {
|
||||
# Custom S3 endpoint for MinIO
|
||||
S3_ENDPOINT_URL = cfg.s3.endpoint;
|
||||
AWS_S3_ENDPOINT_URL = cfg.s3.endpoint;
|
||||
}) // (lib.optionalAttrs (cfg.sentry.dsn != "") {
|
||||
# Sentry error tracking (optional)
|
||||
SENTRY_ELASTIC_BEANSTALK_DSN = cfg.sentry.dsn;
|
||||
SENTRY_DATA_PROCESSING_DSN = cfg.sentry.dsn;
|
||||
}) // (lib.optionalAttrs cfg.celery.enable {
|
||||
# Celery/RabbitMQ settings
|
||||
CELERY_BROKER_URL = celeryBrokerUrl;
|
||||
BROKER_URL = celeryBrokerUrl;
|
||||
# jhsware fork environment variables for Celery configuration
|
||||
# These replace the manager_ip file requirement
|
||||
CELERY_MANAGER_IP = "${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}";
|
||||
CELERY_PASSWORD = cfg.celery.rabbitmq.password;
|
||||
}) // cfg.extraEnvironment;
|
||||
|
||||
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.beiwe-backend";
|
||||
|
||||
# ==========================================================================
|
||||
# Package and Version Configuration
|
||||
# ==========================================================================
|
||||
|
||||
version = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Git commit hash of beiwe-backend to install.
|
||||
|
||||
Uses jhsware fork which adds environment variable support for Celery.
|
||||
Supported versions are defined in package.nix.
|
||||
|
||||
See package.nix for instructions on adding new versions.
|
||||
'';
|
||||
default = "93be878"; # jhsware fork with CELERY_MANAGER_IP/CELERY_PASSWORD env var support
|
||||
example = "main";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
description = ''
|
||||
Custom beiwe-backend package to use. If null, the package will be built
|
||||
using the version specified in 'version' option.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Network Configuration
|
||||
# ==========================================================================
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind beiwe-backend to.";
|
||||
default = "127.0.0.1";
|
||||
example = "0.0.0.0";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for beiwe-backend web interface.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall for beiwe-backend.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
domainName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Domain name for the Beiwe backend (used in DOMAIN_NAME env var).";
|
||||
default = "localhost:8080";
|
||||
example = "beiwe.example.com";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Security Configuration
|
||||
# ==========================================================================
|
||||
|
||||
flaskSecretKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
A unique, cryptographically secure string for Flask sessions.
|
||||
IMPORTANT: Change this in production!
|
||||
'';
|
||||
default = "CHANGE_ME_IN_PRODUCTION_use_a_random_string";
|
||||
example = "your-super-secret-random-key-here";
|
||||
};
|
||||
|
||||
sysadminEmails = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "System administrator email addresses (comma-separated).";
|
||||
default = "sysadmin@localhost";
|
||||
example = "admin@example.com";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Data Directory
|
||||
# ==========================================================================
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Directory where beiwe-backend data is stored.";
|
||||
default = "/var/lib/beiwe-backend";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Database Configuration (PostgreSQL)
|
||||
# ==========================================================================
|
||||
database = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL host.";
|
||||
default = "/run/postgresql";
|
||||
example = "localhost";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "PostgreSQL port.";
|
||||
default = 5432;
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL database name.";
|
||||
default = "beiwe";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL user.";
|
||||
default = "beiwe";
|
||||
};
|
||||
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
PostgreSQL password. Required by Beiwe even when using trust authentication.
|
||||
For trust authentication, use an empty string or placeholder value.
|
||||
'';
|
||||
default = "unused_with_trust_auth";
|
||||
example = "secure-password-here";
|
||||
};
|
||||
|
||||
passwordSecretName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the secret containing the PostgreSQL password.
|
||||
The secret should be placed at /run/secrets/<n>.
|
||||
If null, peer/socket authentication is assumed.
|
||||
'';
|
||||
default = null;
|
||||
example = "beiwe-db-password";
|
||||
};
|
||||
|
||||
sslmode = lib.mkOption {
|
||||
type = lib.types.enum [ "disable" "allow" "prefer" "require" "verify-ca" "verify-full" ];
|
||||
description = ''
|
||||
PostgreSQL SSL mode. For local development without SSL certificates,
|
||||
use "disable". For production with SSL, use "require" or "verify-full".
|
||||
|
||||
See: https://www.postgresql.org/docs/current/libpq-ssl.html
|
||||
'';
|
||||
default = "prefer";
|
||||
example = "disable";
|
||||
};
|
||||
|
||||
createLocally = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Whether to create the database user locally.
|
||||
This requires PostgreSQL to be running locally with trust or peer authentication.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# S3/MinIO Configuration
|
||||
# ==========================================================================
|
||||
s3 = {
|
||||
bucket = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "S3 bucket name for data storage.";
|
||||
default = "beiwe-data";
|
||||
};
|
||||
|
||||
accessKeyId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "AWS/MinIO access key ID.";
|
||||
default = "";
|
||||
example = "minioadmin";
|
||||
};
|
||||
|
||||
secretAccessKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "AWS/MinIO secret access key.";
|
||||
default = "";
|
||||
example = "minioadmin";
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Custom S3 endpoint URL for MinIO or other S3-compatible storage.
|
||||
Leave empty for AWS S3.
|
||||
'';
|
||||
default = "";
|
||||
example = "http://localhost:9000";
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Sentry Configuration (Optional)
|
||||
# ==========================================================================
|
||||
sentry = {
|
||||
dsn = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Sentry DSN for error tracking. Leave empty to disable.";
|
||||
default = "";
|
||||
example = "https://xxx@sentry.io/xxx";
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Celery Configuration (Optional - for background tasks)
|
||||
# ==========================================================================
|
||||
celery = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable Celery worker for background task processing.
|
||||
|
||||
When enabled, the following features become available:
|
||||
- Push notifications to mobile apps
|
||||
- Data processing pipelines
|
||||
- Forest analysis integration
|
||||
|
||||
Requires RabbitMQ to be running and accessible.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
rabbitmq = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RabbitMQ host for Celery broker.";
|
||||
default = "127.0.0.1";
|
||||
example = "rabbitmq.example.com";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "RabbitMQ port.";
|
||||
default = 5672;
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RabbitMQ user.";
|
||||
default = "guest";
|
||||
example = "beiwe";
|
||||
};
|
||||
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RabbitMQ password.";
|
||||
default = "guest";
|
||||
example = "secure-password";
|
||||
};
|
||||
|
||||
vhost = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RabbitMQ virtual host.";
|
||||
default = "";
|
||||
example = "beiwe";
|
||||
};
|
||||
};
|
||||
|
||||
concurrency = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Number of concurrent Celery worker processes.";
|
||||
default = 2;
|
||||
};
|
||||
|
||||
queues = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Celery queues to process. Beiwe uses separate queues for different tasks:
|
||||
- celery (default queue)
|
||||
- data_processing
|
||||
- push_notifications
|
||||
- forest
|
||||
'';
|
||||
default = [ "celery" "data_processing" "push_notifications" "forest" ];
|
||||
};
|
||||
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.enum [ "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" ];
|
||||
description = "Celery worker log level.";
|
||||
default = "INFO";
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Gunicorn Configuration
|
||||
# ==========================================================================
|
||||
gunicorn = {
|
||||
workers = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Number of Gunicorn worker processes.";
|
||||
default = 4;
|
||||
};
|
||||
|
||||
threads = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Number of threads per worker.";
|
||||
default = 2;
|
||||
};
|
||||
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Request timeout in seconds.";
|
||||
default = 120;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Extra Environment Variables
|
||||
# ==========================================================================
|
||||
|
||||
extraEnvironment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
description = ''
|
||||
Additional environment variables for beiwe-backend.
|
||||
These are passed directly to the service.
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
DEBUG = "false";
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Reverse Proxy Configuration
|
||||
# ==========================================================================
|
||||
|
||||
reverseProxy = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable nginx reverse proxy for beiwe-backend.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Hostname for the reverse proxy.";
|
||||
default = "localhost";
|
||||
example = "beiwe.example.com";
|
||||
};
|
||||
|
||||
ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable SSL/HTTPS for the reverse proxy.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# ==========================================================================
|
||||
# Beiwe User and Group
|
||||
# ==========================================================================
|
||||
|
||||
users.users.beiwe = {
|
||||
isSystemUser = true;
|
||||
group = "beiwe";
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
description = "Beiwe backend service user";
|
||||
};
|
||||
|
||||
users.groups.beiwe = {};
|
||||
|
||||
# ==========================================================================
|
||||
# Beiwe Backend Systemd Service (Web Server)
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.beiwe-backend = {
|
||||
description = "Beiwe Backend - Digital Phenotyping Research Platform";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "postgresql.service" ] ++
|
||||
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
|
||||
lib.optionals cfg.celery.enable [ "rabbitmq.service" ];
|
||||
wants = lib.optionals cfg.database.createLocally [
|
||||
"beiwe-db-setup.service"
|
||||
];
|
||||
requires = lib.optionals cfg.database.createLocally [
|
||||
"postgresql.service"
|
||||
];
|
||||
|
||||
environment = beiweEnvironment;
|
||||
|
||||
# Load database password from secret file if specified
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "beiwe";
|
||||
Group = "beiwe";
|
||||
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
|
||||
|
||||
ExecStartPre = let
|
||||
preStartScript = pkgs.writeShellScript "beiwe-pre-start" ''
|
||||
# Run database migrations
|
||||
${beiwePackage}/bin/beiwe-manage migrate --noinput || true
|
||||
'';
|
||||
in "+${preStartScript}";
|
||||
|
||||
ExecStart = ''
|
||||
${beiwePackage}/bin/beiwe-gunicorn wsgi:application \
|
||||
--bind ${cfg.bindToIp}:${toString cfg.bindToPort} \
|
||||
--workers ${toString cfg.gunicorn.workers} \
|
||||
--threads ${toString cfg.gunicorn.threads} \
|
||||
--timeout ${toString cfg.gunicorn.timeout} \
|
||||
--access-logfile - \
|
||||
--error-logfile -
|
||||
'';
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
|
||||
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
|
||||
});
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Beiwe Celery Worker Service (Background Task Processing)
|
||||
# ==========================================================================
|
||||
#
|
||||
# Uses jhsware fork which supports CELERY_MANAGER_IP and CELERY_PASSWORD
|
||||
# environment variables instead of requiring a manager_ip file.
|
||||
|
||||
systemd.services.beiwe-celery-worker = lib.mkIf cfg.celery.enable {
|
||||
description = "Beiwe Celery Worker - Background Task Processing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "postgresql.service" "rabbitmq.service" ];
|
||||
requires = [ "rabbitmq.service" ];
|
||||
wants = [ "beiwe-backend.service" ];
|
||||
|
||||
environment = beiweEnvironment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "beiwe";
|
||||
Group = "beiwe";
|
||||
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
|
||||
|
||||
# Celery command pattern from beiwe-backend wiki:
|
||||
# python3 -m celery -A services.celery_data_processing worker -Q ...
|
||||
ExecStart = let
|
||||
queuesArg = lib.concatStringsSep "," cfg.celery.queues;
|
||||
in ''
|
||||
${beiwePackage}/bin/beiwe-celery \
|
||||
-A services.celery_data_processing \
|
||||
worker \
|
||||
--queues=${queuesArg} \
|
||||
--concurrency=${toString cfg.celery.concurrency} \
|
||||
--loglevel=${cfg.celery.logLevel}
|
||||
'';
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir "/tmp" ];
|
||||
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
|
||||
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
|
||||
});
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Beiwe Celery Beat Service (Scheduled Tasks)
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.beiwe-celery-beat = lib.mkIf cfg.celery.enable {
|
||||
description = "Beiwe Celery Beat - Task Scheduler";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "rabbitmq.service" "beiwe-celery-worker.service" ];
|
||||
requires = [ "rabbitmq.service" ];
|
||||
wants = [ "beiwe-celery-worker.service" ];
|
||||
|
||||
environment = beiweEnvironment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "beiwe";
|
||||
Group = "beiwe";
|
||||
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
|
||||
|
||||
ExecStart = ''
|
||||
${beiwePackage}/bin/beiwe-celery \
|
||||
-A services.celery_data_processing \
|
||||
beat \
|
||||
--loglevel=${cfg.celery.logLevel} \
|
||||
--schedule=${cfg.dataDir}/celerybeat-schedule
|
||||
'';
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
|
||||
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
|
||||
});
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# PostgreSQL Database Setup (Optional)
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.beiwe-db-setup = lib.mkIf cfg.database.createLocally {
|
||||
description = "Create Beiwe database and user";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "postgresql.service" ];
|
||||
requires = [ "postgresql.service" ];
|
||||
before = [ "beiwe-backend.service" ];
|
||||
requiredBy = [ "beiwe-backend.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "postgres";
|
||||
};
|
||||
script = let
|
||||
dbUser = cfg.database.user;
|
||||
dbName = cfg.database.name;
|
||||
dbHost = cfg.database.host;
|
||||
dbPort = toString cfg.database.port;
|
||||
in ''
|
||||
set -euo pipefail
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort} 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL is ready"
|
||||
|
||||
# Create database user if it doesn't exist
|
||||
echo "Checking if user '${dbUser}' exists..."
|
||||
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1; then
|
||||
echo "Creating user '${dbUser}'..."
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
|
||||
else
|
||||
echo "User '${dbUser}' already exists"
|
||||
fi
|
||||
|
||||
# Create database if it doesn't exist
|
||||
echo "Checking if database '${dbName}' exists..."
|
||||
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_database WHERE datname='${dbName}'" | grep -q 1; then
|
||||
echo "Creating database '${dbName}'..."
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE DATABASE ${dbName} OWNER ${dbUser}"
|
||||
else
|
||||
echo "Database '${dbName}' already exists"
|
||||
fi
|
||||
|
||||
# Grant privileges on database (idempotent)
|
||||
echo "Granting privileges..."
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}" || true
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}" || true
|
||||
|
||||
echo "Database setup complete"
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Nginx Reverse Proxy (Optional)
|
||||
# ==========================================================================
|
||||
|
||||
services.nginx = lib.mkIf cfg.reverseProxy.enable {
|
||||
enable = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = cfg.reverseProxy.ssl;
|
||||
|
||||
virtualHosts.${cfg.reverseProxy.hostName} = {
|
||||
forceSSL = cfg.reverseProxy.ssl;
|
||||
enableACME = cfg.reverseProxy.ssl;
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout ${toString cfg.gunicorn.timeout}s;
|
||||
proxy_connect_timeout ${toString cfg.gunicorn.timeout}s;
|
||||
client_max_body_size 100M;
|
||||
'';
|
||||
};
|
||||
|
||||
# Static files
|
||||
locations."/static/" = {
|
||||
alias = "${beiwePackage}/lib/beiwe-backend/frontend/static/";
|
||||
extraConfig = ''
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Firewall Configuration
|
||||
# ==========================================================================
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
|
||||
[ cfg.bindToPort ] ++
|
||||
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
|
||||
);
|
||||
|
||||
# ==========================================================================
|
||||
# Utilities
|
||||
# ==========================================================================
|
||||
|
||||
environment.systemPackages = [
|
||||
beiwePackage
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
# Beiwe Backend package
|
||||
# A Django-based smartphone digital phenotyping research platform backend
|
||||
#
|
||||
# Using jhsware fork which adds environment variable support for Celery configuration
|
||||
# (CELERY_MANAGER_IP and CELERY_PASSWORD instead of manager_ip file)
|
||||
#
|
||||
# To update to a new version:
|
||||
# 1. Update the rev to the new commit hash
|
||||
# 2. Run: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<NEW_COMMIT>.tar.gz
|
||||
# 3. Update the hash with the output from step 2
|
||||
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
fetchPypi,
|
||||
python312,
|
||||
python312Packages,
|
||||
postgresql,
|
||||
# Custom parameters
|
||||
rev ? "93be878", # jhsware fork with env var support for Celery
|
||||
}:
|
||||
|
||||
|
||||
let
|
||||
# Known version hashes
|
||||
# To add a new version, run:
|
||||
# nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<COMMIT>.tar.gz
|
||||
versionHashes = {
|
||||
# jhsware fork with CELERY_MANAGER_IP and CELERY_PASSWORD env var support
|
||||
"93be878" = {
|
||||
srcHash = "sha256-marYxVINxgW0X9x+xoHL7bdRYEgm+4q+O1CF5WXeZGg=";
|
||||
};
|
||||
# Original onnela-lab version (for reference)
|
||||
"6bb5363" = {
|
||||
srcHash = "sha256-EeD+I3mWC81mhmlO9cKzRrArDLKVBmqhZjgJI8+geu0=";
|
||||
};
|
||||
};
|
||||
|
||||
hashes = versionHashes.${rev} or (throw ''
|
||||
beiwe-backend revision ${rev} is not supported.
|
||||
|
||||
Supported revisions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
|
||||
|
||||
To add support for revision ${rev}:
|
||||
1. Get source hash: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/${rev}.tar.gz
|
||||
2. Add entry to versionHashes in app_modules/_unstable/beiwe-backend/package.nix
|
||||
'');
|
||||
|
||||
# Build cronutils from PyPI (not available in nixpkgs)
|
||||
cronutils = python312Packages.buildPythonPackage rec {
|
||||
pname = "cronutils";
|
||||
version = "0.4.2";
|
||||
format = "setuptools";
|
||||
|
||||
src = fetchPypi {
|
||||
inherit pname version;
|
||||
hash = "sha256-SFHkQ9NltAyWArArkFpSBIJF3gMoXbxHEXreM1SEPUY=";
|
||||
};
|
||||
|
||||
propagatedBuildInputs = with python312Packages; [
|
||||
sentry-sdk
|
||||
];
|
||||
|
||||
# Tests require network access
|
||||
doCheck = false;
|
||||
|
||||
pythonImportsCheck = [ "cronutils" ];
|
||||
|
||||
meta = with lib; {
|
||||
description = "Utilities for cron jobs including error handling";
|
||||
homepage = "https://pypi.org/project/cronutils/";
|
||||
license = licenses.mit;
|
||||
};
|
||||
};
|
||||
|
||||
# Build beiwe-forest from GitHub (Forest analysis library)
|
||||
# See: https://github.com/onnela-lab/forest
|
||||
# Note: pip install git+https://github.com/onnela-lab/forest
|
||||
beiweForest = python312Packages.buildPythonPackage rec {
|
||||
pname = "forest";
|
||||
version = "unstable-2024-12-01";
|
||||
format = "pyproject";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "onnela-lab";
|
||||
repo = "forest";
|
||||
rev = "develop"; # Main development branch
|
||||
hash = "sha256-t+oq/jfJUmWCs0XzrN+xciYc3lz4oPiO8V8qfj3iTJA=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with python312Packages; [
|
||||
setuptools
|
||||
];
|
||||
|
||||
propagatedBuildInputs = with python312Packages; [
|
||||
# Core data science
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
scikit-learn
|
||||
|
||||
# Time/date utilities
|
||||
pytz
|
||||
holidays
|
||||
timezonefinder
|
||||
|
||||
# GIS/mapping
|
||||
shapely
|
||||
pyproj
|
||||
|
||||
# Audio processing (for voice analysis)
|
||||
librosa
|
||||
|
||||
# HTTP/API
|
||||
requests
|
||||
ratelimit
|
||||
];
|
||||
|
||||
# Some optional dependencies not in nixpkgs (openrouteservice, ssqueezepy)
|
||||
# Disable strict runtime deps check to allow partial functionality
|
||||
pythonRelaxDeps = true;
|
||||
pythonRemoveDeps = [ "openrouteservice" "ssqueezepy" ];
|
||||
|
||||
# Tests require data files
|
||||
doCheck = false;
|
||||
|
||||
pythonImportsCheck = [ "forest" ];
|
||||
|
||||
meta = with lib; {
|
||||
description = "Forest library for analyzing Beiwe digital phenotyping data";
|
||||
homepage = "https://github.com/onnela-lab/forest";
|
||||
license = licenses.bsd3;
|
||||
};
|
||||
};
|
||||
|
||||
# Python environment with all dependencies from requirements.txt
|
||||
|
||||
pythonEnv = python312.withPackages (ps: with ps; [
|
||||
# Django and web framework
|
||||
django
|
||||
django-extensions
|
||||
django-timezone-field # Provides timezone_field module
|
||||
gunicorn
|
||||
jinja2
|
||||
|
||||
# Database - using psycopg (v3) as specified in requirements.txt
|
||||
psycopg
|
||||
|
||||
# AWS/S3 support
|
||||
boto3
|
||||
|
||||
# Celery for task queue
|
||||
celery
|
||||
|
||||
# Error tracking and monitoring
|
||||
sentry-sdk
|
||||
cronutils # Custom package built above
|
||||
|
||||
# Firebase (push notifications)
|
||||
firebase-admin
|
||||
|
||||
# Security and crypto
|
||||
pycryptodomex # Note: pycryptodomex not pycryptodome
|
||||
pyotp
|
||||
bleach
|
||||
|
||||
# Serialization
|
||||
orjson
|
||||
|
||||
# Date/time utilities
|
||||
python-dateutil
|
||||
pytz
|
||||
|
||||
# Compression - pyzstd provides "import pyzstd" (jhsware fork uses pyzstd)
|
||||
pyzstd
|
||||
|
||||
# Data analysis
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
scikit-learn
|
||||
beiweForest # Custom package - provides "import forest"
|
||||
|
||||
# Other utilities
|
||||
requests
|
||||
rcssmin
|
||||
pypng
|
||||
pyqrcode
|
||||
|
||||
# Development/debugging
|
||||
ipython
|
||||
mypy
|
||||
]);
|
||||
|
||||
|
||||
in
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "beiwe-backend";
|
||||
version = rev;
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "jhsware"; # Fork with env var support for Celery
|
||||
repo = "beiwe-backend";
|
||||
inherit rev;
|
||||
hash = hashes.srcHash;
|
||||
};
|
||||
|
||||
buildInputs = [
|
||||
pythonEnv
|
||||
postgresql
|
||||
];
|
||||
|
||||
# No build phase needed - this is a Python application
|
||||
dontBuild = true;
|
||||
|
||||
# Patch Django settings to support DATABASE_SSLMODE environment variable
|
||||
# This allows controlling PostgreSQL SSL mode via environment variable
|
||||
postPatch = ''
|
||||
# Find the Django settings file and patch the database configuration
|
||||
# to include sslmode from environment variable
|
||||
|
||||
# Add sslmode support to database configuration
|
||||
# This sed command finds the DATABASES dict and adds OPTIONS with sslmode
|
||||
if [ -f config/django_settings.py ]; then
|
||||
echo "Patching config/django_settings.py for DATABASE_SSLMODE support..."
|
||||
|
||||
# Add import for os at the top if not already there
|
||||
if ! grep -q "^import os" config/django_settings.py; then
|
||||
sed -i '1i import os' config/django_settings.py
|
||||
fi
|
||||
|
||||
# Append code to add sslmode to database options at the end of the file
|
||||
cat >> config/django_settings.py << 'SSLPATCH'
|
||||
|
||||
# Patched by nix-infra-machine: Add DATABASE_SSLMODE support
|
||||
# This allows setting PostgreSQL sslmode via environment variable
|
||||
_db_sslmode = os.environ.get('DATABASE_SSLMODE', os.environ.get('PGSSLMODE', 'prefer'))
|
||||
if 'default' in DATABASES:
|
||||
if 'OPTIONS' not in DATABASES['default']:
|
||||
DATABASES['default']['OPTIONS'] = {}
|
||||
DATABASES['default']['OPTIONS']['sslmode'] = _db_sslmode
|
||||
SSLPATCH
|
||||
echo "Patched database settings for sslmode support"
|
||||
else
|
||||
echo "Warning: config/django_settings.py not found, skipping sslmode patch"
|
||||
fi
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Create directory structure
|
||||
mkdir -p $out/lib/beiwe-backend
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Copy all source files
|
||||
cp -r . $out/lib/beiwe-backend/
|
||||
|
||||
# Create wrapper scripts
|
||||
cat > $out/bin/beiwe-manage <<EOF
|
||||
#!/usr/bin/env bash
|
||||
cd $out/lib/beiwe-backend
|
||||
exec ${pythonEnv}/bin/python manage.py "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/beiwe-manage
|
||||
|
||||
cat > $out/bin/beiwe-gunicorn <<EOF
|
||||
#!/usr/bin/env bash
|
||||
cd $out/lib/beiwe-backend
|
||||
exec ${pythonEnv}/bin/gunicorn "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/beiwe-gunicorn
|
||||
|
||||
cat > $out/bin/beiwe-celery <<EOF
|
||||
#!/usr/bin/env bash
|
||||
cd $out/lib/beiwe-backend
|
||||
exec ${pythonEnv}/bin/celery "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/beiwe-celery
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Beiwe - smartphone-based digital phenotyping research platform backend";
|
||||
longDescription = ''
|
||||
The Beiwe Research Platform collects high-throughput smartphone-based
|
||||
digital phenotyping data including spatial trajectories (GPS), physical
|
||||
activity patterns (accelerometer/gyroscope), social networks and
|
||||
communication dynamics (call/text logs), and voice samples.
|
||||
|
||||
This package provides the Django-based backend server that supports:
|
||||
- Web-based study management portal
|
||||
- API endpoints for iOS/Android mobile apps
|
||||
- Data processing pipelines
|
||||
|
||||
This is the jhsware fork which adds environment variable support for
|
||||
Celery configuration (CELERY_MANAGER_IP and CELERY_PASSWORD).
|
||||
|
||||
Patched by nix-infra-machine to support DATABASE_SSLMODE environment
|
||||
variable for controlling PostgreSQL SSL mode.
|
||||
'';
|
||||
homepage = "https://github.com/jhsware/beiwe-backend";
|
||||
license = lib.licenses.bsd3;
|
||||
platforms = lib.platforms.unix;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
# CrowdSec Firewall Bouncer Module
|
||||
# Provides firewall-level IP blocking using iptables/nftables/ipset
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
stateDir = "/var/lib/crowdsec";
|
||||
yamlFormat = pkgs.formats.yaml {};
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName} = {
|
||||
features.firewallBouncer = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable the firewall bouncer to automatically block malicious IPs.
|
||||
|
||||
The bouncer fetches decisions from the CrowdSec API and applies
|
||||
them to the system firewall (iptables/nftables). Available in
|
||||
nixpkgs as pkgs.crowdsec-firewall-bouncer starting from NixOS 25.11.
|
||||
|
||||
When using nftables mode (default), the module creates declarative
|
||||
nftables tables that integrate properly with NixOS's firewall and
|
||||
survive system rebuilds.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(b) - Incident Handling: Provides automated incident
|
||||
response by blocking identified threats in real-time.
|
||||
|
||||
Article 21(2)(d) - Network Security: Implements active network
|
||||
protection through automated firewall rule management.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
bouncer = {
|
||||
package = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
description = ''
|
||||
CrowdSec firewall bouncer package to use.
|
||||
|
||||
The package is available in nixpkgs as pkgs.crowdsec-firewall-bouncer
|
||||
starting from NixOS 25.11.
|
||||
|
||||
Set to null to disable the bouncer even when features.firewallBouncer
|
||||
is enabled (useful for testing detection without blocking).
|
||||
'';
|
||||
default = pkgs.crowdsec-firewall-bouncer or null;
|
||||
defaultText = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
|
||||
example = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "iptables" "nftables" "ipset" ];
|
||||
description = ''
|
||||
Firewall mode for the bouncer.
|
||||
|
||||
- "nftables": Recommended for NixOS. Uses nftables sets which integrate
|
||||
well with NixOS declarative firewall. The module creates the necessary
|
||||
tables/chains declaratively, and the bouncer only manages set membership.
|
||||
|
||||
- "iptables": Traditional iptables rules. May conflict with NixOS firewall
|
||||
on system rebuilds.
|
||||
|
||||
- "ipset": Uses ipset for IP blocking. More compatible with iptables-based
|
||||
firewalls and survives rule flushes better.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
All modes provide equivalent security protection. Choose based on your
|
||||
existing firewall infrastructure.
|
||||
'';
|
||||
default = "nftables";
|
||||
};
|
||||
|
||||
nftablesIntegration = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
When using nftables mode, declaratively create the CrowdSec table
|
||||
structure in NixOS configuration. This ensures the tables/chains
|
||||
survive NixOS rebuilds and prevents conflicts with the declarative
|
||||
firewall.
|
||||
|
||||
When enabled:
|
||||
- Creates "crowdsec" and "crowdsec6" tables declaratively
|
||||
- Configures bouncer in "set-only" mode
|
||||
- Bouncer only manages IP set membership, not table structure
|
||||
|
||||
When disabled:
|
||||
- Bouncer creates and manages its own tables
|
||||
- May conflict with NixOS firewall rebuilds
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
|
||||
denyAction = lib.mkOption {
|
||||
type = lib.types.enum [ "DROP" "REJECT" ];
|
||||
description = ''
|
||||
Action to take for blocked IPs.
|
||||
|
||||
- "DROP": Silently drop packets (recommended for security)
|
||||
- "REJECT": Send rejection response to client
|
||||
|
||||
DROP is generally preferred as it doesn't reveal firewall presence.
|
||||
'';
|
||||
default = "DROP";
|
||||
};
|
||||
|
||||
denyLog = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Log blocked connections before dropping/rejecting.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Maintains audit trail
|
||||
of blocked threats for incident analysis and reporting.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
|
||||
denyLogPrefix = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Prefix for firewall log entries.";
|
||||
default = "crowdsec: ";
|
||||
};
|
||||
|
||||
banDuration = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Default ban duration for blocked IPs.
|
||||
|
||||
Format: Go duration string (e.g., "4h", "24h", "7d")
|
||||
'';
|
||||
default = "4h";
|
||||
example = "24h";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
config = lib.mkIf (cfg.enable && cfg.features.firewallBouncer && cfg.bouncer.package != null) (
|
||||
let
|
||||
useNftablesIntegration = cfg.bouncer.mode == "nftables" && cfg.bouncer.nftablesIntegration;
|
||||
|
||||
# Bouncer config - uses set-only mode when nftablesIntegration is enabled
|
||||
bouncerConfigFile = yamlFormat.generate "crowdsec-firewall-bouncer.yaml" ({
|
||||
mode = cfg.bouncer.mode;
|
||||
update_frequency = "10s";
|
||||
api_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/";
|
||||
api_key = "\${BOUNCER_API_KEY}";
|
||||
disable_ipv6 = false;
|
||||
deny_action = cfg.bouncer.denyAction;
|
||||
deny_log = cfg.bouncer.denyLog;
|
||||
deny_log_prefix = cfg.bouncer.denyLogPrefix;
|
||||
} // lib.optionalAttrs (cfg.bouncer.mode == "nftables") {
|
||||
nftables = {
|
||||
ipv4 = {
|
||||
enabled = true;
|
||||
set-only = useNftablesIntegration;
|
||||
table = "crowdsec";
|
||||
chain = "crowdsec-chain";
|
||||
set = "crowdsec-blocklist";
|
||||
};
|
||||
ipv6 = {
|
||||
enabled = true;
|
||||
set-only = useNftablesIntegration;
|
||||
table = "crowdsec6";
|
||||
chain = "crowdsec6-chain";
|
||||
set = "crowdsec6-blocklist";
|
||||
};
|
||||
};
|
||||
} // lib.optionalAttrs (cfg.bouncer.mode == "iptables") {
|
||||
iptables_chains = [ "INPUT" "FORWARD" ];
|
||||
} // lib.optionalAttrs (cfg.bouncer.mode == "ipset") {
|
||||
ipset_type = "nethash";
|
||||
ipset = "crowdsec-blocklist";
|
||||
ipset6 = "crowdsec6-blocklist";
|
||||
});
|
||||
|
||||
bouncerRegisterScript = pkgs.writeShellScript "crowdsec-bouncer-register" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
|
||||
|
||||
CONFIG_DIR="${stateDir}/config"
|
||||
KEY_FILE="/var/lib/crowdsec-firewall-bouncer/api_key"
|
||||
|
||||
# Wait for CrowdSec API to be ready
|
||||
for i in $(seq 1 60); do
|
||||
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if bouncer already registered
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "firewall-bouncer"; then
|
||||
# Register new bouncer and save key
|
||||
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add firewall-bouncer -o raw 2>/dev/null || echo "")
|
||||
if [ -n "$KEY" ]; then
|
||||
echo "$KEY" > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read existing key
|
||||
if [ -f "$KEY_FILE" ]; then
|
||||
export BOUNCER_API_KEY=$(cat "$KEY_FILE")
|
||||
fi
|
||||
|
||||
# Generate config with key substituted
|
||||
# Use | as sed delimiter since API keys may contain /
|
||||
if [ -n "$BOUNCER_API_KEY" ]; then
|
||||
sed "s|\''${BOUNCER_API_KEY}|$BOUNCER_API_KEY|g" ${bouncerConfigFile} > /var/lib/crowdsec-firewall-bouncer/config.yaml
|
||||
fi
|
||||
'';
|
||||
|
||||
in lib.mkMerge [
|
||||
# Assertions
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.bouncer.package != null;
|
||||
message = ''
|
||||
CrowdSec firewall bouncer is enabled but no package is configured.
|
||||
|
||||
The bouncer package should be available as pkgs.crowdsec-firewall-bouncer
|
||||
on NixOS 25.11+. If using an older NixOS version, you may need to:
|
||||
|
||||
1. Upgrade to NixOS 25.11+
|
||||
2. Set infrastructure.crowdsec.features.firewallBouncer = false
|
||||
3. Provide the package from an external source
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Install CLI tools based on mode
|
||||
environment.systemPackages =
|
||||
lib.optionals (cfg.bouncer.mode == "nftables") [ pkgs.nftables ]
|
||||
++ lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables ]
|
||||
++ lib.optionals (cfg.bouncer.mode == "ipset") [ pkgs.ipset ];
|
||||
}
|
||||
|
||||
# Declarative nftables Integration
|
||||
(lib.mkIf useNftablesIntegration {
|
||||
networking.nftables.enable = true;
|
||||
|
||||
networking.nftables.tables = {
|
||||
# IPv4 CrowdSec table
|
||||
crowdsec = {
|
||||
family = "ip";
|
||||
content = ''
|
||||
set crowdsec-blocklist {
|
||||
type ipv4_addr
|
||||
flags timeout
|
||||
}
|
||||
|
||||
chain crowdsec-chain {
|
||||
type filter hook input priority -1; policy accept;
|
||||
${lib.optionalString cfg.bouncer.denyLog ''
|
||||
ip saddr @crowdsec-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
|
||||
''}
|
||||
ip saddr @crowdsec-blocklist ${lib.toLower cfg.bouncer.denyAction}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# IPv6 CrowdSec table
|
||||
crowdsec6 = {
|
||||
family = "ip6";
|
||||
content = ''
|
||||
set crowdsec6-blocklist {
|
||||
type ipv6_addr
|
||||
flags timeout
|
||||
}
|
||||
|
||||
chain crowdsec6-chain {
|
||||
type filter hook input priority -1; policy accept;
|
||||
${lib.optionalString cfg.bouncer.denyLog ''
|
||||
ip6 saddr @crowdsec6-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
|
||||
''}
|
||||
ip6 saddr @crowdsec6-blocklist ${lib.toLower cfg.bouncer.denyAction}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
# Tmpfiles and service
|
||||
{
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/crowdsec-firewall-bouncer 0750 root root - -"
|
||||
];
|
||||
|
||||
# Firewall bouncer service
|
||||
systemd.services.crowdsec-firewall-bouncer = {
|
||||
description = "CrowdSec Firewall Bouncer";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "crowdsec.service" ];
|
||||
requires = [ "crowdsec.service" ];
|
||||
|
||||
path = lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables pkgs.ipset ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStartPre = "${bouncerRegisterScript}";
|
||||
ExecStart = "${cfg.bouncer.package}/bin/cs-firewall-bouncer -c /var/lib/crowdsec-firewall-bouncer/config.yaml";
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
};
|
||||
};
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# CrowdSec HAProxy SPOA Bouncer Module
|
||||
# Provides application-layer protection via HAProxy Stream Processing Offload API
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
stateDir = "/var/lib/crowdsec";
|
||||
yamlFormat = pkgs.formats.yaml {};
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName} = {
|
||||
features.haproxyProtection = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable HAProxy security integration via SPOA (Stream Processing Offload API).
|
||||
|
||||
The cs-haproxy-spoa-bouncer acts as a stream processing agent that checks
|
||||
each connection in real-time against CrowdSec's decision database before
|
||||
allowing traffic to reach your application servers.
|
||||
|
||||
This provides layer 7 application-level protection, complementing the
|
||||
layer 3/4 protection from the firewall bouncer.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(d) - Network Security: Provides application-layer protection
|
||||
for HTTP/HTTPS traffic through HAProxy integration.
|
||||
|
||||
Article 21(2)(e) - Supply Chain Security: Protects web applications that
|
||||
may be part of the digital supply chain.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
haproxy = {
|
||||
package = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
description = ''
|
||||
CrowdSec HAProxy SPOA bouncer package to use.
|
||||
|
||||
The package should be available as pkgs.cs-haproxy-spoa-bouncer.
|
||||
Set to null to disable the bouncer even when features.haproxyProtection
|
||||
is enabled.
|
||||
'';
|
||||
default = pkgs.cs-haproxy-spoa-bouncer or null;
|
||||
defaultText = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
|
||||
example = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
|
||||
};
|
||||
|
||||
listenAddr = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Address for the SPOA bouncer to listen on.
|
||||
HAProxy will connect to this address to check decisions.
|
||||
'';
|
||||
default = "127.0.0.1";
|
||||
example = "0.0.0.0";
|
||||
};
|
||||
|
||||
listenPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
description = "Port for the SPOA bouncer to listen on.";
|
||||
default = 3000;
|
||||
example = 3000;
|
||||
};
|
||||
|
||||
action = lib.mkOption {
|
||||
type = lib.types.enum [ "deny" "tarpit" ];
|
||||
description = ''
|
||||
Action to take for blocked requests in HAProxy.
|
||||
|
||||
- "deny": Immediately reject the connection
|
||||
- "tarpit": Slow down the connection (tar pit)
|
||||
'';
|
||||
default = "deny";
|
||||
};
|
||||
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.enum [ "error" "warning" "info" "debug" ];
|
||||
description = "Log level for the SPOA bouncer.";
|
||||
default = "info";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
config = lib.mkIf (cfg.enable && cfg.features.haproxyProtection && cfg.haproxy.package != null) (
|
||||
let
|
||||
# HAProxy SPOA bouncer config file
|
||||
haproxyBouncerConfigFile = yamlFormat.generate "crowdsec-haproxy-spoa-bouncer.yaml" {
|
||||
lapi_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
|
||||
lapi_key = "\${HAPROXY_SPOA_API_KEY}";
|
||||
action = cfg.haproxy.action;
|
||||
log_level = cfg.haproxy.logLevel;
|
||||
listen_addr = cfg.haproxy.listenAddr;
|
||||
listen_port = cfg.haproxy.listenPort;
|
||||
update_frequency = "10s";
|
||||
};
|
||||
|
||||
haproxyBouncerRegisterScript = pkgs.writeShellScript "crowdsec-haproxy-bouncer-register" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
|
||||
|
||||
CONFIG_DIR="${stateDir}/config"
|
||||
KEY_FILE="/var/lib/crowdsec-haproxy-bouncer/api_key"
|
||||
|
||||
# Wait for CrowdSec API to be ready
|
||||
for i in $(seq 1 60); do
|
||||
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if bouncer already registered
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "haproxy-spoa-bouncer"; then
|
||||
# Register new bouncer and save key
|
||||
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add haproxy-spoa-bouncer -o raw 2>/dev/null || echo "")
|
||||
if [ -n "$KEY" ]; then
|
||||
echo "$KEY" > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read existing key
|
||||
if [ -f "$KEY_FILE" ]; then
|
||||
export HAPROXY_SPOA_API_KEY=$(cat "$KEY_FILE")
|
||||
fi
|
||||
|
||||
# Generate config with key substituted
|
||||
# Use | as sed delimiter since API keys may contain /
|
||||
if [ -n "$HAPROXY_SPOA_API_KEY" ]; then
|
||||
sed "s|\''${HAPROXY_SPOA_API_KEY}|$HAPROXY_SPOA_API_KEY|g" ${haproxyBouncerConfigFile} > /var/lib/crowdsec-haproxy-bouncer/config.yaml
|
||||
fi
|
||||
'';
|
||||
|
||||
in {
|
||||
# Assertions
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.haproxy.package != null;
|
||||
message = ''
|
||||
CrowdSec HAProxy SPOA bouncer is enabled but no package is configured.
|
||||
|
||||
The bouncer package should be available as pkgs.cs-haproxy-spoa-bouncer.
|
||||
If the package is not available, you may need to:
|
||||
|
||||
1. Set infrastructure.crowdsec.features.haproxyProtection = false
|
||||
2. Provide the package from an external source
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Tmpfiles
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/crowdsec-haproxy-bouncer 0750 root root - -"
|
||||
];
|
||||
|
||||
# HAProxy SPOA bouncer service
|
||||
systemd.services.crowdsec-haproxy-bouncer = {
|
||||
description = "CrowdSec HAProxy SPOA Bouncer";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "crowdsec.service" ];
|
||||
requires = [ "crowdsec.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStartPre = "${haproxyBouncerRegisterScript}";
|
||||
ExecStart = "${cfg.haproxy.package}/bin/cs-haproxy-spoa-bouncer -c /var/lib/crowdsec-haproxy-bouncer/config.yaml";
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
# CrowdSec Python Bouncer Module
|
||||
# Provides API key registration for pycrowdsec and python-capi-sdk integration
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
stateDir = "/var/lib/crowdsec";
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName}.python = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable Python bouncer registration for use with pycrowdsec.
|
||||
|
||||
This registers a bouncer with CrowdSec and stores the API key in a
|
||||
configurable location so Python applications can use the pycrowdsec
|
||||
library to check IPs against CrowdSec decisions.
|
||||
|
||||
Python applications should use the StreamClient or QueryClient from
|
||||
pycrowdsec to query decisions:
|
||||
|
||||
```python
|
||||
from pycrowdsec.client import StreamClient
|
||||
client = StreamClient(
|
||||
api_key=open("/run/crowdsec-python-bouncer/api_key").read().strip(),
|
||||
lapi_url="http://127.0.0.1:8080/"
|
||||
)
|
||||
client.run()
|
||||
action = client.get_action_for("1.2.3.4") # Returns "ban", "captcha", etc.
|
||||
```
|
||||
|
||||
For Flask/Django integration, configure the middleware to read the API key
|
||||
from the configured apiKeyFile path.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(d) - Network Security: Provides application-layer protection
|
||||
for Python web applications through CrowdSec integration.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
bouncerName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Name to register the Python bouncer with in CrowdSec.";
|
||||
default = "python-bouncer";
|
||||
example = "my-flask-app-bouncer";
|
||||
};
|
||||
|
||||
apiKeyFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path where the bouncer API key will be stored.
|
||||
|
||||
This file will be readable by the configured group (default: root).
|
||||
Python applications need read access to this file to authenticate
|
||||
with the CrowdSec Local API.
|
||||
'';
|
||||
default = "/run/crowdsec-python-bouncer/api_key";
|
||||
example = "/run/secrets/crowdsec-python-api-key";
|
||||
};
|
||||
|
||||
apiKeyFileGroup = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Group that should have read access to the API key file.
|
||||
|
||||
Set this to match the group your Python application runs as.
|
||||
For example, if your Flask app runs as user "flask" in group "flask",
|
||||
set this to "flask".
|
||||
'';
|
||||
default = "root";
|
||||
example = "www-data";
|
||||
};
|
||||
|
||||
enableCapi = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable Central API (CAPI) credentials for signal sharing with python-capi-sdk.
|
||||
|
||||
When enabled, this generates machine credentials that can be used with
|
||||
the python-capi-sdk to send attack signals to CrowdSec's central infrastructure
|
||||
and receive community blocklists.
|
||||
|
||||
The credentials are stored in the capiCredentialsFile location.
|
||||
|
||||
Example usage with python-capi-sdk:
|
||||
|
||||
```python
|
||||
from cscapi.client import CAPIClient, CAPIClientConfig
|
||||
from cscapi.sql_storage import SQLStorage
|
||||
import yaml
|
||||
|
||||
# Load credentials generated by this module
|
||||
with open("/run/crowdsec-python-bouncer/capi_credentials.yaml") as f:
|
||||
creds = yaml.safe_load(f)
|
||||
|
||||
client = CAPIClient(
|
||||
storage=SQLStorage(connection_string="sqlite:///signals.db"),
|
||||
config=CAPIClientConfig(scenarios=["crowdsecurity/ssh-bf"])
|
||||
)
|
||||
```
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 14 - Information Sharing: Enables participation in threat
|
||||
intelligence sharing through the CrowdSec community network.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
capiCredentialsFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path where CAPI credentials will be stored for python-capi-sdk.
|
||||
|
||||
This file contains machine_id and password for authenticating
|
||||
with CrowdSec's Central API.
|
||||
'';
|
||||
default = "/run/crowdsec-python-bouncer/capi_credentials.yaml";
|
||||
example = "/run/secrets/crowdsec-capi-credentials.yaml";
|
||||
};
|
||||
|
||||
capiScenarios = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Scenarios that your Python application will report signals for.
|
||||
|
||||
These should match the attack patterns your application detects.
|
||||
Common scenarios include:
|
||||
- crowdsecurity/ssh-bf (SSH brute force)
|
||||
- crowdsecurity/http-bf (HTTP brute force)
|
||||
- crowdsecurity/http-crawl-non_statics (Web crawling)
|
||||
'';
|
||||
default = [];
|
||||
example = [ "crowdsecurity/http-bf" "crowdsecurity/http-crawl-non_statics" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
config = lib.mkIf (cfg.enable && cfg.python.enable) (
|
||||
let
|
||||
# Get the directory from the apiKeyFile path
|
||||
pythonBouncerDir = builtins.dirOf cfg.python.apiKeyFile;
|
||||
pythonCapiDir = builtins.dirOf cfg.python.capiCredentialsFile;
|
||||
|
||||
# Python bouncer registration script
|
||||
pythonBouncerRegisterScript = pkgs.writeShellScript "crowdsec-python-bouncer-register" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
|
||||
|
||||
CONFIG_DIR="${stateDir}/config"
|
||||
KEY_FILE="${cfg.python.apiKeyFile}"
|
||||
KEY_DIR="${pythonBouncerDir}"
|
||||
BOUNCER_NAME="${cfg.python.bouncerName}"
|
||||
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
mkdir -p "$KEY_DIR"
|
||||
|
||||
# Wait for CrowdSec API to be ready
|
||||
for i in $(seq 1 60); do
|
||||
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if bouncer already registered
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "$BOUNCER_NAME"; then
|
||||
# Register new bouncer and save key
|
||||
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add "$BOUNCER_NAME" -o raw 2>/dev/null || echo "")
|
||||
if [ -n "$KEY" ]; then
|
||||
echo "$KEY" > "$KEY_FILE"
|
||||
# Set permissions: owner read/write, group read
|
||||
chmod 640 "$KEY_FILE"
|
||||
chown root:"$KEY_GROUP" "$KEY_FILE"
|
||||
echo "Python bouncer '$BOUNCER_NAME' registered successfully"
|
||||
echo "API key stored at: $KEY_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Python bouncer '$BOUNCER_NAME' already registered"
|
||||
fi
|
||||
|
||||
# Create a helper config file for Python applications
|
||||
LAPI_URL="http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
|
||||
cat > "$KEY_DIR/config.yaml" << EOF
|
||||
# CrowdSec Python Bouncer Configuration
|
||||
# Generated by NixOS infrastructure.crowdsec module
|
||||
#
|
||||
# Usage with pycrowdsec:
|
||||
# from pycrowdsec.client import StreamClient
|
||||
# import yaml
|
||||
#
|
||||
# with open('${pythonBouncerDir}/config.yaml') as f:
|
||||
# config = yaml.safe_load(f)
|
||||
#
|
||||
# client = StreamClient(
|
||||
# api_key=open(config['api_key_file']).read().strip(),
|
||||
# lapi_url=config['lapi_url']
|
||||
# )
|
||||
# client.run()
|
||||
|
||||
lapi_url: "$LAPI_URL"
|
||||
api_key_file: "$KEY_FILE"
|
||||
bouncer_name: "$BOUNCER_NAME"
|
||||
EOF
|
||||
chmod 644 "$KEY_DIR/config.yaml"
|
||||
chown root:"$KEY_GROUP" "$KEY_DIR/config.yaml"
|
||||
'';
|
||||
|
||||
# Python CAPI credentials script (for python-capi-sdk signal sharing)
|
||||
pythonCapiRegisterScript = pkgs.writeShellScript "crowdsec-python-capi-register" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.openssl ]}:$PATH"
|
||||
|
||||
CONFIG_DIR="${stateDir}/config"
|
||||
CAPI_FILE="${cfg.python.capiCredentialsFile}"
|
||||
CAPI_DIR="${pythonCapiDir}"
|
||||
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
mkdir -p "$CAPI_DIR"
|
||||
|
||||
# Generate unique machine ID based on hostname and a random component
|
||||
MACHINE_ID="python-$(hostname)-$(openssl rand -hex 4)"
|
||||
|
||||
# Generate a secure password
|
||||
MACHINE_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Check if credentials already exist
|
||||
if [ -f "$CAPI_FILE" ]; then
|
||||
echo "CAPI credentials already exist at $CAPI_FILE"
|
||||
else
|
||||
# Create credentials file for python-capi-sdk
|
||||
cat > "$CAPI_FILE" << EOF
|
||||
# CrowdSec Central API Credentials for python-capi-sdk
|
||||
# Generated by NixOS infrastructure.crowdsec module
|
||||
#
|
||||
# Usage with python-capi-sdk:
|
||||
# from cscapi.client import CAPIClient, CAPIClientConfig
|
||||
# from cscapi.sql_storage import SQLStorage
|
||||
# from cscapi.utils import generate_machine_id_from_key
|
||||
# import yaml
|
||||
#
|
||||
# with open('${cfg.python.capiCredentialsFile}') as f:
|
||||
# creds = yaml.safe_load(f)
|
||||
#
|
||||
# client = CAPIClient(
|
||||
# storage=SQLStorage(connection_string="sqlite:///signals.db"),
|
||||
# config=CAPIClientConfig(
|
||||
# scenarios=${builtins.toJSON cfg.python.capiScenarios}
|
||||
# )
|
||||
# )
|
||||
#
|
||||
# # Note: Machine enrollment with CrowdSec CAPI requires manual approval
|
||||
# # Contact CrowdSec for signal sharing partnership details
|
||||
|
||||
machine_id: "$MACHINE_ID"
|
||||
password: "$MACHINE_PASSWORD"
|
||||
scenarios: ${builtins.toJSON cfg.python.capiScenarios}
|
||||
capi_url: "https://api.crowdsec.net/"
|
||||
|
||||
# Local API connection (for reading decisions)
|
||||
lapi_url: "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
|
||||
EOF
|
||||
chmod 640 "$CAPI_FILE"
|
||||
chown root:"$KEY_GROUP" "$CAPI_FILE"
|
||||
echo "CAPI credentials generated at $CAPI_FILE"
|
||||
echo ""
|
||||
echo "NOTE: To share signals with CrowdSec CAPI, you need to:"
|
||||
echo "1. Contact CrowdSec for signal sharing partnership enrollment"
|
||||
echo "2. Use the machine_id from this file when enrolling"
|
||||
echo "3. Update your application to use the python-capi-sdk"
|
||||
fi
|
||||
'';
|
||||
|
||||
in {
|
||||
# Python bouncer registration service (oneshot - registers bouncer and stores API key)
|
||||
systemd.services.crowdsec-python-bouncer = {
|
||||
description = "CrowdSec Python Bouncer Registration";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "crowdsec.service" ];
|
||||
requires = [ "crowdsec.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${pythonBouncerRegisterScript}";
|
||||
};
|
||||
};
|
||||
|
||||
# Python CAPI credentials service (oneshot - generates CAPI credentials for signal sharing)
|
||||
systemd.services.crowdsec-python-capi = lib.mkIf cfg.python.enableCapi {
|
||||
description = "CrowdSec Python CAPI Credentials Generation";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "crowdsec.service" "crowdsec-python-bouncer.service" ];
|
||||
requires = [ "crowdsec.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${pythonCapiRegisterScript}";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
# CrowdSec - Collaborative Intrusion Prevention System
|
||||
#
|
||||
# This module provides simplified boolean feature toggles for common use cases
|
||||
# and can use either a custom implementation or the native NixOS module.
|
||||
#
|
||||
# Module structure:
|
||||
# - default.nix: Core CrowdSec engine and detection features
|
||||
# - bouncers/: Response modules (firewall, haproxy, python)
|
||||
# - integrations/: External system integrations (auditd, console)
|
||||
{ config, pkgs, lib, options, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
# ==========================================================================
|
||||
# Version Detection (must not depend on cfg to avoid recursion)
|
||||
# ==========================================================================
|
||||
# Check if the native services.crowdsec module exists (NixOS 25.11+)
|
||||
hasNativeCrowdsecModule = options ? services && options.services ? crowdsec;
|
||||
|
||||
# The native module in NixOS 25.11 has multiple bugs that make it unusable:
|
||||
# - #445342: Missing sensible defaults, API server disabled by default
|
||||
# - #446764: Console enrollment broken
|
||||
# - #459224: Cannot enable local API
|
||||
# - Missing hub.postoverflows, hub.scenarios, hub.parsers options
|
||||
# - Null coercion errors in systemd service generation
|
||||
#
|
||||
# We mark the native module as unstable until these are fixed.
|
||||
# Users can override with implementation = "native" to test.
|
||||
nativeModuleIsStable = false;
|
||||
|
||||
# State directory for CrowdSec
|
||||
stateDir = "/var/lib/crowdsec";
|
||||
|
||||
# Helper to generate YAML format
|
||||
yamlFormat = pkgs.formats.yaml {};
|
||||
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Import Sub-Modules
|
||||
# ==========================================================================
|
||||
imports = [
|
||||
# Bouncers - Response mechanisms
|
||||
./bouncers/firewall.nix
|
||||
./bouncers/haproxy.nix
|
||||
./bouncers/python.nix
|
||||
# Integrations - External system connections
|
||||
./integrations/auditd.nix
|
||||
./integrations/console.nix
|
||||
];
|
||||
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption ''
|
||||
CrowdSec - Collaborative Intrusion Prevention System.
|
||||
|
||||
CrowdSec is an open-source security automation tool that detects and blocks
|
||||
malicious behavior by analyzing logs and sharing threat intelligence with
|
||||
the community.
|
||||
|
||||
This module provides simplified boolean feature toggles for common use cases
|
||||
and can use either a custom implementation or the native NixOS module.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(b) - Incident Handling: CrowdSec provides automated threat
|
||||
detection and response capabilities, helping organizations meet requirements
|
||||
for detecting, analyzing, and responding to cybersecurity incidents.
|
||||
|
||||
Article 21(2)(d) - Network Security: Acts as an Intrusion Detection/Prevention
|
||||
System (IDS/IPS), a core requirement for protecting network infrastructure.
|
||||
'';
|
||||
|
||||
implementation = lib.mkOption {
|
||||
type = lib.types.enum [ "auto" "native" "custom" ];
|
||||
description = ''
|
||||
Which implementation to use for CrowdSec.
|
||||
|
||||
- "auto": Automatically select based on NixOS version and module stability.
|
||||
Currently defaults to "custom" because the native module has bugs.
|
||||
- "native": Force use of NixOS's native services.crowdsec module.
|
||||
Requires NixOS 25.11+. May have bugs - use for testing only.
|
||||
- "custom": Use the custom implementation that manages its own systemd
|
||||
service. Works on all NixOS versions with the crowdsec package.
|
||||
|
||||
The native module in NixOS 25.11 has several known issues:
|
||||
- #445342: Missing sensible defaults
|
||||
- #446764: Console enrollment broken
|
||||
- #459224: Cannot enable local API
|
||||
|
||||
When these are fixed, "auto" will switch to using the native module.
|
||||
'';
|
||||
default = "auto";
|
||||
example = "custom";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "CrowdSec package to use.";
|
||||
default = pkgs.crowdsec;
|
||||
defaultText = lib.literalExpression "pkgs.crowdsec";
|
||||
};
|
||||
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.enum [ "trace" "debug" "info" "warning" "error" "fatal" ];
|
||||
description = ''
|
||||
Log level for CrowdSec.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Appropriate logging level
|
||||
enables proper security event monitoring and incident investigation.
|
||||
'';
|
||||
default = "info";
|
||||
example = "debug";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# API Configuration
|
||||
# ==========================================================================
|
||||
|
||||
api = {
|
||||
listenAddr = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Address for the CrowdSec Local API (LAPI) to listen on.
|
||||
Use "127.0.0.1" for local-only access or "0.0.0.0" for network access.
|
||||
'';
|
||||
default = "127.0.0.1";
|
||||
example = "0.0.0.0";
|
||||
};
|
||||
|
||||
listenPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
description = "Port for the CrowdSec Local API (LAPI) to listen on.";
|
||||
default = 8080;
|
||||
example = 8080;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Whether to open the firewall port for the CrowdSec API.
|
||||
Only needed if bouncers from other machines need to connect.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Detection Features (Simple Boolean Options)
|
||||
# ==========================================================================
|
||||
|
||||
features = {
|
||||
sshProtection = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable SSH brute-force detection and prevention.
|
||||
|
||||
Monitors SSH authentication logs to detect and block IP addresses
|
||||
attempting password guessing or credential stuffing attacks.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(i) - Human Resources Security: Protects authentication
|
||||
systems and helps prevent unauthorized access attempts.
|
||||
|
||||
Article 21(2)(j) - Access Control: Provides automated protection
|
||||
against credential-based attacks on administrative interfaces.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
|
||||
nginxProtection = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable nginx/web server attack detection.
|
||||
|
||||
Monitors nginx access and error logs to detect web-based attacks
|
||||
including SQL injection, XSS, path traversal, and more.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(d) - Network Security: Provides web application
|
||||
firewall (WAF) capabilities to protect public-facing services.
|
||||
|
||||
Article 21(2)(e) - Supply Chain Security: Helps protect web
|
||||
services that may be part of the digital supply chain.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
nginxLogPaths = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Paths to nginx log files to monitor.";
|
||||
default = [ "/var/log/nginx/*.log" ];
|
||||
example = [ "/var/log/nginx/access.log" "/var/log/nginx/error.log" ];
|
||||
};
|
||||
|
||||
systemProtection = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable system/kernel-level threat detection.
|
||||
|
||||
Monitors kernel and system logs for suspicious activity including
|
||||
privilege escalation attempts and system abuse.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(a) - Risk Analysis: Provides continuous monitoring
|
||||
to identify and respond to system-level threats.
|
||||
|
||||
Article 21(2)(g) - Security Monitoring: Implements comprehensive
|
||||
security monitoring across the system infrastructure.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
communityBlocklists = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable community-contributed IP blocklists.
|
||||
|
||||
When enrolled in the CrowdSec Console, your instance can receive
|
||||
curated blocklists of known malicious IPs from the community.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(d) - Network Security: Leverages collective threat
|
||||
intelligence to proactively block known attackers.
|
||||
|
||||
Article 14 - Information Sharing: Participates in cybersecurity
|
||||
information sharing to improve collective defense.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Hub Configuration (Parsers, Scenarios, Collections)
|
||||
# ==========================================================================
|
||||
|
||||
hub = {
|
||||
collections = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Additional CrowdSec Hub collections to install.
|
||||
|
||||
Collections bundle related parsers and scenarios together.
|
||||
Browse available collections at: https://hub.crowdsec.net/
|
||||
'';
|
||||
default = [];
|
||||
example = [ "crowdsecurity/apache2" "crowdsecurity/postfix" ];
|
||||
};
|
||||
|
||||
scenarios = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Additional CrowdSec Hub scenarios to install.
|
||||
|
||||
Scenarios define detection rules for specific attack patterns.
|
||||
'';
|
||||
default = [];
|
||||
example = [ "crowdsecurity/http-bf-wordpress_bf" ];
|
||||
};
|
||||
|
||||
parsers = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Additional CrowdSec Hub parsers to install.
|
||||
|
||||
Parsers extract structured data from log files.
|
||||
'';
|
||||
default = [];
|
||||
example = [ "crowdsecurity/docker-logs" ];
|
||||
};
|
||||
|
||||
postoverflows = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Additional post-overflow parsers to install.";
|
||||
default = [];
|
||||
example = [ "crowdsecurity/cdn-whitelist" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Custom Acquisitions
|
||||
# ==========================================================================
|
||||
|
||||
acquisitions = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
description = ''
|
||||
Additional log sources for CrowdSec to monitor.
|
||||
|
||||
Each acquisition defines a log source (file, journalctl, etc.)
|
||||
and the parser type to use.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Enables comprehensive
|
||||
log collection and monitoring across all systems.
|
||||
'';
|
||||
default = [];
|
||||
example = lib.literalExpression ''
|
||||
[
|
||||
{
|
||||
source = "journalctl";
|
||||
journalctl_filter = [ "_SYSTEMD_UNIT=postgresql.service" ];
|
||||
labels.type = "syslog";
|
||||
}
|
||||
{
|
||||
filenames = [ "/var/log/myapp/*.log" ];
|
||||
labels.type = "syslog";
|
||||
}
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Pass-through Configuration
|
||||
# ==========================================================================
|
||||
|
||||
extraSettings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Extra settings merged into the CrowdSec configuration.
|
||||
For native implementation: merged into services.crowdsec.settings.
|
||||
For custom implementation: merged into the generated config.yaml.
|
||||
'';
|
||||
default = {};
|
||||
};
|
||||
|
||||
extraLocalConfig = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Extra settings merged into the local configuration.
|
||||
For native implementation: merged into services.crowdsec.localConfig.
|
||||
For custom implementation: not currently used.
|
||||
'';
|
||||
default = {};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
# ========================================================================
|
||||
# All cfg-dependent values MUST be defined inside this let block
|
||||
# to avoid infinite recursion during module evaluation
|
||||
# ========================================================================
|
||||
|
||||
# Determine which implementation to use
|
||||
useNativeImplementation =
|
||||
if cfg.implementation == "native" then true
|
||||
else if cfg.implementation == "custom" then false
|
||||
else if cfg.implementation == "auto" then
|
||||
hasNativeCrowdsecModule && nativeModuleIsStable
|
||||
else false;
|
||||
|
||||
# Build acquisitions list based on enabled features
|
||||
acquisitions = lib.flatten [
|
||||
# SSH acquisition (journalctl-based)
|
||||
(lib.optional cfg.features.sshProtection {
|
||||
source = "journalctl";
|
||||
journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ];
|
||||
labels.type = "syslog";
|
||||
})
|
||||
# Nginx acquisition (log file-based)
|
||||
(lib.optional cfg.features.nginxProtection {
|
||||
filenames = cfg.features.nginxLogPaths;
|
||||
labels.type = "nginx";
|
||||
})
|
||||
# System/kernel logs acquisition
|
||||
(lib.optional cfg.features.systemProtection {
|
||||
source = "journalctl";
|
||||
journalctl_filter = [ "_TRANSPORT=kernel" ];
|
||||
labels.type = "syslog";
|
||||
})
|
||||
# Custom acquisitions from user
|
||||
cfg.acquisitions
|
||||
];
|
||||
|
||||
# Build hub collections list based on enabled features
|
||||
hubCollections = lib.flatten [
|
||||
(lib.optional cfg.features.sshProtection "crowdsecurity/sshd")
|
||||
(lib.optional cfg.features.nginxProtection "crowdsecurity/nginx")
|
||||
(lib.optional cfg.features.systemProtection "crowdsecurity/linux")
|
||||
cfg.hub.collections
|
||||
];
|
||||
|
||||
# ========================================================================
|
||||
# Custom Implementation: Configuration Files
|
||||
# ========================================================================
|
||||
|
||||
# Generate acquisitions file as multi-document YAML
|
||||
# CrowdSec expects each acquisition as a separate YAML document (separated by ---)
|
||||
# We use yamlFormat.generate for each acquisition and concatenate them
|
||||
acquisitionsFile = pkgs.writeText "acquisitions.yaml" (
|
||||
lib.concatMapStringsSep "\n---\n" (acq:
|
||||
builtins.readFile (yamlFormat.generate "acq.yaml" acq)
|
||||
) acquisitions
|
||||
);
|
||||
|
||||
# Generate simulation file (CrowdSec requires this)
|
||||
simulationFile = yamlFormat.generate "simulation.yaml" {
|
||||
simulation = false;
|
||||
exclusions = [];
|
||||
};
|
||||
|
||||
# Generate main config file (compatible with CrowdSec 1.7.x)
|
||||
configFile = yamlFormat.generate "config.yaml" {
|
||||
common = {
|
||||
daemonize = false;
|
||||
log_media = "stdout";
|
||||
log_level = cfg.logLevel;
|
||||
};
|
||||
config_paths = {
|
||||
config_dir = "${stateDir}/config";
|
||||
data_dir = "${stateDir}/data";
|
||||
hub_dir = "${stateDir}/hub";
|
||||
simulation_path = "${stateDir}/config/simulation.yaml";
|
||||
};
|
||||
crowdsec_service = {
|
||||
acquisition_path = "${stateDir}/config/acquisitions.yaml";
|
||||
parser_routines = 1;
|
||||
};
|
||||
cscli = {
|
||||
output = "human";
|
||||
};
|
||||
api = {
|
||||
client = {
|
||||
insecure_skip_verify = false;
|
||||
credentials_path = "${stateDir}/config/local_api_credentials.yaml";
|
||||
};
|
||||
server = {
|
||||
enable = true;
|
||||
listen_uri = "${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
|
||||
profiles_path = "${stateDir}/config/profiles.yaml";
|
||||
online_client = {
|
||||
credentials_path = "${stateDir}/config/online_api_credentials.yaml";
|
||||
};
|
||||
};
|
||||
};
|
||||
db_config = {
|
||||
type = "sqlite";
|
||||
db_path = "${stateDir}/data/crowdsec.db";
|
||||
use_wal = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Generate profiles file (CrowdSec expects multi-document YAML format)
|
||||
# Use bouncer.banDuration if available, otherwise default to 4h
|
||||
banDuration = cfg.bouncer.banDuration or "4h";
|
||||
profilesFile = pkgs.writeText "profiles.yaml" ''
|
||||
name: default_ip_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: ${banDuration}
|
||||
on_success: break
|
||||
'';
|
||||
|
||||
# Initialization script - sets up CrowdSec on first run
|
||||
initScript = pkgs.writeShellScript "crowdsec-init" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.nettools pkgs.findutils ]}:$PATH"
|
||||
|
||||
STATE_DIR="${stateDir}"
|
||||
CONFIG_DIR="$STATE_DIR/config"
|
||||
DATA_DIR="$STATE_DIR/data"
|
||||
HUB_DIR="$STATE_DIR/hub"
|
||||
PACKAGE="${cfg.package}"
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$HUB_DIR"
|
||||
|
||||
# Copy configuration files
|
||||
cp -f ${configFile} "$CONFIG_DIR/config.yaml"
|
||||
cp -f ${profilesFile} "$CONFIG_DIR/profiles.yaml"
|
||||
cp -f ${acquisitionsFile} "$CONFIG_DIR/acquisitions.yaml"
|
||||
cp -f ${simulationFile} "$CONFIG_DIR/simulation.yaml"
|
||||
|
||||
# Debug: Show acquisitions file content
|
||||
echo "Generated acquisitions.yaml:"
|
||||
cat "$CONFIG_DIR/acquisitions.yaml"
|
||||
echo ""
|
||||
|
||||
# Copy patterns directory from package (required for parser grok patterns)
|
||||
echo "Looking for patterns directory..."
|
||||
|
||||
# Try common locations
|
||||
PATTERNS_FOUND=0
|
||||
for PATTERNS_PATH in \
|
||||
"$PACKAGE/share/crowdsec/config/patterns" \
|
||||
"$PACKAGE/share/crowdsec/patterns" \
|
||||
"$PACKAGE/etc/crowdsec/patterns" \
|
||||
; do
|
||||
if [ -d "$PATTERNS_PATH" ]; then
|
||||
echo "Found patterns at: $PATTERNS_PATH"
|
||||
rm -rf "$CONFIG_DIR/patterns"
|
||||
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
|
||||
PATTERNS_FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If not found in common locations, search the entire package
|
||||
if [ "$PATTERNS_FOUND" = "0" ]; then
|
||||
echo "Searching for patterns directory in package..."
|
||||
PATTERNS_PATH=$(find "$PACKAGE" -type d -name "patterns" 2>/dev/null | head -1)
|
||||
if [ -n "$PATTERNS_PATH" ]; then
|
||||
echo "Found patterns at: $PATTERNS_PATH"
|
||||
rm -rf "$CONFIG_DIR/patterns"
|
||||
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
|
||||
PATTERNS_FOUND=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PATTERNS_FOUND" = "0" ]; then
|
||||
echo "WARNING: Could not find patterns directory!"
|
||||
echo "Package contents:"
|
||||
ls -la "$PACKAGE/" || true
|
||||
ls -la "$PACKAGE/share/" || true
|
||||
ls -la "$PACKAGE/share/crowdsec/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Initialize database if it doesn't exist
|
||||
if [ ! -f "$DATA_DIR/crowdsec.db" ]; then
|
||||
echo "Initializing CrowdSec database..."
|
||||
touch "$CONFIG_DIR/local_api_credentials.yaml"
|
||||
touch "$CONFIG_DIR/online_api_credentials.yaml"
|
||||
chmod 640 "$CONFIG_DIR/local_api_credentials.yaml"
|
||||
chmod 640 "$CONFIG_DIR/online_api_credentials.yaml"
|
||||
fi
|
||||
|
||||
# Generate machine ID if it doesn't exist
|
||||
if [ ! -f "$CONFIG_DIR/local_api_credentials.yaml" ] || [ ! -s "$CONFIG_DIR/local_api_credentials.yaml" ]; then
|
||||
echo "Registering local machine..."
|
||||
cscli -c "$CONFIG_DIR/config.yaml" machines add "$(hostname)" --auto --force || true
|
||||
fi
|
||||
|
||||
# Update hub index
|
||||
echo "Updating hub index..."
|
||||
cscli -c "$CONFIG_DIR/config.yaml" hub update || true
|
||||
|
||||
# Set correct ownership
|
||||
chown -R crowdsec:crowdsec "$STATE_DIR"
|
||||
'';
|
||||
|
||||
# Hub installation script (runs after service is started)
|
||||
hubInstallScript = pkgs.writeShellScript "crowdsec-hub-install" ''
|
||||
set -e
|
||||
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep ]}:$PATH"
|
||||
|
||||
CONFIG_DIR="${stateDir}/config"
|
||||
|
||||
# Wait for API to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if cscli -c "$CONFIG_DIR/config.yaml" hub list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Install collections
|
||||
${lib.concatMapStringsSep "\n" (c: ''
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" collections list 2>/dev/null | grep -q "${c}"; then
|
||||
cscli -c "$CONFIG_DIR/config.yaml" collections install ${c} || true
|
||||
fi
|
||||
'') hubCollections}
|
||||
|
||||
# Install additional scenarios
|
||||
${lib.concatMapStringsSep "\n" (s: ''
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" scenarios list 2>/dev/null | grep -q "${s}"; then
|
||||
cscli -c "$CONFIG_DIR/config.yaml" scenarios install ${s} || true
|
||||
fi
|
||||
'') cfg.hub.scenarios}
|
||||
|
||||
# Install additional parsers
|
||||
${lib.concatMapStringsSep "\n" (p: ''
|
||||
if ! cscli -c "$CONFIG_DIR/config.yaml" parsers list 2>/dev/null | grep -q "${p}"; then
|
||||
cscli -c "$CONFIG_DIR/config.yaml" parsers install ${p} || true
|
||||
fi
|
||||
'') cfg.hub.parsers}
|
||||
'';
|
||||
|
||||
in lib.mkMerge [
|
||||
|
||||
# ==========================================================================
|
||||
# Common Configuration (both implementations)
|
||||
# ==========================================================================
|
||||
{
|
||||
# Assertions
|
||||
assertions = [
|
||||
{
|
||||
assertion = acquisitions != [];
|
||||
message = ''
|
||||
CrowdSec requires at least one acquisition source.
|
||||
|
||||
Enable at least one of:
|
||||
- infrastructure.crowdsec.features.sshProtection = true
|
||||
- infrastructure.crowdsec.features.nginxProtection = true
|
||||
- infrastructure.crowdsec.features.systemProtection = true
|
||||
|
||||
Or add custom acquisitions via infrastructure.crowdsec.acquisitions
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = cfg.implementation != "native" || hasNativeCrowdsecModule;
|
||||
message = ''
|
||||
CrowdSec native implementation requires NixOS 25.11 or later.
|
||||
|
||||
Either:
|
||||
1. Upgrade to NixOS 25.11+
|
||||
2. Set infrastructure.crowdsec.implementation = "custom"
|
||||
3. Set infrastructure.crowdsec.implementation = "auto" (recommended)
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Open firewall for LAPI if configured
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.mkIf cfg.api.openFirewall [ cfg.api.listenPort ];
|
||||
|
||||
# Install useful CLI tools
|
||||
environment.systemPackages = [
|
||||
cfg.package # Includes cscli
|
||||
];
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# Custom Implementation
|
||||
# ==========================================================================
|
||||
(lib.mkIf (!useNativeImplementation) {
|
||||
# Create crowdsec user and group
|
||||
users.users.crowdsec = {
|
||||
isSystemUser = true;
|
||||
group = "crowdsec";
|
||||
home = stateDir;
|
||||
description = "CrowdSec daemon user";
|
||||
};
|
||||
users.groups.crowdsec = {};
|
||||
|
||||
# Ensure data directories exist and create config symlink for cscli
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${stateDir} 0755 crowdsec crowdsec - -"
|
||||
"d ${stateDir}/config 0755 crowdsec crowdsec - -"
|
||||
"d ${stateDir}/data 0755 crowdsec crowdsec - -"
|
||||
"d ${stateDir}/hub 0755 crowdsec crowdsec - -"
|
||||
# Create /etc/crowdsec directory and symlink for cscli default config path
|
||||
"L+ /etc/crowdsec/config.yaml - - - - ${stateDir}/config/config.yaml"
|
||||
];
|
||||
|
||||
# Main CrowdSec service
|
||||
systemd.services.crowdsec = {
|
||||
description = "CrowdSec Security Engine";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "local-fs.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "crowdsec";
|
||||
Group = "crowdsec";
|
||||
ExecStartPre = [
|
||||
"+${initScript}" # Run as root for permissions
|
||||
];
|
||||
ExecStart = "${cfg.package}/bin/crowdsec -c ${stateDir}/config/config.yaml";
|
||||
ExecStartPost = "${hubInstallScript}";
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Security hardening
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
NoNewPrivileges = true;
|
||||
ReadWritePaths = [ stateDir ];
|
||||
|
||||
# Allow journal access for systemd log sources
|
||||
SupplementaryGroups = lib.optional (cfg.features.sshProtection || cfg.features.systemProtection) "systemd-journal";
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
# ==========================================================================
|
||||
# Native Implementation (NixOS 25.11+)
|
||||
# ==========================================================================
|
||||
(lib.mkIf (useNativeImplementation && hasNativeCrowdsecModule) {
|
||||
# Workarounds for native module bugs
|
||||
systemd.tmpfiles.rules = [
|
||||
# WORKAROUND #445342: Create state directory
|
||||
# WORKAROUND #446764: Create online_api_credentials.yaml
|
||||
"f /var/lib/crowdsec/online_api_credentials.yaml 0640 crowdsec crowdsec - -"
|
||||
];
|
||||
|
||||
services.crowdsec = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
|
||||
# Hub items to install (only collections - other options may not exist)
|
||||
hub = {
|
||||
collections = hubCollections;
|
||||
};
|
||||
|
||||
# Local configuration (acquisitions)
|
||||
localConfig = {
|
||||
inherit acquisitions;
|
||||
} // cfg.extraLocalConfig;
|
||||
|
||||
# Main settings
|
||||
settings = lib.mkMerge [
|
||||
{
|
||||
# WORKAROUND: BUG #445342 - Enable API server by default
|
||||
general.api.server.enable = true;
|
||||
}
|
||||
|
||||
# Console enrollment (if configured)
|
||||
# Note: console options are defined in integrations/console.nix
|
||||
(lib.mkIf (cfg.console.enrollKeyFile != null) {
|
||||
console.tokenFile = cfg.console.enrollKeyFile;
|
||||
})
|
||||
|
||||
# User's extra settings
|
||||
cfg.extraSettings
|
||||
];
|
||||
};
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# CrowdSec Auditd Integration Module
|
||||
# Provides kernel-level security event monitoring via Linux Audit Framework
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName}.auditd = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable auditd integration with CrowdSec.
|
||||
|
||||
When enabled, configures auditd to send audit events to CrowdSec
|
||||
for analysis. This enables detection of:
|
||||
- Privilege escalation attempts
|
||||
- Unauthorized file access
|
||||
- System call anomalies
|
||||
- User authentication events
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Provides kernel-level
|
||||
visibility into security events and potential threats.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
rules = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Additional auditd rules to configure for CrowdSec monitoring.
|
||||
|
||||
These rules are added to the system's auditd configuration.
|
||||
|
||||
Common rules for security monitoring:
|
||||
- File integrity: "-w /etc/passwd -p wa -k identity"
|
||||
- Privilege escalation: "-w /usr/bin/sudo -p x -k privilege"
|
||||
- Network configuration: "-w /etc/hosts -p wa -k network"
|
||||
'';
|
||||
default = [];
|
||||
example = [
|
||||
"-w /etc/passwd -p wa -k identity"
|
||||
"-w /etc/shadow -p wa -k identity"
|
||||
"-w /etc/sudoers -p wa -k privilege"
|
||||
];
|
||||
};
|
||||
|
||||
nixWrappersWhitelistProcess = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
List of process names to whitelist from auditd monitoring.
|
||||
|
||||
NOTE: This feature is currently disabled due to compatibility issues
|
||||
with the 'comm' field filter in some versions of auditd. The option
|
||||
is preserved for future use when auditd compatibility is resolved.
|
||||
|
||||
NixOS uses wrapper scripts in /run/wrappers/bin for setuid/setgid
|
||||
programs (like sudo, ping, etc.). These wrappers can generate a lot
|
||||
of noise in auditd logs.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Reduces audit log noise
|
||||
while maintaining security visibility on critical processes.
|
||||
'';
|
||||
default = [];
|
||||
example = [ "sshd" "systemd" "sudo" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
config = lib.mkIf (cfg.enable && cfg.auditd.enable) {
|
||||
# Enable the Linux Audit daemon
|
||||
security.auditd.enable = true;
|
||||
|
||||
# Add user-defined audit rules
|
||||
# Note: The nixWrappersWhitelistProcess feature is currently disabled
|
||||
# due to auditd compatibility issues with the 'comm' field filter
|
||||
security.audit.rules = cfg.auditd.rules;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# CrowdSec Console Integration Module
|
||||
# Provides cloud enrollment and community threat intelligence sharing
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
appName = "crowdsec";
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
# ==========================================================================
|
||||
# Options
|
||||
# ==========================================================================
|
||||
options.infrastructure.${appName}.console = {
|
||||
enrollKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Path to file containing the CrowdSec Console enrollment key.
|
||||
|
||||
Enrolling connects your instance to the CrowdSec Console for:
|
||||
- Centralized monitoring and management
|
||||
- Access to community and commercial blocklists
|
||||
- Threat intelligence dashboards
|
||||
- Alert visualization and analytics
|
||||
|
||||
Get your enrollment key from: https://app.crowdsec.net/
|
||||
|
||||
The enrollment key should be stored securely, for example using
|
||||
agenix or sops-nix for secrets management.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 21(2)(g) - Security Monitoring: Provides centralized
|
||||
visibility into security events across infrastructure.
|
||||
|
||||
Article 23 - Reporting: Facilitates incident documentation
|
||||
and reporting through centralized logging.
|
||||
'';
|
||||
default = null;
|
||||
example = "/run/secrets/crowdsec-enroll-key";
|
||||
};
|
||||
|
||||
shareDecisions = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Share your detected threats with the CrowdSec community.
|
||||
|
||||
When enabled, anonymized attack signals are shared to improve
|
||||
collective threat intelligence for all CrowdSec users. This is
|
||||
a key part of CrowdSec's collaborative security model.
|
||||
|
||||
Shared data includes:
|
||||
- Source IP addresses of attacks
|
||||
- Attack type/scenario that triggered
|
||||
- Timestamp of the attack
|
||||
|
||||
Personal data and log contents are NOT shared.
|
||||
|
||||
[NIS2 COMPLIANCE]
|
||||
Article 14 - Information Sharing: Contributes to EU-wide
|
||||
cybersecurity by participating in threat intelligence sharing.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Custom name for this instance in the CrowdSec Console.
|
||||
|
||||
If not set, the hostname will be used. Useful for identifying
|
||||
machines in multi-server deployments.
|
||||
'';
|
||||
default = null;
|
||||
example = "web-server-01";
|
||||
};
|
||||
|
||||
tags = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Tags to apply to this instance in the CrowdSec Console.
|
||||
|
||||
Tags help organize and filter machines in the console dashboard.
|
||||
'';
|
||||
default = [];
|
||||
example = [ "production" "web-tier" "eu-west" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration
|
||||
# ==========================================================================
|
||||
# Note: The actual console enrollment is handled by the main module
|
||||
# since it requires integration with both native and custom implementations.
|
||||
# This module only defines the options.
|
||||
#
|
||||
# For native implementation: settings are passed to services.crowdsec.settings
|
||||
# For custom implementation: enrollment is done via cscli in the init script
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "n8n";
|
||||
defaultPort = 5678;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
# Build the custom n8n package with version selection
|
||||
n8nPackage = if cfg.package != null then cfg.package else
|
||||
pkgs.callPackage ./package.nix {
|
||||
version = cfg.version;
|
||||
buildMemoryMB = cfg.buildMemoryMB;
|
||||
};
|
||||
|
||||
# Environment variables for n8n configuration
|
||||
n8nEnvironment = {
|
||||
# Network settings
|
||||
N8N_PORT = toString cfg.bindToPort;
|
||||
N8N_LISTEN_ADDRESS = cfg.bindToIp;
|
||||
|
||||
# Execution settings
|
||||
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
|
||||
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
|
||||
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
|
||||
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
|
||||
# Webhook URL (if specified)
|
||||
WEBHOOK_URL = cfg.webhookUrl;
|
||||
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
|
||||
# Database settings (only set if using PostgreSQL)
|
||||
DB_TYPE = "postgresdb";
|
||||
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
|
||||
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
|
||||
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
|
||||
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
|
||||
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
|
||||
DB_POSTGRESDB_SSL_ENABLED = "true";
|
||||
}) // cfg.settings;
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.n8n";
|
||||
|
||||
# ==========================================================================
|
||||
# Package and Version Configuration
|
||||
# ==========================================================================
|
||||
|
||||
version = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
n8n version to install.
|
||||
|
||||
Supported versions are defined in package.nix. To add a new version,
|
||||
you need to compute the source and pnpm dependency hashes.
|
||||
|
||||
See package.nix for instructions on adding new versions.
|
||||
'';
|
||||
default = "2.1.5";
|
||||
example = "1.120.4";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
description = ''
|
||||
Custom n8n package to use. If null, the package will be built
|
||||
using the version specified in 'version' option.
|
||||
|
||||
Use this to provide a completely custom n8n build.
|
||||
'';
|
||||
default = null;
|
||||
example = lib.literalExpression "pkgs.n8n";
|
||||
};
|
||||
|
||||
buildMemoryMB = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = ''
|
||||
Maximum Node.js heap size in MB for building n8n.
|
||||
Increase this if you encounter "JavaScript heap out of memory" errors during build.
|
||||
'';
|
||||
default = 4096;
|
||||
example = 8192;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Network Configuration
|
||||
# ==========================================================================
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind n8n to.";
|
||||
default = "127.0.0.1";
|
||||
example = "0.0.0.0";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for n8n web interface.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall for n8n.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Webhook Configuration
|
||||
# ==========================================================================
|
||||
|
||||
webhookUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
|
||||
This is the external URL where webhooks can reach n8n.
|
||||
'';
|
||||
default = "";
|
||||
example = "https://n8n.example.com/";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Data Directory
|
||||
# ==========================================================================
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Directory where n8n data is stored.";
|
||||
default = "/var/lib/n8n";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Database Configuration
|
||||
# ==========================================================================
|
||||
database = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "sqlite" "postgresdb" ];
|
||||
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
|
||||
default = "sqlite";
|
||||
};
|
||||
|
||||
postgresdb = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL host.";
|
||||
default = "localhost";
|
||||
example = "/run/postgresql";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "PostgreSQL port.";
|
||||
default = 5432;
|
||||
};
|
||||
|
||||
database = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL database name.";
|
||||
default = "n8n";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL user.";
|
||||
default = "n8n";
|
||||
};
|
||||
|
||||
passwordSecretName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the secret containing the PostgreSQL password.
|
||||
The secret should be placed at /run/secrets/<n>.
|
||||
If null, peer/socket authentication is assumed.
|
||||
'';
|
||||
default = null;
|
||||
example = "n8n-db-password";
|
||||
};
|
||||
|
||||
ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable SSL for PostgreSQL connection.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
createLocally = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Whether to create the database user locally.
|
||||
This requires PostgreSQL to be running locally with trust or peer authentication.
|
||||
The database itself should be created via infrastructure.postgresql.initialDatabases.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Execution Configuration
|
||||
# ==========================================================================
|
||||
|
||||
executions = {
|
||||
pruneData = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable automatic pruning of old execution data.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
pruneDataMaxAge = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Maximum age of execution data in hours before pruning.";
|
||||
default = 336; # 14 days
|
||||
};
|
||||
|
||||
pruneDataMaxCount = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Maximum number of executions to keep.";
|
||||
default = 10000;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# n8n Settings (pass-through as environment variables)
|
||||
# ==========================================================================
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Additional n8n configuration as environment variables.
|
||||
These are passed directly to the n8n service.
|
||||
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
GENERIC_TIMEZONE = "Europe/London";
|
||||
WORKFLOWS_DEFAULT_NAME = "My Workflow";
|
||||
N8N_METRICS = "true";
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Reverse Proxy Configuration
|
||||
# ==========================================================================
|
||||
|
||||
reverseProxy = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable nginx reverse proxy for n8n.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Hostname for the reverse proxy.";
|
||||
default = "localhost";
|
||||
example = "n8n.example.com";
|
||||
};
|
||||
|
||||
ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable SSL/HTTPS for the reverse proxy.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# ==========================================================================
|
||||
# Disable the native n8n service (we'll configure our own systemd service)
|
||||
# ==========================================================================
|
||||
|
||||
# Do NOT enable services.n8n - we create our own service to have full control
|
||||
|
||||
# ==========================================================================
|
||||
# n8n User and Group
|
||||
# ==========================================================================
|
||||
|
||||
users.users.n8n = {
|
||||
isSystemUser = true;
|
||||
group = "n8n";
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
description = "n8n service user";
|
||||
};
|
||||
|
||||
users.groups.n8n = {};
|
||||
|
||||
# ==========================================================================
|
||||
# n8n Systemd Service
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.n8n = {
|
||||
description = "n8n - Workflow Automation";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ] ++
|
||||
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
|
||||
lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
|
||||
"postgresql.service"
|
||||
"n8n-db-setup.service"
|
||||
];
|
||||
wants = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
|
||||
"n8n-db-setup.service"
|
||||
];
|
||||
requires = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
|
||||
"postgresql.service"
|
||||
];
|
||||
|
||||
environment = n8nEnvironment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "n8n";
|
||||
Group = "n8n";
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${n8nPackage}/bin/n8n";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Nginx Reverse Proxy (Optional)
|
||||
# ==========================================================================
|
||||
|
||||
services.nginx = lib.mkIf cfg.reverseProxy.enable {
|
||||
enable = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = cfg.reverseProxy.ssl;
|
||||
|
||||
virtualHosts.${cfg.reverseProxy.hostName} = {
|
||||
forceSSL = cfg.reverseProxy.ssl;
|
||||
enableACME = cfg.reverseProxy.ssl;
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
chunked_transfer_encoding off;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Firewall Configuration
|
||||
# ==========================================================================
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
|
||||
[ cfg.bindToPort ] ++
|
||||
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
|
||||
);
|
||||
|
||||
# ==========================================================================
|
||||
# Service Dependencies
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
|
||||
wants = [ "n8n.service" ];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# PostgreSQL Database Setup (Optional)
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.n8n-db-setup = lib.mkIf (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) {
|
||||
description = "Create n8n database user";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "postgresql.service" ];
|
||||
requires = [ "postgresql.service" ];
|
||||
before = [ "n8n.service" ];
|
||||
requiredBy = [ "n8n.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "postgres";
|
||||
};
|
||||
script = let
|
||||
dbUser = cfg.database.postgresdb.user;
|
||||
dbName = cfg.database.postgresdb.database;
|
||||
dbHost = cfg.database.postgresdb.host;
|
||||
dbPort = toString cfg.database.postgresdb.port;
|
||||
in ''
|
||||
# Wait for PostgreSQL to be ready
|
||||
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort}; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Create database user if it doesn't exist
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1 || \
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
|
||||
|
||||
# Grant privileges on database
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}"
|
||||
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Utilities
|
||||
# ==========================================================================
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Custom n8n package with version selection
|
||||
# Based on: https://github.com/NixOS/nixpkgs/blob/nixos-25.11/pkgs/by-name/n8/n8n/package.nix
|
||||
#
|
||||
# To add a new version:
|
||||
# 1. Get the source hash:
|
||||
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
|
||||
# 2. Get the pnpm deps hash by running a build with lib.fakeHash and copying the correct hash from error
|
||||
# 3. Add entry to versionHashes below
|
||||
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
nodejs,
|
||||
pnpm_10,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
python3,
|
||||
node-gyp,
|
||||
cctools,
|
||||
xcbuild,
|
||||
libkrb5,
|
||||
libmongocrypt,
|
||||
libpq,
|
||||
makeWrapper,
|
||||
# Custom parameters
|
||||
version ? "2.1.5",
|
||||
buildMemoryMB ? 4096,
|
||||
}:
|
||||
|
||||
let
|
||||
# Known version hashes
|
||||
# To add a new version, run:
|
||||
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
|
||||
# Then build with lib.fakeHash for pnpmDepsHash to get the correct hash
|
||||
versionHashes = {
|
||||
"2.1.5" = {
|
||||
srcHash = "sha256-/MPY3j/2I3CgX5rRhzj3v7bHjaQEDMNnkVfk3taCrYA=";
|
||||
pnpmDepsHash = "sha256-FRoZIINONy0kFPQAJhOwnCUv7HHwdgqm3r5SJmq4UYk=";
|
||||
};
|
||||
"2.1.4" = {
|
||||
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
};
|
||||
"2.0.0" = {
|
||||
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
};
|
||||
"1.120.4" = {
|
||||
srcHash = "sha256-gUqQM/eA7GnvFYiduSGkj/MCvgWNQPhDLExAJz67bHg=";
|
||||
pnpmDepsHash = "sha256-UWiN3NvI8We16KwY5JspyX0ok1PJWVg0T5zw+0SnrWk=";
|
||||
};
|
||||
"1.91.3" = {
|
||||
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
};
|
||||
};
|
||||
|
||||
# Get hashes for the requested version
|
||||
hashes = versionHashes.${version} or (throw ''
|
||||
n8n version ${version} is not supported.
|
||||
|
||||
Supported versions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
|
||||
|
||||
To add support for version ${version}:
|
||||
1. Get source hash: nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@${version}.tar.gz
|
||||
2. Add entry to versionHashes in app_modules/_unstable/n8n/package.nix
|
||||
3. Build once with placeholder pnpmDepsHash to get the correct hash from the error message
|
||||
'');
|
||||
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "n8n";
|
||||
inherit version;
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "n8n-io";
|
||||
repo = "n8n";
|
||||
tag = "n8n@${finalAttrs.version}";
|
||||
hash = hashes.srcHash;
|
||||
};
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pnpm_10;
|
||||
fetcherVersion = 2;
|
||||
hash = hashes.pnpmDepsHash;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
pnpmConfigHook
|
||||
pnpm_10
|
||||
python3 # required to build sqlite3 bindings
|
||||
node-gyp # required to build sqlite3 bindings
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
cctools
|
||||
xcbuild
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
nodejs
|
||||
libkrb5
|
||||
libmongocrypt
|
||||
libpq
|
||||
];
|
||||
|
||||
# Set memory limit for Node.js during build
|
||||
env = {
|
||||
NODE_OPTIONS = "--max-old-space-size=${toString buildMemoryMB}";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
pushd node_modules/sqlite3
|
||||
node-gyp rebuild
|
||||
popd
|
||||
|
||||
# TODO: use deploy after resolved https://github.com/pnpm/pnpm/issues/5315
|
||||
pnpm build --filter=n8n
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
preInstall = ''
|
||||
echo "Removing non-deterministic and unnecessary files"
|
||||
|
||||
find -type d -name .turbo -exec rm -rf {} +
|
||||
rm node_modules/.modules.yaml
|
||||
rm -f packages/nodes-base/dist/types/nodes.json
|
||||
|
||||
CI=true pnpm --ignore-scripts prune --prod
|
||||
find -type f \( -name "*.ts" -o -name "*.map" \) -exec rm -rf {} +
|
||||
rm -rf node_modules/.pnpm/{typescript*,prettier*}
|
||||
shopt -s globstar
|
||||
# https://github.com/pnpm/pnpm/issues/3645
|
||||
find node_modules packages/**/node_modules -xtype l -delete
|
||||
|
||||
echo "Removed non-deterministic and unnecessary files"
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/{bin,lib/n8n}
|
||||
mv {packages,node_modules} $out/lib/n8n
|
||||
|
||||
makeWrapper $out/lib/n8n/packages/cli/bin/n8n $out/bin/n8n \
|
||||
--set N8N_RELEASE_TYPE "stable"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
# this package has ~80000 files, these take too long and seem to be unnecessary
|
||||
dontStrip = true;
|
||||
dontPatchELF = true;
|
||||
dontRewriteSymlinks = true;
|
||||
|
||||
meta = {
|
||||
description = "Free and source-available fair-code licensed workflow automation tool";
|
||||
longDescription = ''
|
||||
Free and source-available fair-code licensed workflow automation tool.
|
||||
Easily automate tasks across different services.
|
||||
'';
|
||||
homepage = "https://n8n.io";
|
||||
changelog = "https://github.com/n8n-io/n8n/releases/tag/n8n@${finalAttrs.version}";
|
||||
maintainers = with lib.maintainers; [
|
||||
gepbird
|
||||
AdrienLemaire
|
||||
];
|
||||
license = lib.licenses.sustainableUse;
|
||||
mainProgram = "n8n";
|
||||
platforms = lib.platforms.unix;
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
# Wrapper module that imports the unstable crowdsec module
|
||||
# This allows the standard app_modules/default.nix import to work
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../_unstable/crowdsec
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
imports = [
|
||||
./elasticsearch
|
||||
./haproxy
|
||||
./home-assistant
|
||||
./mariadb
|
||||
./minio
|
||||
./mongodb
|
||||
./mongodb-pod
|
||||
./n8n-pod
|
||||
./nextcloud
|
||||
./nginx
|
||||
./opensearch
|
||||
./postgresql
|
||||
./rabbitmq
|
||||
./redis
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "elasticsearch";
|
||||
defaultHttpPort = 9200;
|
||||
defaultTransportPort = 9300;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.elasticsearch";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "Elasticsearch package to use.";
|
||||
default = pkgs.elasticsearch;
|
||||
example = "pkgs.elasticsearch7";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind for HTTP API.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
httpPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for HTTP API.";
|
||||
default = defaultHttpPort;
|
||||
};
|
||||
|
||||
transportPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for transport/cluster communication.";
|
||||
default = defaultTransportPort;
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Data directory for Elasticsearch.";
|
||||
default = "/var/lib/elasticsearch";
|
||||
};
|
||||
|
||||
clusterName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Name of the Elasticsearch cluster.";
|
||||
default = "elasticsearch";
|
||||
};
|
||||
|
||||
singleNode = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Run as a single-node cluster (disables bootstrap checks).";
|
||||
default = true;
|
||||
};
|
||||
|
||||
heapSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "JVM heap size for Elasticsearch (e.g., '512m', '1g').";
|
||||
default = "512m";
|
||||
};
|
||||
|
||||
extraSettings = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = "Extra settings to add to elasticsearch.yml.";
|
||||
default = {};
|
||||
example = { "action.destructive_requires_name" = true; };
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.elasticsearch = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
dataDir = cfg.dataDir;
|
||||
cluster_name = cfg.clusterName;
|
||||
listenAddress = cfg.bindToIp;
|
||||
port = cfg.httpPort;
|
||||
tcp_port = cfg.transportPort;
|
||||
single_node = cfg.singleNode;
|
||||
|
||||
extraConf = lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (name: value: "${name}: ${builtins.toJSON value}") cfg.extraSettings
|
||||
);
|
||||
|
||||
extraJavaOptions = [
|
||||
"-Xms${cfg.heapSize}"
|
||||
"-Xmx${cfg.heapSize}"
|
||||
];
|
||||
};
|
||||
|
||||
# Install curl for API access
|
||||
environment.systemPackages = [ pkgs.curl pkgs.jq ];
|
||||
|
||||
# Open firewall for Elasticsearch if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
|
||||
cfg.httpPort
|
||||
cfg.transportPort
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "haproxy";
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
# Generate combined PEM file path for a domain
|
||||
combinedPemPath = domain: "/var/lib/acme/${domain}/combined.pem";
|
||||
|
||||
# Self-signed certificate directory
|
||||
selfSignedCertDir = "/var/lib/haproxy/certs";
|
||||
|
||||
# Script to concatenate fullchain.pem and privkey.pem for HAProxy
|
||||
# HAProxy requires a single file with cert chain + private key
|
||||
mkCombinePemScript = domain: pkgs.writeShellScript "combine-pem-${domain}" ''
|
||||
ACME_DIR="/var/lib/acme/${domain}"
|
||||
COMBINED="$ACME_DIR/combined.pem"
|
||||
|
||||
if [ -f "$ACME_DIR/fullchain.pem" ] && [ -f "$ACME_DIR/privkey.pem" ]; then
|
||||
cat "$ACME_DIR/fullchain.pem" "$ACME_DIR/privkey.pem" > "$COMBINED"
|
||||
chmod 640 "$COMBINED"
|
||||
chown acme:haproxy "$COMBINED"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Script to generate self-signed certificates for testing
|
||||
mkSelfSignedCertScript = domain: pkgs.writeShellScript "generate-self-signed-${domain}" ''
|
||||
CERT_DIR="${selfSignedCertDir}"
|
||||
COMBINED="$CERT_DIR/${domain}.pem"
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Only generate if not exists or expired
|
||||
if [ ! -f "$COMBINED" ] || ! ${pkgs.openssl}/bin/openssl x509 -checkend 86400 -noout -in "$COMBINED" 2>/dev/null; then
|
||||
echo "Generating self-signed certificate for ${domain}..."
|
||||
${pkgs.openssl}/bin/openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout "$CERT_DIR/${domain}.key" \
|
||||
-out "$CERT_DIR/${domain}.crt" \
|
||||
-sha256 -days 365 -nodes \
|
||||
-subj "/CN=${domain}" \
|
||||
-addext "subjectAltName=DNS:${domain},DNS:*.${domain}"
|
||||
|
||||
# Combine for HAProxy
|
||||
cat "$CERT_DIR/${domain}.crt" "$CERT_DIR/${domain}.key" > "$COMBINED"
|
||||
chmod 640 "$COMBINED"
|
||||
chown haproxy:haproxy "$COMBINED"
|
||||
rm -f "$CERT_DIR/${domain}.key" "$CERT_DIR/${domain}.crt"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Default HAProxy global configuration
|
||||
defaultGlobalConfig = ''
|
||||
global
|
||||
log /dev/log local0
|
||||
log /dev/log local1 notice
|
||||
maxconn 4096
|
||||
# Modern SSL settings
|
||||
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
|
||||
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11
|
||||
tune.ssl.default-dh-param 2048
|
||||
'';
|
||||
|
||||
# Default HAProxy defaults configuration
|
||||
defaultDefaultsConfig = ''
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
option dontlognull
|
||||
option forwardfor
|
||||
option http-server-close
|
||||
timeout connect 5s
|
||||
timeout client 50s
|
||||
timeout server 50s
|
||||
timeout http-request 10s
|
||||
timeout http-keep-alive 10s
|
||||
errorfile 400 /dev/null
|
||||
errorfile 403 /dev/null
|
||||
errorfile 408 /dev/null
|
||||
errorfile 500 /dev/null
|
||||
errorfile 502 /dev/null
|
||||
errorfile 503 /dev/null
|
||||
errorfile 504 /dev/null
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.haproxy";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "HAProxy package to use.";
|
||||
default = pkgs.haproxy;
|
||||
example = "pkgs.haproxy-lts";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
|
||||
default = true;
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "User account under which HAProxy runs.";
|
||||
default = "haproxy";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Group account under which HAProxy runs.";
|
||||
default = "haproxy";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Let's Encrypt / ACME Configuration
|
||||
# ==========================================================================
|
||||
|
||||
acme = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable ACME (Let's Encrypt) certificate management.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
acceptTerms = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Accept the ACME provider's terms of service.
|
||||
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
email = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Default email address for ACME certificate registration and renewal notifications.";
|
||||
default = null;
|
||||
example = "admin@example.com";
|
||||
};
|
||||
|
||||
staging = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Use Let's Encrypt staging server for testing.
|
||||
Certificates won't be trusted but you won't hit rate limits.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
domains = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
extraDomainNames = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Additional domain names (SANs) for this certificate.";
|
||||
default = [];
|
||||
example = [ "www.example.com" "api.example.com" ];
|
||||
};
|
||||
webroot = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Webroot path for HTTP-01 challenge. If null, standalone mode is used.";
|
||||
default = "/var/lib/acme/acme-challenge";
|
||||
};
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = "Extra configuration options for this certificate.";
|
||||
default = {};
|
||||
};
|
||||
};
|
||||
});
|
||||
description = ''
|
||||
Domains to obtain certificates for. The key is the primary domain name.
|
||||
Use extraDomainNames for additional SANs (Subject Alternative Names).
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"example.com" = {
|
||||
extraDomainNames = [ "www.example.com" ];
|
||||
};
|
||||
"api.example.com" = {};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Extra configuration options passed to security.acme.defaults.
|
||||
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
|
||||
'';
|
||||
default = {};
|
||||
example = {
|
||||
renewInterval = "daily";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Self-Signed Certificate Configuration (for testing)
|
||||
# ==========================================================================
|
||||
|
||||
selfSigned = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable self-signed certificate generation for testing.
|
||||
These certificates are NOT trusted by browsers but useful for development/testing.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
domains = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "List of domains to generate self-signed certificates for.";
|
||||
default = [];
|
||||
example = [ "localhost" "test.local" ];
|
||||
};
|
||||
|
||||
regenerate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Force regeneration of self-signed certificates on each activation.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# SSL/TLS Configuration
|
||||
# ==========================================================================
|
||||
|
||||
ssl = {
|
||||
minVersion = lib.mkOption {
|
||||
type = lib.types.enum [ "TLSv1.2" "TLSv1.3" ];
|
||||
description = "Minimum TLS version to accept.";
|
||||
default = "TLSv1.2";
|
||||
};
|
||||
|
||||
ciphers = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Custom cipher suite for TLS 1.2 and below.";
|
||||
default = null;
|
||||
example = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256";
|
||||
};
|
||||
|
||||
ciphersuites = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Custom cipher suite for TLS 1.3.";
|
||||
default = null;
|
||||
example = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384";
|
||||
};
|
||||
|
||||
hsts = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable HTTP Strict Transport Security (HSTS) header.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
maxAge = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "HSTS max-age in seconds.";
|
||||
default = 31536000; # 1 year
|
||||
};
|
||||
|
||||
includeSubDomains = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Include subdomains in HSTS policy.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
preload = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Add preload directive to HSTS header.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# HTTP to HTTPS Redirect
|
||||
# ==========================================================================
|
||||
|
||||
httpToHttpsRedirect = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Automatically redirect HTTP requests to HTTPS.
|
||||
Creates a frontend on port 80 that redirects all traffic to HTTPS.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
code = lib.mkOption {
|
||||
type = lib.types.enum [ 301 302 307 308 ];
|
||||
description = "HTTP redirect status code to use.";
|
||||
default = 301;
|
||||
};
|
||||
|
||||
excludePaths = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Paths to exclude from redirect (e.g., ACME challenge).";
|
||||
default = [ "/.well-known/acme-challenge/" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# HAProxy Configuration
|
||||
# ==========================================================================
|
||||
|
||||
globalConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "HAProxy global section configuration.";
|
||||
default = defaultGlobalConfig;
|
||||
example = ''
|
||||
global
|
||||
log /dev/log local0
|
||||
maxconn 2048
|
||||
'';
|
||||
};
|
||||
|
||||
defaultsConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "HAProxy defaults section configuration.";
|
||||
default = defaultDefaultsConfig;
|
||||
example = ''
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
timeout connect 5s
|
||||
timeout client 50s
|
||||
timeout server 50s
|
||||
'';
|
||||
};
|
||||
|
||||
frontends = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
bind = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Bind addresses and ports.";
|
||||
default = [];
|
||||
example = [ "*:80" "*:443 ssl crt /path/to/cert.pem" ];
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "http" "tcp" ];
|
||||
description = "Frontend mode.";
|
||||
default = "http";
|
||||
};
|
||||
options = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "HAProxy options for this frontend.";
|
||||
default = [];
|
||||
example = [ "httplog" "forwardfor" ];
|
||||
};
|
||||
acls = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "ACL definitions.";
|
||||
default = [];
|
||||
example = [ "is_api path_beg /api" "is_static path_beg /static" ];
|
||||
};
|
||||
httpRequest = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "http-request rules.";
|
||||
default = [];
|
||||
example = [ "set-header X-Forwarded-Proto https if { ssl_fc }" ];
|
||||
};
|
||||
httpResponse = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "http-response rules.";
|
||||
default = [];
|
||||
example = [ "set-header Strict-Transport-Security max-age=31536000" ];
|
||||
};
|
||||
tcpRequest = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "tcp-request rules (for TCP mode).";
|
||||
default = [];
|
||||
example = [ "inspect-delay 5s" "content accept if { req_ssl_hello_type 1 }" ];
|
||||
};
|
||||
useBackend = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "use_backend rules.";
|
||||
default = [];
|
||||
example = [ "api_backend if is_api" "static_backend if is_static" ];
|
||||
};
|
||||
defaultBackend = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Default backend for this frontend.";
|
||||
default = null;
|
||||
example = "web_backend";
|
||||
};
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "Extra configuration for this frontend.";
|
||||
default = "";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = "HAProxy frontend configurations.";
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
http = {
|
||||
bind = [ "*:80" ];
|
||||
defaultBackend = "web_backend";
|
||||
};
|
||||
https = {
|
||||
bind = [ "*:443 ssl crt /var/lib/acme/example.com/combined.pem" ];
|
||||
httpRequest = [ "set-header X-Forwarded-Proto https" ];
|
||||
defaultBackend = "web_backend";
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
backends = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "http" "tcp" ];
|
||||
description = "Backend mode.";
|
||||
default = "http";
|
||||
};
|
||||
balance = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Load balancing algorithm.";
|
||||
default = "roundrobin";
|
||||
example = "leastconn";
|
||||
};
|
||||
options = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "HAProxy options for this backend.";
|
||||
default = [];
|
||||
example = [ "httpchk GET /health" ];
|
||||
};
|
||||
httpRequest = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "http-request rules for this backend.";
|
||||
default = [];
|
||||
};
|
||||
httpResponse = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "http-response rules for this backend.";
|
||||
default = [];
|
||||
};
|
||||
tcpCheck = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "tcp-check rules for TCP mode health checking.";
|
||||
default = [];
|
||||
example = [ "connect" "send PING\\r\\n" "expect string +PONG" ];
|
||||
};
|
||||
servers = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Backend server definitions.";
|
||||
default = [];
|
||||
example = [ "server1 127.0.0.1:8080 check" "server2 127.0.0.1:8081 check" ];
|
||||
};
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "Extra configuration for this backend.";
|
||||
default = "";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = "HAProxy backend configurations.";
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
web_backend = {
|
||||
balance = "roundrobin";
|
||||
servers = [ "web1 127.0.0.1:8080 check" "web2 127.0.0.1:8081 check" ];
|
||||
options = [ "httpchk GET /health" ];
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
listen = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
bind = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Bind addresses and ports.";
|
||||
default = [];
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "http" "tcp" ];
|
||||
description = "Listen mode.";
|
||||
default = "http";
|
||||
};
|
||||
balance = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Load balancing algorithm.";
|
||||
default = null;
|
||||
};
|
||||
options = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "HAProxy options for this listen section.";
|
||||
default = [];
|
||||
};
|
||||
servers = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Server definitions.";
|
||||
default = [];
|
||||
};
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "Extra configuration for this listen section.";
|
||||
default = "";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = "HAProxy listen sections (combined frontend/backend).";
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
stats = {
|
||||
bind = [ "*:8404" ];
|
||||
options = [ "http-use-htx" "httplog" ];
|
||||
extraConfig = '''
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 10s
|
||||
''';
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "Extra HAProxy configuration appended to the config file.";
|
||||
default = "";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Assertions
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.acme.enable && cfg.selfSigned.enable);
|
||||
message = "Cannot enable both ACME and self-signed certificates. Choose one.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.acme.enable -> cfg.acme.acceptTerms;
|
||||
message = "You must accept the ACME terms of service to use Let's Encrypt.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.acme.enable -> cfg.acme.email != null;
|
||||
message = "You must provide an email address for ACME certificate registration.";
|
||||
}
|
||||
];
|
||||
|
||||
# ACME configuration for Let's Encrypt
|
||||
security.acme = lib.mkIf cfg.acme.enable {
|
||||
acceptTerms = cfg.acme.acceptTerms;
|
||||
defaults = {
|
||||
email = cfg.acme.email;
|
||||
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
|
||||
webroot = "/var/lib/acme/acme-challenge";
|
||||
group = "haproxy";
|
||||
} // cfg.acme.extraConfig;
|
||||
|
||||
# Create certificate configurations for each domain
|
||||
certs = lib.mapAttrs (domain: domainCfg: {
|
||||
inherit (domainCfg) extraDomainNames;
|
||||
webroot = domainCfg.webroot;
|
||||
# Reload HAProxy after certificate renewal
|
||||
postRun = ''
|
||||
# Combine fullchain and privkey for HAProxy
|
||||
${mkCombinePemScript domain}
|
||||
# Reload HAProxy to pick up new certificates
|
||||
${pkgs.systemd}/bin/systemctl reload haproxy.service || true
|
||||
'';
|
||||
} // domainCfg.extraConfig) cfg.acme.domains;
|
||||
};
|
||||
|
||||
# Ensure haproxy user is in acme group to read certificates
|
||||
users.users.haproxy = lib.mkIf cfg.acme.enable {
|
||||
extraGroups = [ "acme" ];
|
||||
};
|
||||
|
||||
# Create directories for ACME and self-signed certificates
|
||||
systemd.tmpfiles.rules =
|
||||
lib.optionals cfg.acme.enable [
|
||||
"d /var/lib/acme/acme-challenge 0755 acme acme -"
|
||||
"d /var/lib/acme/acme-challenge/.well-known 0755 acme acme -"
|
||||
"d /var/lib/acme/acme-challenge/.well-known/acme-challenge 0755 acme acme -"
|
||||
] ++
|
||||
lib.optionals cfg.selfSigned.enable [
|
||||
"d ${selfSignedCertDir} 0750 haproxy haproxy -"
|
||||
];
|
||||
|
||||
# Self-signed certificate generation service
|
||||
systemd.services.haproxy-generate-self-signed = lib.mkIf cfg.selfSigned.enable {
|
||||
description = "Generate self-signed certificates for HAProxy";
|
||||
wantedBy = [ "haproxy.service" ];
|
||||
before = [ "haproxy.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = lib.concatMapStringsSep "\n" (domain:
|
||||
"${mkSelfSignedCertScript domain}"
|
||||
) cfg.selfSigned.domains;
|
||||
};
|
||||
|
||||
# HAProxy configuration
|
||||
services.haproxy = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
|
||||
config = let
|
||||
# HSTS header value
|
||||
hstsHeader = lib.optionalString cfg.ssl.hsts.enable (
|
||||
"max-age=${toString cfg.ssl.hsts.maxAge}" +
|
||||
lib.optionalString cfg.ssl.hsts.includeSubDomains "; includeSubDomains" +
|
||||
lib.optionalString cfg.ssl.hsts.preload "; preload"
|
||||
);
|
||||
|
||||
# HTTP to HTTPS redirect frontend
|
||||
httpRedirectFrontend = lib.optionalString cfg.httpToHttpsRedirect.enable ''
|
||||
frontend http-redirect
|
||||
bind *:80
|
||||
mode http
|
||||
${lib.concatMapStringsSep "\n " (path: "acl is_acme path_beg ${path}") cfg.httpToHttpsRedirect.excludePaths}
|
||||
${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) "use_backend acme_backend if is_acme"}
|
||||
http-request redirect scheme https code ${toString cfg.httpToHttpsRedirect.code} unless { ssl_fc }${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) " or is_acme"}
|
||||
'';
|
||||
|
||||
# Generate frontend configuration
|
||||
frontendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: frontend: ''
|
||||
frontend ${name}
|
||||
${lib.concatMapStringsSep "\n " (b: "bind ${b}") frontend.bind}
|
||||
mode ${frontend.mode}
|
||||
${lib.concatMapStringsSep "\n " (o: "option ${o}") frontend.options}
|
||||
${lib.concatMapStringsSep "\n " (a: "acl ${a}") frontend.acls}
|
||||
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") frontend.httpRequest}
|
||||
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") frontend.httpResponse}
|
||||
${lib.concatMapStringsSep "\n " (r: "tcp-request ${r}") frontend.tcpRequest}
|
||||
${lib.optionalString (cfg.ssl.hsts.enable && frontend.mode == "http") "http-response set-header Strict-Transport-Security \"${hstsHeader}\""}
|
||||
${lib.concatMapStringsSep "\n " (u: "use_backend ${u}") frontend.useBackend}
|
||||
${lib.optionalString (frontend.defaultBackend != null) "default_backend ${frontend.defaultBackend}"}
|
||||
${frontend.extraConfig}
|
||||
'') cfg.frontends);
|
||||
|
||||
# Generate backend configuration
|
||||
backendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: backend: ''
|
||||
backend ${name}
|
||||
mode ${backend.mode}
|
||||
balance ${backend.balance}
|
||||
${lib.concatMapStringsSep "\n " (o: "option ${o}") backend.options}
|
||||
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") backend.httpRequest}
|
||||
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") backend.httpResponse}
|
||||
${lib.concatMapStringsSep "\n " (c: "tcp-check ${c}") backend.tcpCheck}
|
||||
${lib.concatMapStringsSep "\n " (s: "server ${s}") backend.servers}
|
||||
${backend.extraConfig}
|
||||
'') cfg.backends);
|
||||
|
||||
# Generate listen configuration
|
||||
listenConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: listenCfg: ''
|
||||
listen ${name}
|
||||
${lib.concatMapStringsSep "\n " (b: "bind ${b}") listenCfg.bind}
|
||||
mode ${listenCfg.mode}
|
||||
${lib.optionalString (listenCfg.balance != null) "balance ${listenCfg.balance}"}
|
||||
${lib.concatMapStringsSep "\n " (o: "option ${o}") listenCfg.options}
|
||||
${lib.concatMapStringsSep "\n " (s: "server ${s}") listenCfg.servers}
|
||||
${listenCfg.extraConfig}
|
||||
'') cfg.listen);
|
||||
|
||||
in ''
|
||||
${cfg.globalConfig}
|
||||
|
||||
${cfg.defaultsConfig}
|
||||
|
||||
${httpRedirectFrontend}
|
||||
|
||||
${frontendConfigs}
|
||||
|
||||
${backendConfigs}
|
||||
|
||||
${listenConfigs}
|
||||
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
};
|
||||
|
||||
# Ensure HAProxy starts after certificates are ready
|
||||
systemd.services.haproxy = lib.mkMerge [
|
||||
(lib.mkIf cfg.acme.enable {
|
||||
wants = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
|
||||
after = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
|
||||
})
|
||||
(lib.mkIf cfg.selfSigned.enable {
|
||||
wants = [ "haproxy-generate-self-signed.service" ];
|
||||
after = [ "haproxy-generate-self-signed.service" ];
|
||||
})
|
||||
{
|
||||
serviceConfig = {
|
||||
# Allow HAProxy to reload without restart
|
||||
ExecReload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
# Open firewall for HTTP/HTTPS
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
|
||||
|
||||
# Install useful utilities
|
||||
environment.systemPackages = [ cfg.package pkgs.curl pkgs.openssl ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "home-assistant";
|
||||
defaultPort = 8123;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.home-assistant";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "Home Assistant package to use.";
|
||||
default = pkgs.home-assistant;
|
||||
example = "pkgs.home-assistant";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Network Configuration
|
||||
# ==========================================================================
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind Home Assistant to.";
|
||||
default = "0.0.0.0";
|
||||
example = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for Home Assistant web interface.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall for Home Assistant.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration Directory
|
||||
# ==========================================================================
|
||||
|
||||
configDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Directory where Home Assistant configuration is stored.";
|
||||
default = "/var/lib/hass";
|
||||
};
|
||||
|
||||
configWritable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Whether to make configuration.yaml writable from the web UI.
|
||||
This allows editing configuration from Home Assistant's interface.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Components and Integrations
|
||||
# ==========================================================================
|
||||
|
||||
extraComponents = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
List of Home Assistant components/integrations to include.
|
||||
Component names can be found at https://www.home-assistant.io/integrations/
|
||||
'';
|
||||
default = [
|
||||
# Components required for initial onboarding
|
||||
"esphome"
|
||||
"met"
|
||||
"radio_browser"
|
||||
];
|
||||
example = [
|
||||
"esphome"
|
||||
"met"
|
||||
"radio_browser"
|
||||
"hue"
|
||||
"zwave_js"
|
||||
"mqtt"
|
||||
"homekit"
|
||||
];
|
||||
};
|
||||
|
||||
customComponents = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
description = "List of custom component packages to install.";
|
||||
default = [];
|
||||
};
|
||||
|
||||
customLovelaceModules = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
description = "List of custom Lovelace card packages to load.";
|
||||
default = [];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Home Assistant Configuration (config.yaml as Nix)
|
||||
# ==========================================================================
|
||||
|
||||
config = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
|
||||
description = ''
|
||||
Home Assistant configuration.yaml as a Nix attribute set.
|
||||
Set to null to use an existing configuration.yaml file.
|
||||
'';
|
||||
default = {
|
||||
# Basic setup with default_config integration
|
||||
default_config = {};
|
||||
|
||||
# HTTP configuration
|
||||
http = {
|
||||
server_host = cfg.bindToIp;
|
||||
server_port = cfg.bindToPort;
|
||||
};
|
||||
|
||||
# Homeassistant core settings
|
||||
homeassistant = {
|
||||
name = "Home";
|
||||
unit_system = "metric";
|
||||
};
|
||||
};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
default_config = {};
|
||||
homeassistant = {
|
||||
name = "My Smart Home";
|
||||
unit_system = "metric";
|
||||
time_zone = "Europe/London";
|
||||
latitude = 51.5074;
|
||||
longitude = -0.1278;
|
||||
};
|
||||
automation = "!include automations.yaml";
|
||||
scene = "!include scenes.yaml";
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Lovelace Dashboard Configuration
|
||||
# ==========================================================================
|
||||
|
||||
lovelaceConfig = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
|
||||
description = ''
|
||||
Lovelace dashboard configuration as a Nix attribute set.
|
||||
Set to null to use UI-managed dashboards.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
|
||||
lovelaceConfigWritable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to make Lovelace configuration writable.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Reverse Proxy Configuration
|
||||
# ==========================================================================
|
||||
|
||||
reverseProxy = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable nginx reverse proxy for Home Assistant.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Hostname for the reverse proxy.";
|
||||
default = "localhost";
|
||||
example = "homeassistant.example.com";
|
||||
};
|
||||
|
||||
ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable SSL/HTTPS for the reverse proxy.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
trustedProxies = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "List of trusted proxy IP addresses.";
|
||||
default = [ "127.0.0.1" "::1" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# ==========================================================================
|
||||
# Home Assistant Service Configuration
|
||||
# ==========================================================================
|
||||
|
||||
services.home-assistant = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
configDir = cfg.configDir;
|
||||
configWritable = cfg.configWritable;
|
||||
|
||||
# Components and integrations
|
||||
extraComponents = cfg.extraComponents;
|
||||
customComponents = cfg.customComponents;
|
||||
customLovelaceModules = cfg.customLovelaceModules;
|
||||
|
||||
# Configuration
|
||||
config = if cfg.config != null then (cfg.config // {
|
||||
# Always include HTTP config if using reverse proxy
|
||||
http = (cfg.config.http or {}) // (lib.mkIf cfg.reverseProxy.enable {
|
||||
use_x_forwarded_for = true;
|
||||
trusted_proxies = cfg.reverseProxy.trustedProxies;
|
||||
});
|
||||
}) else null;
|
||||
|
||||
# Lovelace configuration
|
||||
lovelaceConfig = cfg.lovelaceConfig;
|
||||
lovelaceConfigWritable = cfg.lovelaceConfigWritable;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Nginx Reverse Proxy (Optional)
|
||||
# ==========================================================================
|
||||
|
||||
services.nginx = lib.mkIf cfg.reverseProxy.enable {
|
||||
enable = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = cfg.reverseProxy.ssl;
|
||||
|
||||
virtualHosts.${cfg.reverseProxy.hostName} = {
|
||||
forceSSL = cfg.reverseProxy.ssl;
|
||||
enableACME = cfg.reverseProxy.ssl;
|
||||
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
'';
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.bindToPort}";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Firewall Configuration
|
||||
# ==========================================================================
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
|
||||
[ cfg.bindToPort ] ++
|
||||
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
|
||||
);
|
||||
|
||||
# ==========================================================================
|
||||
# Service Dependencies
|
||||
# ==========================================================================
|
||||
|
||||
systemd.services.home-assistant = {
|
||||
after = lib.mkIf cfg.reverseProxy.enable [ "nginx.service" ];
|
||||
};
|
||||
|
||||
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
|
||||
wants = [ "home-assistant.service" ];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Utilities
|
||||
# ==========================================================================
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "mariadb";
|
||||
appPort = 3306;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.mariadb";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "MariaDB package to use.";
|
||||
default = pkgs.mariadb;
|
||||
example = "pkgs.mariadb_110";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port to bind.";
|
||||
default = appPort;
|
||||
};
|
||||
|
||||
initialDatabases = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Database name.";
|
||||
};
|
||||
schema = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
description = "Path to SQL schema file to import.";
|
||||
default = null;
|
||||
};
|
||||
};
|
||||
});
|
||||
description = "List of databases to create on initialization.";
|
||||
default = [];
|
||||
example = [ { name = "myapp"; } { name = "testdb"; schema = ./schema.sql; } ];
|
||||
};
|
||||
|
||||
ensureUsers = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "User name.";
|
||||
};
|
||||
ensurePermissions = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
description = "Permissions to grant to the user.";
|
||||
default = {};
|
||||
example = { "*.*" = "ALL PRIVILEGES"; };
|
||||
};
|
||||
};
|
||||
});
|
||||
description = ''
|
||||
List of users to ensure exist. Users are created with Unix socket
|
||||
authentication by default (no password required for local connections
|
||||
when the system username matches the MySQL username).
|
||||
'';
|
||||
default = [];
|
||||
example = [ { name = "myuser"; ensurePermissions = { "mydb.*" = "ALL PRIVILEGES"; }; } ];
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = "Additional MariaDB settings.";
|
||||
default = {};
|
||||
example = {
|
||||
max_connections = 200;
|
||||
innodb_buffer_pool_size = "1G";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
|
||||
settings = {
|
||||
mysqld = {
|
||||
bind-address = cfg.bindToIp;
|
||||
port = cfg.bindToPort;
|
||||
} // cfg.settings;
|
||||
};
|
||||
|
||||
# Create initial databases if specified
|
||||
initialDatabases = cfg.initialDatabases;
|
||||
|
||||
# Create users if specified
|
||||
ensureUsers = cfg.ensureUsers;
|
||||
};
|
||||
|
||||
# Open firewall for MariaDB if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "minio";
|
||||
defaultApiPort = 9000;
|
||||
defaultConsolePort = 9001;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.minio";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "MinIO package to use.";
|
||||
default = pkgs.minio;
|
||||
example = "pkgs.minio";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
apiPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for S3 API.";
|
||||
default = defaultApiPort;
|
||||
};
|
||||
|
||||
consolePort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for web console.";
|
||||
default = defaultConsolePort;
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Data directories for MinIO storage.";
|
||||
default = [ "/var/lib/minio/data" ];
|
||||
example = [ "/var/lib/minio/data1" "/var/lib/minio/data2" ];
|
||||
};
|
||||
|
||||
configDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Configuration directory for MinIO.";
|
||||
default = "/var/lib/minio/config";
|
||||
};
|
||||
|
||||
rootCredentialsSecretName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Name of the secret containing root credentials.
|
||||
The secret file should contain:
|
||||
MINIO_ROOT_USER=<user>
|
||||
MINIO_ROOT_PASSWORD=<password>
|
||||
|
||||
The secret will be loaded from:
|
||||
/run/secrets/<secretName>
|
||||
'';
|
||||
example = "minio-root-credentials";
|
||||
};
|
||||
|
||||
region = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Region for MinIO server.";
|
||||
default = "us-east-1";
|
||||
};
|
||||
|
||||
browser = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable or disable the web browser console.";
|
||||
default = true;
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.minio = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
listenAddress = "${cfg.bindToIp}:${toString cfg.apiPort}";
|
||||
consoleAddress = "${cfg.bindToIp}:${toString cfg.consolePort}";
|
||||
dataDir = cfg.dataDir;
|
||||
configDir = cfg.configDir;
|
||||
rootCredentialsFile = "/run/secrets/${cfg.rootCredentialsSecretName}";
|
||||
region = cfg.region;
|
||||
browser = cfg.browser;
|
||||
};
|
||||
|
||||
# Install MinIO client for CLI access
|
||||
environment.systemPackages = [ pkgs.minio-client pkgs.curl pkgs.jq ];
|
||||
|
||||
# Open firewall for MinIO if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
|
||||
cfg.apiPort
|
||||
cfg.consolePort
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "mongodb-pod";
|
||||
appPort = 27017;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
dataDir = "/var/lib/mongodb-pod";
|
||||
execStartPreScript = pkgs.writeShellScript "preStart" ''
|
||||
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.mongodb-pod oci";
|
||||
|
||||
image = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "MongoDB Docker image to use.";
|
||||
default = "mongo:6";
|
||||
example = "mongo:4.4.29-focal";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port to bind.";
|
||||
default = appPort;
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up
|
||||
infrastructure.oci-containers.backend = "podman";
|
||||
infrastructure.oci-containers.containers.${appName} = {
|
||||
app = {
|
||||
name = appName;
|
||||
};
|
||||
image = cfg.image;
|
||||
autoStart = true;
|
||||
ports = [
|
||||
"${cfg.bindToIp}:${toString cfg.bindToPort}:27017"
|
||||
];
|
||||
bindToIp = cfg.bindToIp;
|
||||
volumes = [
|
||||
"${dataDir}:/data/db"
|
||||
];
|
||||
|
||||
execHooks = {
|
||||
ExecStartPre = [
|
||||
"${execStartPreScript}"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "mongodb";
|
||||
defaultPort = 27017;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.mongodb";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "MongoDB package to use.";
|
||||
default = pkgs.mongodb-ce;
|
||||
example = "pkgs.mongodb";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port to bind.";
|
||||
default = defaultPort;
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.mongodb = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
bind_ip = cfg.bindToIp;
|
||||
extraConfig = lib.mkIf (cfg.bindToPort != defaultPort) ''
|
||||
net.port: ${toString cfg.bindToPort}
|
||||
'';
|
||||
};
|
||||
|
||||
# Install mongosh for CLI access
|
||||
environment.systemPackages = [ pkgs.mongosh ];
|
||||
|
||||
# Open firewall for MongoDB if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "n8n-pod";
|
||||
defaultPort = 5678;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
dataDir = "/var/lib/n8n-pod";
|
||||
execStartPreScript = pkgs.writeShellScript "preStart" ''
|
||||
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
|
||||
${pkgs.coreutils}/bin/chown -R 1000:1000 ${dataDir}
|
||||
'';
|
||||
|
||||
# Build environment variables for the container
|
||||
containerEnv = {
|
||||
# Network settings
|
||||
N8N_PORT = toString defaultPort;
|
||||
N8N_LISTEN_ADDRESS = "0.0.0.0"; # Always bind to all interfaces inside container
|
||||
|
||||
# Execution settings
|
||||
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
|
||||
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
|
||||
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
|
||||
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
|
||||
WEBHOOK_URL = cfg.webhookUrl;
|
||||
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
|
||||
DB_TYPE = "postgresdb";
|
||||
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
|
||||
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
|
||||
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
|
||||
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
|
||||
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
|
||||
DB_POSTGRESDB_SSL_ENABLED = "true";
|
||||
}) // cfg.settings;
|
||||
|
||||
# Convert environment to list of "KEY=VALUE" strings
|
||||
envList = lib.mapAttrsToList (name: value: "${name}=${toString value}") containerEnv;
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.n8n-pod oci";
|
||||
|
||||
image = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "n8n Docker image to use.";
|
||||
default = "docker.n8n.io/n8nio/n8n:latest";
|
||||
example = "docker.n8n.io/n8nio/n8n:1.70.0";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Network Configuration
|
||||
# ==========================================================================
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind n8n to on the host.";
|
||||
default = "127.0.0.1";
|
||||
example = "0.0.0.0";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for n8n web interface on the host.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall for n8n.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Webhook Configuration
|
||||
# ==========================================================================
|
||||
|
||||
webhookUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
|
||||
This is the external URL where webhooks can reach n8n.
|
||||
'';
|
||||
default = "";
|
||||
example = "https://n8n.example.com/";
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Database Configuration
|
||||
# ==========================================================================
|
||||
database = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "sqlite" "postgresdb" ];
|
||||
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
|
||||
default = "sqlite";
|
||||
};
|
||||
|
||||
postgresdb = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL host. Use host IP for container access.";
|
||||
default = "host.containers.internal";
|
||||
example = "192.168.1.100";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "PostgreSQL port.";
|
||||
default = 5432;
|
||||
};
|
||||
|
||||
database = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL database name.";
|
||||
default = "n8n";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "PostgreSQL user.";
|
||||
default = "n8n";
|
||||
};
|
||||
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
PostgreSQL password. For production, consider using
|
||||
passwordFile or environment variable injection instead.
|
||||
'';
|
||||
default = "";
|
||||
example = "secretpassword";
|
||||
};
|
||||
|
||||
ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable SSL for PostgreSQL connection.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Execution Configuration
|
||||
# ==========================================================================
|
||||
|
||||
executions = {
|
||||
pruneData = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable automatic pruning of old execution data.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
pruneDataMaxAge = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Maximum age of execution data in hours before pruning.";
|
||||
default = 336; # 14 days
|
||||
};
|
||||
|
||||
pruneDataMaxCount = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Maximum number of executions to keep.";
|
||||
default = 10000;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# n8n Settings (pass-through as environment variables)
|
||||
# ==========================================================================
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Additional n8n configuration as environment variables.
|
||||
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
GENERIC_TIMEZONE = "Europe/London";
|
||||
WORKFLOWS_DEFAULT_NAME = "My Workflow";
|
||||
N8N_METRICS = "true";
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Configure podman backend
|
||||
infrastructure.oci-containers.backend = "podman";
|
||||
|
||||
infrastructure.oci-containers.containers.${appName} = {
|
||||
app = {
|
||||
name = appName;
|
||||
};
|
||||
image = cfg.image;
|
||||
autoStart = true;
|
||||
ports = [
|
||||
"${cfg.bindToIp}:${toString cfg.bindToPort}:${toString defaultPort}"
|
||||
];
|
||||
bindToIp = cfg.bindToIp;
|
||||
|
||||
# Mount data directory for persistence
|
||||
volumes = [
|
||||
"${dataDir}:/home/node/.n8n"
|
||||
];
|
||||
|
||||
# Environment variables
|
||||
environment = containerEnv;
|
||||
|
||||
# Run as node user (UID 1000 in official image)
|
||||
user = "1000:1000";
|
||||
|
||||
execHooks = {
|
||||
ExecStartPre = [
|
||||
"${execStartPreScript}"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Firewall Configuration
|
||||
# ==========================================================================
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.bindToPort ];
|
||||
|
||||
# ==========================================================================
|
||||
# Utilities
|
||||
# ==========================================================================
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "nextcloud";
|
||||
defaultPort = 80;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.nextcloud";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "Nextcloud package to use.";
|
||||
default = pkgs.nextcloud31;
|
||||
example = "pkgs.nextcloud30";
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Hostname for Nextcloud.";
|
||||
default = "localhost";
|
||||
example = "cloud.example.com";
|
||||
};
|
||||
|
||||
https = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to use HTTPS.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Admin Configuration
|
||||
# ==========================================================================
|
||||
|
||||
admin = {
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Admin username.";
|
||||
default = "admin";
|
||||
};
|
||||
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Path to file containing admin password.";
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Database Configuration
|
||||
# ==========================================================================
|
||||
|
||||
database = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "sqlite" "pgsql" "mysql" ];
|
||||
description = "Database type to use.";
|
||||
default = "pgsql";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Database name.";
|
||||
default = "nextcloud";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Database user.";
|
||||
default = "nextcloud";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Database host. Use socket path for local connections.";
|
||||
default = "/run/postgresql";
|
||||
example = "127.0.0.1";
|
||||
};
|
||||
|
||||
createLocally = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Whether to create the database and user locally.
|
||||
Only works for PostgreSQL and MySQL/MariaDB when using socket authentication.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Caching Configuration
|
||||
# ==========================================================================
|
||||
|
||||
caching = {
|
||||
redis = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable Redis for caching and file locking.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
apcu = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable APCu for local caching.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
memcached = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable Memcached for distributed caching.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# PHP Configuration
|
||||
# ==========================================================================
|
||||
|
||||
maxUploadSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Maximum upload size.";
|
||||
default = "512M";
|
||||
example = "1G";
|
||||
};
|
||||
|
||||
phpOptions = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
description = "Additional PHP options.";
|
||||
default = {};
|
||||
example = {
|
||||
"opcache.interned_strings_buffer" = "16";
|
||||
"opcache.max_accelerated_files" = "10000";
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Extra Configuration
|
||||
# ==========================================================================
|
||||
|
||||
extraApps = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.package;
|
||||
description = "Extra Nextcloud apps to install.";
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
with config.services.nextcloud.package.packages.apps; {
|
||||
inherit calendar contacts notes;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
extraAppsEnable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Automatically enable extra apps.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Additional Nextcloud configuration settings.
|
||||
These are passed directly to services.nextcloud.settings.
|
||||
'';
|
||||
default = {};
|
||||
example = {
|
||||
default_phone_region = "US";
|
||||
overwriteprotocol = "https";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# ==========================================================================
|
||||
# Nextcloud Service Configuration
|
||||
# ==========================================================================
|
||||
|
||||
services.nextcloud = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
hostName = cfg.hostName;
|
||||
https = cfg.https;
|
||||
|
||||
# Admin configuration
|
||||
config = {
|
||||
adminuser = cfg.admin.user;
|
||||
adminpassFile = cfg.admin.passwordFile;
|
||||
|
||||
# Database configuration
|
||||
dbtype = cfg.database.type;
|
||||
dbname = cfg.database.name;
|
||||
dbuser = cfg.database.user;
|
||||
dbhost = cfg.database.host;
|
||||
};
|
||||
|
||||
# Database creation
|
||||
database.createLocally = cfg.database.createLocally;
|
||||
|
||||
# Caching configuration
|
||||
caching = {
|
||||
redis = cfg.caching.redis;
|
||||
apcu = cfg.caching.apcu;
|
||||
memcached = cfg.caching.memcached;
|
||||
};
|
||||
|
||||
# Configure Redis for file locking if enabled
|
||||
configureRedis = cfg.caching.redis;
|
||||
|
||||
# PHP settings
|
||||
maxUploadSize = cfg.maxUploadSize;
|
||||
phpOptions = {
|
||||
"opcache.enable" = "1";
|
||||
"opcache.enable_cli" = "1";
|
||||
"opcache.interned_strings_buffer" = "8";
|
||||
"opcache.max_accelerated_files" = "10000";
|
||||
"opcache.memory_consumption" = "128";
|
||||
"opcache.save_comments" = "1";
|
||||
"opcache.revalidate_freq" = "1";
|
||||
} // cfg.phpOptions;
|
||||
|
||||
# Extra apps
|
||||
extraApps = cfg.extraApps;
|
||||
extraAppsEnable = cfg.extraAppsEnable;
|
||||
|
||||
# Additional settings
|
||||
settings = {
|
||||
default_phone_region = "US";
|
||||
maintenance_window_start = 1;
|
||||
} // cfg.settings;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Nginx Configuration (automatically enabled by Nextcloud module)
|
||||
# ==========================================================================
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = true;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Service Dependencies
|
||||
# ==========================================================================
|
||||
|
||||
# Ensure Nextcloud starts after its dependencies
|
||||
systemd.services.nextcloud-setup = {
|
||||
after = lib.mkMerge [
|
||||
# Database dependencies
|
||||
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
|
||||
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
|
||||
# Redis dependency
|
||||
(lib.mkIf cfg.caching.redis [ "redis-nextcloud.service" ])
|
||||
];
|
||||
requires = lib.mkMerge [
|
||||
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
|
||||
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
|
||||
];
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Firewall Configuration
|
||||
# ==========================================================================
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
|
||||
# ==========================================================================
|
||||
# Utilities
|
||||
# ==========================================================================
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "nginx";
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.nginx";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "Nginx package to use.";
|
||||
default = pkgs.nginx;
|
||||
example = "pkgs.nginxMainline";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
|
||||
default = true;
|
||||
};
|
||||
|
||||
recommendedSettings = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Enable recommended nginx settings for optimization and security.
|
||||
This enables recommendedGzipSettings, recommendedOptimisation,
|
||||
recommendedProxySettings, and recommendedTlsSettings.
|
||||
'';
|
||||
default = true;
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Let's Encrypt / ACME Configuration
|
||||
# ==========================================================================
|
||||
|
||||
acme = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable ACME (Let's Encrypt) certificate management.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
acceptTerms = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Accept the ACME provider's terms of service.
|
||||
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
email = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Default email address for ACME certificate registration and renewal notifications.";
|
||||
default = null;
|
||||
example = "admin@example.com";
|
||||
};
|
||||
|
||||
staging = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Use Let's Encrypt staging server for testing.
|
||||
Certificates won't be trusted but you won't hit rate limits.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Extra configuration options passed to security.acme.defaults.
|
||||
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
|
||||
'';
|
||||
default = {};
|
||||
example = {
|
||||
webroot = "/var/lib/acme/acme-challenge";
|
||||
renewInterval = "daily";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ==========================================================================
|
||||
# Pass-through Configuration
|
||||
# ==========================================================================
|
||||
|
||||
virtualHosts = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
# Use freeformType to allow any nginx virtualHost options
|
||||
freeformType = lib.types.attrsOf lib.types.anything;
|
||||
});
|
||||
description = ''
|
||||
Virtual host configurations passed directly to services.nginx.virtualHosts.
|
||||
See https://nixos.org/manual/nixos/stable/options.html#opt-services.nginx.virtualHosts
|
||||
|
||||
Example with Let's Encrypt:
|
||||
{
|
||||
"example.com" = {
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:8080";
|
||||
};
|
||||
};
|
||||
}
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"example.com" = {
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
root = "/var/www/example.com";
|
||||
};
|
||||
"api.example.com" = {
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:3000";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
appendHttpConfig = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "Additional nginx http block configuration.";
|
||||
default = "";
|
||||
example = ''
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
'';
|
||||
};
|
||||
|
||||
extraConfig = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
description = ''
|
||||
Extra configuration options passed directly to services.nginx.
|
||||
Use this for any nginx options not explicitly exposed by this module.
|
||||
'';
|
||||
default = {};
|
||||
example = {
|
||||
clientMaxBodySize = "100m";
|
||||
resolver = { addresses = [ "1.1.1.1" ]; };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# ACME configuration
|
||||
security.acme = lib.mkIf cfg.acme.enable {
|
||||
acceptTerms = cfg.acme.acceptTerms;
|
||||
defaults = {
|
||||
email = cfg.acme.email;
|
||||
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
|
||||
} // cfg.acme.extraConfig;
|
||||
};
|
||||
|
||||
# Nginx configuration
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
|
||||
# Recommended settings
|
||||
recommendedGzipSettings = cfg.recommendedSettings;
|
||||
recommendedOptimisation = cfg.recommendedSettings;
|
||||
recommendedProxySettings = cfg.recommendedSettings;
|
||||
recommendedTlsSettings = cfg.recommendedSettings;
|
||||
|
||||
# Virtual hosts (pass-through)
|
||||
virtualHosts = cfg.virtualHosts;
|
||||
|
||||
# Additional http config
|
||||
appendHttpConfig = cfg.appendHttpConfig;
|
||||
} // cfg.extraConfig;
|
||||
|
||||
# Open firewall for HTTP/HTTPS
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
|
||||
|
||||
# Install useful utilities
|
||||
environment.systemPackages = [ pkgs.curl pkgs.openssl ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "opensearch";
|
||||
defaultHttpPort = 9200;
|
||||
defaultTransportPort = 9300;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.opensearch";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "OpenSearch package to use.";
|
||||
default = pkgs.opensearch;
|
||||
example = "pkgs.opensearch";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind for HTTP API.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
httpPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for HTTP API.";
|
||||
default = defaultHttpPort;
|
||||
};
|
||||
|
||||
transportPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for transport/cluster communication.";
|
||||
default = defaultTransportPort;
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Data directory for OpenSearch.";
|
||||
default = "/var/lib/opensearch";
|
||||
};
|
||||
|
||||
clusterName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Name of the OpenSearch cluster.";
|
||||
default = "opensearch";
|
||||
};
|
||||
|
||||
singleNode = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Run as a single-node cluster (disables bootstrap checks).";
|
||||
default = true;
|
||||
};
|
||||
|
||||
heapSize = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "JVM heap size for OpenSearch (e.g., '512m', '1g').";
|
||||
default = "512m";
|
||||
};
|
||||
|
||||
extraSettings = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = "Extra settings to add to opensearch.yml.";
|
||||
default = {};
|
||||
example = { "action.destructive_requires_name" = true; };
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.opensearch = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
dataDir = cfg.dataDir;
|
||||
|
||||
settings = lib.mkMerge [
|
||||
{
|
||||
"network.host" = cfg.bindToIp;
|
||||
"http.port" = cfg.httpPort;
|
||||
"transport.port" = cfg.transportPort;
|
||||
"cluster.name" = cfg.clusterName;
|
||||
}
|
||||
(lib.mkIf cfg.singleNode {
|
||||
"discovery.type" = "single-node";
|
||||
})
|
||||
cfg.extraSettings
|
||||
];
|
||||
|
||||
extraJavaOptions = [
|
||||
"-Xms${cfg.heapSize}"
|
||||
"-Xmx${cfg.heapSize}"
|
||||
];
|
||||
};
|
||||
|
||||
# Install curl for API access
|
||||
environment.systemPackages = [ pkgs.curl pkgs.jq ];
|
||||
|
||||
# Open firewall for OpenSearch if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
|
||||
cfg.httpPort
|
||||
cfg.transportPort
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "postgresql";
|
||||
appPort = 5432;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.postgresql";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "PostgreSQL package to use.";
|
||||
default = pkgs.postgresql_16;
|
||||
example = "pkgs.postgresql_15";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port to bind.";
|
||||
default = appPort;
|
||||
};
|
||||
|
||||
initialDatabases = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "List of databases to create on initialization.";
|
||||
default = [];
|
||||
example = [ "myapp" "testdb" ];
|
||||
};
|
||||
|
||||
authentication = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
description = "pg_hba.conf authentication rules.";
|
||||
default = ''
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
local all all trust
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
enableTCPIP = true;
|
||||
|
||||
authentication = cfg.authentication;
|
||||
|
||||
settings = {
|
||||
port = lib.mkDefault cfg.bindToPort;
|
||||
listen_addresses = lib.mkDefault cfg.bindToIp;
|
||||
};
|
||||
|
||||
# Create initial databases if specified
|
||||
ensureDatabases = cfg.initialDatabases;
|
||||
};
|
||||
|
||||
# Open firewall for PostgreSQL if binding to non-localhost
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "rabbitmq";
|
||||
defaultPort = 5672;
|
||||
defaultManagementPort = 15672;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.rabbitmq";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "RabbitMQ package to use.";
|
||||
default = pkgs.rabbitmq-server;
|
||||
example = "pkgs.rabbitmq-server";
|
||||
};
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "AMQP port to bind.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
managementPlugin = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable the RabbitMQ management plugin (web UI).";
|
||||
default = true;
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port for the management web UI.";
|
||||
default = defaultManagementPort;
|
||||
};
|
||||
};
|
||||
|
||||
plugins = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Additional RabbitMQ plugins to enable.";
|
||||
default = [];
|
||||
example = [ "rabbitmq_shovel" "rabbitmq_federation" ];
|
||||
};
|
||||
|
||||
configItems = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
description = "Additional RabbitMQ configuration items (key-value pairs).";
|
||||
default = {};
|
||||
example = {
|
||||
"vm_memory_high_watermark" = "0.6";
|
||||
"disk_free_limit.absolute" = "1GB";
|
||||
};
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall ports for RabbitMQ.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.rabbitmq = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
listenAddress = cfg.bindToIp;
|
||||
port = cfg.bindToPort;
|
||||
|
||||
# Enable management plugin if requested
|
||||
managementPlugin.enable = cfg.managementPlugin.enable;
|
||||
managementPlugin.port = cfg.managementPlugin.port;
|
||||
|
||||
# Combine user plugins with management plugin
|
||||
plugins = cfg.plugins;
|
||||
|
||||
# Pass through additional configuration
|
||||
configItems = cfg.configItems;
|
||||
};
|
||||
|
||||
# Install rabbitmqadmin CLI tool when management plugin is enabled
|
||||
environment.systemPackages = lib.mkIf cfg.managementPlugin.enable [
|
||||
pkgs.rabbitmq-server
|
||||
];
|
||||
|
||||
# Open firewall ports if requested
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
|
||||
[ cfg.bindToPort ] ++
|
||||
(lib.optional cfg.managementPlugin.enable cfg.managementPlugin.port)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
appName = "redis";
|
||||
defaultPort = 6379;
|
||||
|
||||
cfg = config.infrastructure.${appName};
|
||||
|
||||
# Server options submodule
|
||||
serverOptions = { name, ... }: {
|
||||
options = {
|
||||
enable = lib.mkEnableOption "this Redis server instance" // { default = true; };
|
||||
|
||||
bindToIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "IP address to bind.";
|
||||
default = "127.0.0.1";
|
||||
};
|
||||
|
||||
bindToPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Port to bind.";
|
||||
default = defaultPort;
|
||||
};
|
||||
|
||||
maxMemory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Maximum memory Redis can use (e.g., '256mb', '1gb'). Null for unlimited.";
|
||||
default = null;
|
||||
example = "256mb";
|
||||
};
|
||||
|
||||
maxMemoryPolicy = lib.mkOption {
|
||||
type = lib.types.enum [ "noeviction" "allkeys-lru" "volatile-lru" "allkeys-random" "volatile-random" "volatile-ttl" ];
|
||||
description = "Policy for handling keys when maxMemory is reached.";
|
||||
default = "noeviction";
|
||||
};
|
||||
|
||||
requirePass = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Password for Redis authentication. Null for no authentication.";
|
||||
default = null;
|
||||
};
|
||||
|
||||
databases = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
description = "Number of databases to configure.";
|
||||
default = 16;
|
||||
};
|
||||
|
||||
appendOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Enable append-only file persistence.";
|
||||
default = false;
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Open firewall for this Redis instance.";
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Filter enabled servers
|
||||
enabledServers = lib.filterAttrs (name: serverCfg: serverCfg.enable) cfg.servers;
|
||||
in
|
||||
{
|
||||
options.infrastructure.${appName} = {
|
||||
enable = lib.mkEnableOption "infrastructure.redis";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "Redis package to use.";
|
||||
default = pkgs.redis;
|
||||
example = "pkgs.redis";
|
||||
};
|
||||
|
||||
servers = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule serverOptions);
|
||||
description = ''
|
||||
Named Redis server instances.
|
||||
Each server creates a systemd service named redis-<name>.service.
|
||||
Use an empty string "" for the default server (redis.service).
|
||||
'';
|
||||
default = {};
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"" = {
|
||||
bindToPort = 6379;
|
||||
};
|
||||
nextcloud = {
|
||||
bindToPort = 6380;
|
||||
maxMemory = "256mb";
|
||||
};
|
||||
cache = {
|
||||
bindToPort = 6381;
|
||||
maxMemory = "512mb";
|
||||
maxMemoryPolicy = "allkeys-lru";
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Set the package at the top level
|
||||
services.redis.package = cfg.package;
|
||||
|
||||
# Create each enabled server
|
||||
services.redis.servers = lib.mapAttrs (name: serverCfg: {
|
||||
enable = true;
|
||||
bind = serverCfg.bindToIp;
|
||||
port = serverCfg.bindToPort;
|
||||
databases = serverCfg.databases;
|
||||
appendOnly = serverCfg.appendOnly;
|
||||
requirePass = serverCfg.requirePass;
|
||||
settings = lib.mkMerge [
|
||||
(lib.mkIf (serverCfg.maxMemory != null) {
|
||||
maxmemory = serverCfg.maxMemory;
|
||||
maxmemory-policy = serverCfg.maxMemoryPolicy;
|
||||
})
|
||||
];
|
||||
}) enabledServers;
|
||||
|
||||
# Install redis-cli for CLI access
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
# Open firewall for servers that request it
|
||||
networking.firewall.allowedTCPPorts = lib.pipe enabledServers [
|
||||
(lib.filterAttrs (name: serverCfg: serverCfg.openFirewall))
|
||||
(lib.mapAttrsToList (name: serverCfg: serverCfg.bindToPort))
|
||||
];
|
||||
};
|
||||
}
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env bash
|
||||
WORK_DIR=${WORK_DIR:-"$(dirname "$0")"}
|
||||
NIX_INFRA=${NIX_INFRA:-"nix-infra"}
|
||||
NIXOS_VERSION=${NIXOS_VERSION:-"25.11"}
|
||||
SSH_KEY=${SSH_KEY:-"nixinfra-machine"}
|
||||
SSH_EMAIL=${SSH_EMAIL:-"your-email@example.com"}
|
||||
SECRETS_PWD=${SECRETS_PWD:-"my_secrets_password"}
|
||||
LOCATION=${LOCATION:-"nbg1"}
|
||||
MACHINE_TYPE=${MACHINE_TYPE:-"cx23"}
|
||||
ENV=${ENV:-"$WORK_DIR/.env"}
|
||||
|
||||
read -r -d '' __help_text__ <<EOF || true
|
||||
nix-infra-machine CLI
|
||||
=====================
|
||||
|
||||
Usage: $0 <command> [options]
|
||||
|
||||
Commands:
|
||||
create <nodes> Provision and initialize machines
|
||||
update <nodes> Update node configuration and deploy apps
|
||||
upgrade <nodes> Upgrade NixOS version on nodes
|
||||
rollback <nodes> Rollback to previous NixOS configuration
|
||||
destroy Destroy machines
|
||||
--target=<nodes> Nodes to destroy
|
||||
|
||||
ssh <node> SSH into a node
|
||||
cmd Run command on node(s)
|
||||
--target=<nodes> Target node(s)
|
||||
<command> Command to execute
|
||||
action Run app module action
|
||||
--target=<node> Target node
|
||||
<module> App module name
|
||||
<cmd> Action command to run
|
||||
port-forward Forward port from remote node to local
|
||||
--target=<node> Target node to forward from
|
||||
--port-mapping=<l:r> Port mapping as local:remote
|
||||
claude Launch Claude with nix-infra-machine-mcp
|
||||
|
||||
Options:
|
||||
--env=<file> Environment file (default: .env)
|
||||
--target=<nodes> Target node(s) for commands
|
||||
--node-module=<file> Node module file (default: node_types/standalone_machine.nix)
|
||||
--port-mapping=<l:r> Port mapping as local:remote (for port-forward)
|
||||
|
||||
Examples:
|
||||
# Create a new machine
|
||||
$0 create --env=.env node001
|
||||
|
||||
# Create multiple machines
|
||||
$0 create --env=.env node001 node002 node003
|
||||
|
||||
# SSH into a machine
|
||||
$0 ssh --env=.env node001
|
||||
|
||||
# Run a command on a machine
|
||||
$0 cmd --env=.env --target=node001 "systemctl status nginx"
|
||||
|
||||
# Update configuration
|
||||
$0 update --env=.env node001
|
||||
|
||||
# Destroy machines
|
||||
$0 destroy --env=.env --target="node001 node002"
|
||||
|
||||
# Launch Claude with dev MCP
|
||||
$0 claude-dev --env=.env
|
||||
EOF
|
||||
|
||||
if [[ "create upgrade rollback destroy update ssh cmd action port-forward claude claude-dev" == *"$1"* ]]; then
|
||||
CMD="$1"
|
||||
shift
|
||||
else
|
||||
echo "$__help_text__"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for i in "$@"; do
|
||||
case $i in
|
||||
--help)
|
||||
echo "$__help_text__"
|
||||
exit 0
|
||||
;;
|
||||
--env=*)
|
||||
ENV="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--target=*)
|
||||
TARGET="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--port-mapping=*)
|
||||
PORT_MAPPING="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
--node-module=*)
|
||||
NODE_MODULE="${i#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
REST="$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Read the environment file if provided
|
||||
if [ "$ENV" != "" ] && [ -f "$ENV" ]; then
|
||||
source "$ENV"
|
||||
fi
|
||||
|
||||
# Check for nix-infra CLI
|
||||
if [ "$NIX_INFRA" = "nix-infra" ] && ! command -v nix-infra >/dev/null 2>&1; then
|
||||
echo "The 'nix-infra' cli is required for this script to work."
|
||||
echo "Visit https://github.com/jhsware/nix-infra for instructions on installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A Hetzner Cloud token is required
|
||||
if [ -z "$HCLOUD_TOKEN" ]; then
|
||||
echo "Missing env-var HCLOUD_TOKEN. Load through .env-file that is specified through --env."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Validation helpers
|
||||
# ============================================================================
|
||||
|
||||
check_required_vars() {
|
||||
local missing=""
|
||||
for var in "$@"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
die "Missing required environment variables:$missing
|
||||
Load through .env-file specified with --env option."
|
||||
fi
|
||||
}
|
||||
|
||||
check_required_args() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [ -z "$value" ]; then
|
||||
die "Missing required argument: $name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
printTime() {
|
||||
local _start=$1; local _end=$2; local _secs=$((_end-_start))
|
||||
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
|
||||
}
|
||||
|
||||
destroyNodes() {
|
||||
$NIX_INFRA fleet destroy -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--target="$TARGET"
|
||||
}
|
||||
|
||||
cleanupOnFail() {
|
||||
if [ $1 -ne 0 ]; then
|
||||
echo "$2"
|
||||
destroyNodes
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Commands
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "ssh" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 ssh --env=.env <node>"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet ssh -d "$WORK_DIR" --env="$ENV" --target="$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "cmd" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 cmd --env=.env --target=<node> <command>"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "action" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
|
||||
echo "Usage: $0 action --env=.env --target=<node> <module> <cmd>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r module action_cmd <<< "$REST"
|
||||
$NIX_INFRA fleet action -d "$WORK_DIR" --env="$ENV" --target="$TARGET" --app-module="$module" \
|
||||
--cmd="$action_cmd"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "port-forward" ]; then
|
||||
if [ -z "$TARGET" ] || [ -z "$PORT_MAPPING" ]; then
|
||||
echo "Usage: $0 port-forward --env=.env --target=<node> --port-mapping=<local:remote>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OLD_IFS=$IFS
|
||||
IFS=: read LOCAL_PORT REMOTE_PORT <<< "$PORT_MAPPING"
|
||||
IFS=$OLD_IFS
|
||||
|
||||
$NIX_INFRA fleet port-forward -d "$WORK_DIR" --env="$ENV" \
|
||||
--target="$TARGET" \
|
||||
--local-port="$LOCAL_PORT" \
|
||||
--remote-port="$REMOTE_PORT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Management Commands
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "destroy" ]; then
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "Usage: $0 destroy --env=.env --target=\"<node1> <node2> ...\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
destroyNodes
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "update" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 update --env=.env <node1> <node2> ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_MODULE=${NODE_MODULE:-"node_types/standalone_machine.nix"}
|
||||
|
||||
$NIX_INFRA fleet update -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--target="$REST" \
|
||||
--node-module="$NODE_MODULE" \
|
||||
--no-rebuild
|
||||
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--target="$REST"
|
||||
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$REST" "nixos-rebuild switch --fast"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "upgrade" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 upgrade --env=$ENV [--nixos-version='$NIXOS_VERSION'] [node1 node2 ...]"
|
||||
exit 1
|
||||
fi
|
||||
# (cd "$WORK_DIR" && git fetch origin && git reset --hard origin/$(git branch --show-current))
|
||||
$NIX_INFRA cluster upgrade-nixos -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--target="$REST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "rollback" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 rollback --env=.env <node1> <node2> ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$REST" "nixos-rebuild switch --rollback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Create Command - Provision and Initialize Machines
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "create" ]; then
|
||||
if [ -z "$REST" ]; then
|
||||
echo "Usage: $0 create --env=.env <node1> <node2> ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET="$REST"
|
||||
|
||||
_start=$(date +%s)
|
||||
|
||||
# Initialize if not already done (check for ssh directory)
|
||||
if [ ! -d "$WORK_DIR/ssh" ]; then
|
||||
echo "Initializing nix-infra..."
|
||||
$NIX_INFRA init -d "$WORK_DIR" --env="$ENV" --no-cert-auth --batch
|
||||
fi
|
||||
|
||||
# Add SSH key to agent
|
||||
ssh-add "$WORK_DIR/ssh/$SSH_KEY" 2>/dev/null || true
|
||||
|
||||
echo "*** Provisioning NixOS $NIXOS_VERSION ***"
|
||||
|
||||
$NIX_INFRA fleet provision -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--ssh-key="$SSH_KEY" \
|
||||
--location="$LOCATION" \
|
||||
--machine-type="$MACHINE_TYPE" \
|
||||
--node-names="$TARGET"
|
||||
|
||||
cleanupOnFail $? "WARNING: Provisioning failed! Cleaning up..."
|
||||
|
||||
_provision=$(date +%s)
|
||||
|
||||
echo "*** Initializing machines ***"
|
||||
|
||||
NODE_MODULE=${NODE_MODULE:-"node_types/standalone_machine.nix"}
|
||||
|
||||
$NIX_INFRA fleet init-machine -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--nixos-version="$NIXOS_VERSION" \
|
||||
--target="$TARGET" \
|
||||
--node-module="$NODE_MODULE"
|
||||
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_init_nodes=$(date +%s)
|
||||
|
||||
echo "*** Deploying apps ***"
|
||||
|
||||
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --env="$ENV" --batch \
|
||||
--target="$TARGET"
|
||||
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "nixos-rebuild switch --fast"
|
||||
|
||||
_end=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
printf '+ provision %s\n' "$(printTime $_start $_provision)"
|
||||
printf '+ init nodes %s\n' "$(printTime $_provision $_init_nodes)"
|
||||
printf '+ deploy apps %s\n' "$(printTime $_init_nodes $_end)"
|
||||
printf '= TOTAL %s\n' "$(printTime $_start $_end)"
|
||||
echo "=========================================="
|
||||
echo "Done!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "claude" ]; then
|
||||
if [ -z "$SSH_KEY" ] || [ -z "$HCLOUD_TOKEN" ]; then
|
||||
echo "You need a .env file in the working directory with SSH_KEY and HCLOUD_TOKEN defined" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! type nix-infra-machine-mcp &> /dev/null; then
|
||||
echo "nix-infra-machine-mcp is not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect OS and set paths accordingly
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
path_to_claude="$HOME/Library/Application Support/Claude"
|
||||
claude_bin="/Applications/Claude.app/Contents/MacOS/Claude"
|
||||
;;
|
||||
Linux)
|
||||
path_to_claude="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
claude_bin="claude"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported operating system: $(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d "$path_to_claude" ]; then
|
||||
echo "You do not have Claude Application Support files in your user. Start the app once manually and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
|
||||
fi
|
||||
|
||||
# Create mcp configuration for Claude
|
||||
cat > "$path_to_claude/claude_desktop_config.json" << EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"nix-infra-machine-mcp": {
|
||||
"command": "nix-infra-machine-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run claude
|
||||
"$claude_bin" 2>&1
|
||||
|
||||
# Reset mcp configuration for Claude
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# MCP Support
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "claude" ] || [ "$CMD" = "claude-dev" ]; then
|
||||
check_required_vars SSH_KEY HCLOUD_TOKEN
|
||||
|
||||
# Detect OS and set paths accordingly
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
path_to_claude="$HOME/Library/Application Support/Claude"
|
||||
claude_bin="/Applications/Claude.app/Contents/MacOS/Claude"
|
||||
;;
|
||||
Linux)
|
||||
path_to_claude="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
claude_bin="claude"
|
||||
;;
|
||||
*)
|
||||
die "Unsupported operating system: $(uname -s)"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d "$path_to_claude" ]; then
|
||||
die "You do not have Claude Application Support files in your user. Start the app once manually and retry."
|
||||
fi
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command: claude
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "claude" ]; then
|
||||
if ! command -v nix-infra-cluster-mcp &>/dev/null; then
|
||||
die "nix-infra-cluster-mcp is not installed"
|
||||
fi
|
||||
|
||||
# Backup existing config
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
|
||||
fi
|
||||
|
||||
# Create mcp configuration for Claude
|
||||
cat >"$path_to_claude/claude_desktop_config.json" <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"nix-infra-cluster-mcp": {
|
||||
"command": "nix-infra-cluster-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run claude
|
||||
"$claude_bin" 2>&1
|
||||
|
||||
# Reset mcp configuration for Claude
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Command: claude-dev
|
||||
# ============================================================================
|
||||
|
||||
if [ "$CMD" = "claude-dev" ]; then
|
||||
# if ! command -v nix-infra-dev-mcp &>/dev/null; then
|
||||
# die "nix-infra-dev-mcp is not installed"
|
||||
# fi
|
||||
|
||||
# Backup existing config
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
|
||||
fi
|
||||
|
||||
# Create mcp configuration for Claude
|
||||
cat >"$path_to_claude/claude_desktop_config.json" <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"nix-infra-dev-mcp": {
|
||||
"command": "nix-infra-dev-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run claude
|
||||
"$claude_bin" 2>&1
|
||||
|
||||
# Reset mcp configuration for Claude
|
||||
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
|
||||
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we get here, command wasn't handled
|
||||
die "Unknown command: $CMD"
|
||||
@@ -0,0 +1,53 @@
|
||||
{ lib, pkgs, ... }:
|
||||
let
|
||||
sshPort = 22;
|
||||
sshKey = "[%%sshKey%%]";
|
||||
nixVersion = "[%%nixVersion%%]"; # 24.05
|
||||
nodeName = "[%%nodeName%%]"; # node001
|
||||
|
||||
clusterNode = lib.fileset.toList (lib.fileset.maybeMissing ./cluster_node.nix);
|
||||
controlNode = lib.fileset.toList (lib.fileset.maybeMissing ./control_node.nix);
|
||||
standaloneMachine = lib.fileset.toList (lib.fileset.maybeMissing ./standalone_machine.nix);
|
||||
nodeConfig = lib.fileset.toList (lib.fileset.maybeMissing ./[%%nodeName%%].nix);
|
||||
modules = lib.fileset.toList (lib.fileset.maybeMissing ./modules/default.nix);
|
||||
appModules = lib.fileset.toList (lib.fileset.maybeMissing ./app_modules/default.nix);
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./networking.nix # generated at runtime by nixos-infect
|
||||
] ++ clusterNode ++ controlNode ++ nodeConfig ++ standaloneMachine ++ modules ++ appModules;
|
||||
|
||||
boot.tmp.cleanOnBoot = true;
|
||||
zramSwap.enable = true;
|
||||
system.stateVersion = nixVersion;
|
||||
|
||||
networking.hostName = nodeName;
|
||||
networking.domain = "";
|
||||
users.users.root.openssh.authorizedKeys.keys = [ sshKey ];
|
||||
networking.firewall.enable = true;
|
||||
networking.firewall.allowedTCPPorts = [ sshPort ];
|
||||
networking.firewall.allowedUDPPorts = [ ];
|
||||
|
||||
services.openssh.enable = true;
|
||||
services.openssh.settings.PermitRootLogin = "yes";
|
||||
services.openssh.settings.PasswordAuthentication = false;
|
||||
services.openssh.settings.KbdInteractiveAuthentication = false;
|
||||
services.openssh.settings.LogLevel = "ERROR";
|
||||
services.openssh.settings.Macs = [
|
||||
"hmac-sha2-512-etm@openssh.com"
|
||||
"hmac-sha2-512" # Required for dartssh
|
||||
"hmac-sha2-256-etm@openssh.com"
|
||||
"hmac-sha2-256" # Required for dartssh
|
||||
"umac-128-etm@openssh.com"
|
||||
];
|
||||
|
||||
services.rsyncd.enable = true;
|
||||
|
||||
# Enable Flakes
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
environment.systemPackages = with pkgs; [
|
||||
# Flakes clones its dependencies through the git command
|
||||
git
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
description = "A simple NixOS flake";
|
||||
|
||||
inputs = {
|
||||
# NixOS official package source, using the specified branch here
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-[%%nixVersion%%]"; # Can we read this from configuration.nix?
|
||||
secrix.url = "github:Platonic-Systems/secrix"; # We should probably fork this
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, secrix, ... }@inputs: {
|
||||
# Please replace my-nixos with your hostname
|
||||
nixosConfigurations.[%%nodeName%%] = nixpkgs.lib.nixosSystem {
|
||||
system = "[%%hwArch%%]";
|
||||
modules = [
|
||||
# Allow commercially licensed packages
|
||||
{ nixpkgs.config.allowUnfree = true; }
|
||||
# Import the previous configuration.nix we used,
|
||||
# so the old configuration file still takes effect
|
||||
./configuration.nix
|
||||
{
|
||||
# Set all inputs parameters as special arguments for all submodules,
|
||||
# so you can directly use all dependencies in inputs in submodules
|
||||
_module.args = { inherit inputs; };
|
||||
}
|
||||
];
|
||||
};
|
||||
apps.x86_64-linux.secrix = inputs.secrix.secrix self;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
imports = [
|
||||
./oci-containers.nix
|
||||
./podman.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
{ config, options, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.infrastructure.oci-containers;
|
||||
proxy_env = config.networking.proxy.envVars;
|
||||
hostName = config.networking.hostName;
|
||||
defaultBackend = options.infrastructure.oci-containers.backend.default;
|
||||
|
||||
containerOptions =
|
||||
{ ... }: {
|
||||
|
||||
options = {
|
||||
|
||||
app = {
|
||||
name = mkOption {
|
||||
type = with types; str;
|
||||
description = "Name of the container.";
|
||||
example = "hello-world";
|
||||
};
|
||||
};
|
||||
|
||||
bindToIp = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "IP to bind to.";
|
||||
example = "127.0.0.1";
|
||||
};
|
||||
|
||||
# Settings for running container
|
||||
|
||||
image = mkOption {
|
||||
type = with types; str;
|
||||
description = "OCI image to run.";
|
||||
example = "library/hello-world";
|
||||
};
|
||||
|
||||
imageFile = mkOption {
|
||||
type = with types; nullOr package;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to an image file to load before running the image. This can
|
||||
be used to bypass pulling the image from the registry.
|
||||
|
||||
The `image` attribute must match the name and
|
||||
tag of the image contained in this file, as they will be used to
|
||||
run the container with that image. If they do not match, the
|
||||
image will be pulled from the registry as usual.
|
||||
'';
|
||||
example = literalExpression "pkgs.dockerTools.buildImage {...};";
|
||||
};
|
||||
|
||||
login = {
|
||||
|
||||
username = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Username for login.";
|
||||
};
|
||||
|
||||
passwordFile = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Path to file containing password.";
|
||||
example = "/etc/nixos/dockerhub-password.txt";
|
||||
};
|
||||
|
||||
registry = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Registry where to login to.";
|
||||
example = "https://docker.pkg.github.com";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
cmd = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Commandline arguments to pass to the image's entrypoint.";
|
||||
example = literalExpression ''
|
||||
["--port=9000"]
|
||||
'';
|
||||
};
|
||||
|
||||
labels = mkOption {
|
||||
type = with types; attrsOf str;
|
||||
default = {};
|
||||
description = "Labels to attach to the container at runtime.";
|
||||
example = literalExpression ''
|
||||
{
|
||||
"traefik.https.routers.example.rule" = "Host(`example.container`)";
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
entrypoint = mkOption {
|
||||
type = with types; nullOr str;
|
||||
description = "Override the default entrypoint of the image.";
|
||||
default = null;
|
||||
example = "/bin/my-app";
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = with types; attrsOf str;
|
||||
default = {};
|
||||
description = "Environment variables to set for this container.";
|
||||
example = literalExpression ''
|
||||
{
|
||||
DATABASE_HOST = "db.example.org";
|
||||
DATABASE_PORT = "3306";
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
environmentFiles = mkOption {
|
||||
type = with types; listOf path;
|
||||
default = [];
|
||||
description = "Environment files for this container.";
|
||||
example = literalExpression ''
|
||||
[
|
||||
/path/to/.env
|
||||
/path/to/.env.secret
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
environmentSecrets = mkOption {
|
||||
type = with types; listOf (attrsOf str);
|
||||
default = [];
|
||||
description = "Secrets passed to pod as env-vars.";
|
||||
example = literalExpression ''
|
||||
# https://systemd.io/CREDENTIALS/
|
||||
[
|
||||
{ name = "secret.name"; envVar = "SECRET_NAME"; }
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
execHooks = {
|
||||
ExecStartPre = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Command to run before starting the container.";
|
||||
};
|
||||
};
|
||||
|
||||
log-driver = mkOption {
|
||||
type = types.str;
|
||||
default = "journald";
|
||||
description = ''
|
||||
Logging driver for the container. The default of
|
||||
`"journald"` means that the container's logs will be
|
||||
handled as part of the systemd unit.
|
||||
|
||||
For more details and a full list of logging drivers, refer to respective backends documentation.
|
||||
|
||||
For Docker:
|
||||
[Docker engine documentation](https://docs.docker.com/engine/reference/run/#logging-drivers---log-driver)
|
||||
|
||||
For Podman:
|
||||
Refer to the docker-run(1) man page.
|
||||
'';
|
||||
};
|
||||
|
||||
ports = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
Network ports to publish from the container to the outer host.
|
||||
|
||||
Valid formats:
|
||||
- `<ip>:<hostPort>:<containerPort>`
|
||||
- `<ip>::<containerPort>`
|
||||
- `<hostPort>:<containerPort>`
|
||||
- `<containerPort>`
|
||||
|
||||
Both `hostPort` and `containerPort` can be specified as a range of
|
||||
ports. When specifying ranges for both, the number of container
|
||||
ports in the range must match the number of host ports in the
|
||||
range. Example: `1234-1236:1234-1236/tcp`
|
||||
|
||||
When specifying a range for `hostPort` only, the `containerPort`
|
||||
must *not* be a range. In this case, the container port is published
|
||||
somewhere within the specified `hostPort` range.
|
||||
Example: `1234-1236:1234/tcp`
|
||||
|
||||
Refer to the
|
||||
[Docker engine documentation](https://docs.docker.com/engine/reference/run/#expose-incoming-ports) for full details.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
"8080:9000"
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = ''
|
||||
Override the username or UID (and optionally groupname or GID) used
|
||||
in the container.
|
||||
'';
|
||||
example = "nobody:nogroup";
|
||||
};
|
||||
|
||||
volumes = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
List of volumes to attach to this container.
|
||||
|
||||
Note that this is a list of `"src:dst"` strings to
|
||||
allow for `src` to refer to `/nix/store` paths, which
|
||||
would be difficult with an attribute set. There are
|
||||
also a variety of mount options available as a third
|
||||
field; please refer to the
|
||||
[docker engine documentation](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) for details.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
"volume_name:/path/inside/container"
|
||||
"/path/on/host:/path/inside/container"
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
workdir = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Override the default working directory for the container.";
|
||||
example = "/var/lib/hello_world";
|
||||
};
|
||||
|
||||
dependsOn = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
Define which other containers this one depends on. They will be added to both After and Requires for the unit.
|
||||
|
||||
Use the same name as the attribute under `virtualisation.oci-containers.containers`.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
virtualisation.oci-containers.containers = {
|
||||
node1 = {};
|
||||
node2 = {
|
||||
dependsOn = [ "node1" ];
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
hostname = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "The hostname of the container.";
|
||||
example = "hello-world";
|
||||
};
|
||||
|
||||
extraOptions = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Extra options for {command}`${defaultBackend} run`.";
|
||||
example = literalExpression ''
|
||||
["--network=host"]
|
||||
'';
|
||||
};
|
||||
|
||||
autoStart = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
When enabled, the container is automatically started on boot.
|
||||
If this option is set to false, the container has to be started on-demand via its service.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
isValidLogin = login: login.username != null && login.passwordFile != null && login.registry != null;
|
||||
|
||||
|
||||
mkService = name: container: let
|
||||
dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn;
|
||||
escapedName = escapeShellArg name;
|
||||
in {
|
||||
wantedBy = [] ++ optional (container.autoStart) "multi-user.target";
|
||||
wants = lib.optional (container.imageFile == null) "network-online.target";
|
||||
after = lib.optionals (cfg.backend == "docker") [ "docker.service" "docker.socket" ]
|
||||
# if imageFile is not set, the service needs the network to download the image from the registry
|
||||
++ lib.optionals (container.imageFile == null) [ "network-online.target" ]
|
||||
++ dependsOn;
|
||||
requires = dependsOn;
|
||||
environment = proxy_env;
|
||||
|
||||
path =
|
||||
if cfg.backend == "docker" then [ config.virtualisation.docker.package ]
|
||||
else if cfg.backend == "podman" then [ config.virtualisation.podman.package ]
|
||||
else throw "Unhandled backend: ${cfg.backend}";
|
||||
|
||||
script = concatStringsSep " \\\n " ([
|
||||
"exec ${cfg.backend} run"
|
||||
"--rm"
|
||||
"--name=${escapedName}"
|
||||
"--log-driver=${container.log-driver}"
|
||||
] ++ optional (container.entrypoint != null)
|
||||
"--entrypoint=${escapeShellArg container.entrypoint}"
|
||||
++ optional (container.hostname != null)
|
||||
"--hostname=${escapeShellArg container.hostname}"
|
||||
++ lib.optionals (cfg.backend == "podman") [
|
||||
"--cidfile=/run/podman-${escapedName}.ctr-id"
|
||||
"--cgroups=no-conmon"
|
||||
"--sdnotify=conmon"
|
||||
"-d"
|
||||
"--replace"
|
||||
] ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment)
|
||||
++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles
|
||||
++ map (s: "-e ${escapeShellArg s.envVar}=$(cat $CREDENTIALS_DIRECTORY/${escapeShellArg s.name})") container.environmentSecrets
|
||||
++ map (p: "-p ${escapeShellArg p}") container.ports
|
||||
++ optional (container.user != null) "-u ${escapeShellArg container.user}"
|
||||
++ map (v: "-v ${escapeShellArg v}") container.volumes
|
||||
++ (mapAttrsToList (k: v: "-l ${escapeShellArg k}=${escapeShellArg v}") container.labels)
|
||||
++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}"
|
||||
++ map escapeShellArg container.extraOptions
|
||||
++ [container.image]
|
||||
++ map escapeShellArg container.cmd
|
||||
);
|
||||
|
||||
preStop = if cfg.backend == "podman"
|
||||
then "podman stop --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
|
||||
else "${cfg.backend} stop ${name} || true";
|
||||
|
||||
postStop = if cfg.backend == "podman"
|
||||
then "podman rm -f --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
|
||||
else "${cfg.backend} rm -f ${name} || true";
|
||||
|
||||
serviceConfig = {
|
||||
### There is no generalized way of supporting `reload` for docker
|
||||
### containers. Some containers may respond well to SIGHUP sent to their
|
||||
### init process, but it is not guaranteed; some apps have other reload
|
||||
### mechanisms, some don't have a reload signal at all, and some docker
|
||||
### images just have broken signal handling. The best compromise in this
|
||||
### case is probably to leave ExecReload undefined, so `systemctl reload`
|
||||
### will at least result in an error instead of potentially undefined
|
||||
### behaviour.
|
||||
###
|
||||
### Advanced users can still override this part of the unit to implement
|
||||
### a custom reload handler, since the result of all this is a normal
|
||||
### systemd service from the perspective of the NixOS module system.
|
||||
###
|
||||
# ExecReload = ...;
|
||||
###
|
||||
ExecStartPre = container.execHooks.ExecStartPre;
|
||||
LoadCredentialEncrypted = map (s: "${s.name}:/root/secrets/${s.name}") container.environmentSecrets;
|
||||
|
||||
TimeoutStartSec = 0;
|
||||
TimeoutStopSec = 120;
|
||||
Restart = "always";
|
||||
} // optionalAttrs (cfg.backend == "podman") {
|
||||
Environment="PODMAN_SYSTEMD_UNIT=podman-${name}.service";
|
||||
Type="notify";
|
||||
NotifyAccess="all";
|
||||
};
|
||||
};
|
||||
in {
|
||||
|
||||
options.infrastructure.oci-containers = {
|
||||
|
||||
backend = mkOption {
|
||||
type = types.enum [ "podman" "docker" ];
|
||||
default = if versionAtLeast config.system.stateVersion "22.05" then "podman" else "docker";
|
||||
description = "The underlying Docker implementation to use.";
|
||||
};
|
||||
|
||||
containers = mkOption {
|
||||
default = {};
|
||||
type = types.attrsOf (types.submodule containerOptions);
|
||||
description = "OCI (Docker) containers to run as systemd services.";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkIf (cfg.containers != {}) (lib.mkMerge [
|
||||
{
|
||||
systemd.services = mapAttrs' (n: v: nameValuePair "${cfg.backend}-${n}" (mkService n v)) cfg.containers;
|
||||
}
|
||||
(lib.mkIf (cfg.backend == "podman") {
|
||||
virtualisation.podman.enable = true;
|
||||
})
|
||||
(lib.mkIf (cfg.backend == "docker") {
|
||||
virtualisation.docker.enable = true;
|
||||
})
|
||||
]);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
cfg = config.infrastructure.podman;
|
||||
in
|
||||
{
|
||||
options.infrastructure.podman = {
|
||||
enable = lib.mkEnableOption "infrastructure.podman";
|
||||
|
||||
dockerRegistryHostPort = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Docker Registry IP address";
|
||||
default = "127.0.0.1:5000";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
# Enable common container config files in /etc/containers
|
||||
virtualisation.containers.enable = true;
|
||||
virtualisation = {
|
||||
podman = {
|
||||
enable = true;
|
||||
|
||||
# Create a `docker` alias for podman, to use it as a drop-in replacement
|
||||
dockerCompat = true;
|
||||
|
||||
# Required for containers under podman-compose to be able to talk to each other.
|
||||
defaultNetwork.settings.dns_enabled = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Add insecure registry
|
||||
virtualisation.containers.registries.insecure = [ "${cfg.dockerRegistryHostPort}" ];
|
||||
# virtualisation.containers.registries."10.10.93.0:5000".insecure = true;
|
||||
|
||||
# Useful otherdevelopment tools
|
||||
environment.systemPackages = with pkgs; [
|
||||
# dive # look into docker image layers
|
||||
podman-tui # status of containers in the terminal
|
||||
# docker-compose # start group of containers for dev
|
||||
podman-compose # start group of containers for dev
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{ config, pkgs, ... }:
|
||||
let
|
||||
# Add variables here
|
||||
in
|
||||
{
|
||||
# This file contains common configuration across your entire fleet
|
||||
# of standalone machines.
|
||||
config.environment.systemPackages = with pkgs; [
|
||||
# Useful tools for debugging and administration
|
||||
htop
|
||||
curl
|
||||
wget
|
||||
netcat
|
||||
jq
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
k8sUpstreamConf = "/run/nginx/k8s-upstream.conf";
|
||||
k8sHttpUpstreamConf = "/run/nginx/k8s-http-upstream.conf";
|
||||
|
||||
updateK8sIp = pkgs.writeShellScriptBin "update-k8s-ip" ''
|
||||
NEW_IP="$SSH_ORIGINAL_COMMAND"
|
||||
|
||||
if ! echo "$NEW_IP" | ${pkgs.gnugrep}/bin/grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Invalid IP: $NEW_IP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONF="${k8sUpstreamConf}"
|
||||
CURRENT_IP=$(${pkgs.gnugrep}/bin/grep -oP 'server \K[0-9.]+' "$CONF" 2>/dev/null | head -1)
|
||||
|
||||
if [ "$CURRENT_IP" = "$NEW_IP" ]; then
|
||||
echo "IP unchanged: $NEW_IP"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'upstream k8s_tls {\n server %s:443;\n}\n\nupstream k8s_ldap {\n server %s:3389;\n}\n' "$NEW_IP" "$NEW_IP" > "$CONF"
|
||||
printf 'upstream k8s_http {\n server %s:80;\n}\n' "$NEW_IP" > ${k8sHttpUpstreamConf}
|
||||
|
||||
${pkgs.nginx}/bin/nginx -t && ${pkgs.systemd}/bin/systemctl reload nginx
|
||||
echo "Updated K8s backend IP to $NEW_IP"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ──────────────────────────────────────────────
|
||||
# Firewall (replaces UFW)
|
||||
# ──────────────────────────────────────────────
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
25 # SMTP
|
||||
465 # SMTP submissions (implicit TLS)
|
||||
587 # SMTP submission (STARTTLS)
|
||||
993 # IMAP (implicit TLS)
|
||||
443 # HTTPS
|
||||
80 # HTTP (ACME + redirect)
|
||||
];
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Sysctl hardening (replaces base role)
|
||||
# ──────────────────────────────────────────────
|
||||
boot.kernel.sysctl = {
|
||||
"net.ipv4.conf.all.rp_filter" = 1;
|
||||
"net.ipv4.conf.default.rp_filter" = 1;
|
||||
"net.ipv4.conf.all.accept_redirects" = 0;
|
||||
"net.ipv4.conf.default.accept_redirects" = 0;
|
||||
"net.ipv4.conf.all.send_redirects" = 0;
|
||||
"net.ipv4.conf.default.send_redirects" = 0;
|
||||
"net.ipv4.tcp_syncookies" = 1;
|
||||
"net.ipv4.icmp_echo_ignore_broadcasts" = 1;
|
||||
"net.ipv6.conf.all.accept_redirects" = 0;
|
||||
"net.ipv6.conf.default.accept_redirects" = 0;
|
||||
};
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Fail2ban
|
||||
# ──────────────────────────────────────────────
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
maxretry = 5;
|
||||
bantime = "1h";
|
||||
|
||||
jails = {
|
||||
sshd = {
|
||||
settings = {
|
||||
enabled = true;
|
||||
port = "ssh";
|
||||
maxretry = 3;
|
||||
bantime = "1h";
|
||||
findtime = "10m";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ACME / Let's Encrypt (replaces certbot)
|
||||
# ──────────────────────────────────────────────
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "admin@rubenhensen.nl";
|
||||
certs."stalwart.rubenhensen.nl" = {
|
||||
group = "stalwart-mail";
|
||||
reloadServices = [ "stalwart-mail" ];
|
||||
webroot = "/var/lib/acme/acme-challenge";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stalwart mail server
|
||||
# ──────────────────────────────────────────────
|
||||
services.stalwart-mail = {
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
hostname = "stalwart.rubenhensen.nl";
|
||||
max-connections = 8192;
|
||||
listener = {
|
||||
smtp = {
|
||||
bind = "[::]:25";
|
||||
protocol = "smtp";
|
||||
};
|
||||
submission = {
|
||||
bind = "[::]:587";
|
||||
protocol = "smtp";
|
||||
};
|
||||
submissions = {
|
||||
bind = "[::]:465";
|
||||
protocol = "smtp";
|
||||
tls.implicit = true;
|
||||
};
|
||||
imaptls = {
|
||||
bind = "[::]:993";
|
||||
protocol = "imap";
|
||||
tls.implicit = true;
|
||||
};
|
||||
https = {
|
||||
bind = "127.0.0.1:8443";
|
||||
protocol = "http";
|
||||
tls.implicit = true;
|
||||
};
|
||||
http = {
|
||||
bind = "127.0.0.1:8080";
|
||||
protocol = "http";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
certificate.default = {
|
||||
cert = "%{file:/var/lib/acme/stalwart.rubenhensen.nl/fullchain.pem}%";
|
||||
private-key = "%{file:/var/lib/acme/stalwart.rubenhensen.nl/key.pem}%";
|
||||
};
|
||||
|
||||
storage = {
|
||||
data = "rocksdb";
|
||||
fts = "rocksdb";
|
||||
blob = "rocksdb";
|
||||
lookup = "rocksdb";
|
||||
directory = "ldap";
|
||||
};
|
||||
|
||||
store.rocksdb = {
|
||||
type = "rocksdb";
|
||||
path = "/var/lib/stalwart-mail/data";
|
||||
compression = "lz4";
|
||||
};
|
||||
|
||||
directory.ldap = {
|
||||
type = "ldap";
|
||||
url = "ldap://127.0.0.1:3389";
|
||||
base-dn = "DC=ldap,DC=goauthentik,DC=io";
|
||||
bind.dn = "cn=ldapservice,ou=users,DC=ldap,DC=goauthentik,DC=io";
|
||||
bind.secret = "%{file:/run/secrets/stalwart-ldap-password}%";
|
||||
filter.name = "(&(objectClass=user)(cn=?))";
|
||||
filter.email = "(&(objectClass=user)(mail=?))";
|
||||
filter.verify = "(&(objectClass=user)(|(mail=*?*)(cn=*?*)))";
|
||||
filter.expand = "(&(objectClass=group)(cn=?))";
|
||||
attribute.name = "cn";
|
||||
attribute.email = "mail";
|
||||
attribute.description = "displayName";
|
||||
};
|
||||
|
||||
tracer.stdout = {
|
||||
type = "stdout";
|
||||
level = "info";
|
||||
ansi = false;
|
||||
enable = true;
|
||||
};
|
||||
|
||||
tracer.log = {
|
||||
type = "log";
|
||||
level = "info";
|
||||
path = "/var/lib/stalwart-mail/logs";
|
||||
prefix = "stalwart.log";
|
||||
rotate = "daily";
|
||||
ansi = false;
|
||||
enable = true;
|
||||
};
|
||||
|
||||
authentication.fallback-admin = {
|
||||
user = "admin";
|
||||
secret = "%{file:/run/secrets/stalwart-admin-password}%";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Grant stalwart and nginx access to ACME certs
|
||||
users.users.stalwart-mail.extraGroups = [ "acme" ];
|
||||
users.users.nginx.extraGroups = [ "stalwart-mail" ];
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Nginx (reverse proxy + stream proxy to K8s)
|
||||
# ──────────────────────────────────────────────
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedTlsSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedProxySettings = true;
|
||||
eventsConfig = "worker_connections 4096;";
|
||||
|
||||
# HTTP upstream for K8s (included from mutable file)
|
||||
appendHttpConfig = ''
|
||||
include /run/nginx/k8s-http-upstream.conf;
|
||||
'';
|
||||
|
||||
# Stream config for TLS SNI routing + LDAP proxy
|
||||
streamConfig = ''
|
||||
log_format stream '$remote_addr [$time_local] '
|
||||
'$protocol $status $bytes_sent $bytes_received '
|
||||
'$session_time "$ssl_preread_server_name"';
|
||||
access_log /var/log/nginx/stream.log stream;
|
||||
|
||||
map $ssl_preread_server_name $tls_backend {
|
||||
stalwart.rubenhensen.nl local_tls;
|
||||
default k8s_tls;
|
||||
}
|
||||
|
||||
upstream local_tls {
|
||||
server 127.0.0.1:8443;
|
||||
}
|
||||
|
||||
include /run/nginx/k8s-upstream.conf;
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
listen [::]:443;
|
||||
ssl_preread on;
|
||||
proxy_pass $tls_backend;
|
||||
}
|
||||
|
||||
# LDAP proxy to K8s Authentik LDAP outpost
|
||||
server {
|
||||
listen 127.0.0.1:3389;
|
||||
proxy_pass k8s_ldap;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# Create stream.d directory and initial upstream config
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /run/secrets 0700 root root -"
|
||||
"d /var/lib/acme/acme-challenge 0755 acme acme -"
|
||||
];
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# K8s IP update script (called via SSH)
|
||||
# ──────────────────────────────────────────────
|
||||
# Allow nginx to read/write mutable upstream configs
|
||||
systemd.services.nginx.serviceConfig.ReadWritePaths = [ "/run/nginx" ];
|
||||
systemd.services.nginx.serviceConfig.LimitNOFILE = 65536;
|
||||
systemd.services.nginx.preStart = lib.mkBefore ''
|
||||
mkdir -p /run/nginx
|
||||
test -f /run/nginx/k8s-upstream.conf || printf 'upstream k8s_tls {\n server 127.0.0.1:443;\n}\n\nupstream k8s_ldap {\n server 127.0.0.1:3389;\n}\n' > /run/nginx/k8s-upstream.conf
|
||||
test -f /run/nginx/k8s-http-upstream.conf || printf 'upstream k8s_http {\n server 127.0.0.1:80;\n}\n' > /run/nginx/k8s-http-upstream.conf
|
||||
'';
|
||||
|
||||
# Stalwart ACME HTTP-01 challenge
|
||||
services.nginx.virtualHosts."stalwart.rubenhensen.nl" = {
|
||||
listen = [
|
||||
{ addr = "0.0.0.0"; port = 80; }
|
||||
{ addr = "[::]"; port = 80; }
|
||||
];
|
||||
locations."/.well-known/acme-challenge/" = {
|
||||
root = "/var/lib/acme/acme-challenge";
|
||||
};
|
||||
locations."/" = {
|
||||
return = "301 https://$host$request_uri";
|
||||
};
|
||||
};
|
||||
|
||||
# Catch-all port 80 — proxy to K8s for ACME challenges + redirect
|
||||
services.nginx.virtualHosts."_" = {
|
||||
default = true;
|
||||
listen = [
|
||||
{ addr = "0.0.0.0"; port = 80; }
|
||||
{ addr = "[::]"; port = 80; }
|
||||
];
|
||||
locations."/" = {
|
||||
proxyPass = "http://k8s_http";
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# IP update script (as a proper Nix package)
|
||||
environment.systemPackages = [ updateK8sIp pkgs.openssl ];
|
||||
|
||||
# SSH authorized key for K8s IP updater (add the actual pubkey)
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
# nix-infra will set the main SSH key via configuration.nix
|
||||
# Add the IP updater key with command restriction:
|
||||
''command="${updateK8sIp}/bin/update-k8s-ip",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII5cMc73rlUCn3mS5FXlu3nO+AUeW2L28jRh22VYIPY4 k8s-ip-updater''
|
||||
];
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Automatic updates
|
||||
# ──────────────────────────────────────────────
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
allowReboot = false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env sh
|
||||
BRANCH=${BRANCH:-"main"}
|
||||
REPO=${REPO:-"git@github.com:jhsware/nix-infra-test-machine.git"}
|
||||
|
||||
|
||||
# Check for nix-infra CLI
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "You need 'git' for this script to work."
|
||||
echo "Install git using your prefered package manager. If in doubt, install Determinate Nix"
|
||||
echo "https://docs.determinate.systems/determinate-nix/ and run: 'nix-shell -p git'"
|
||||
echo
|
||||
echo "With nix-shell you get ephemeral shell environments. Learn more:"
|
||||
echo "https://medium.com/@nonickedgr/exploring-nix-shell-a-game-changer-for-ephemeral-environments-5c622e4074a8"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
printf "Enter folder name [test-nix-infra-machine]: "
|
||||
read -r name
|
||||
name=${name:-test-nix-infra-machine}
|
||||
|
||||
if [ -e "./$name" ]; then
|
||||
echo "Folder or file $name already exists in this directory, aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git clone -b "$BRANCH" "$REPO" "$name"
|
||||
cp "$name/.env.in" "$name/.env"
|
||||
|
||||
echo "Done!"
|
||||
echo
|
||||
echo "Make sure you have installed nix-infra, then:"
|
||||
echo
|
||||
echo "1. cd ./$name"
|
||||
echo "2. Edit .env"
|
||||
echo "3. Run:"
|
||||
echo " ./cli --help - Manage infrastructure (create, destroy, ssh, etc.)"
|
||||
echo " ./__test__/run-tests.sh --help - Run test suite against machines"
|
||||
echo
|
||||
@@ -0,0 +1,18 @@
|
||||
let
|
||||
sources = import ./nix/sources.nix;
|
||||
pkgs = import sources.nixpkgs {};
|
||||
hcloud = pkgs.callPackage nix/hcloud.nix {};
|
||||
|
||||
isMacOS = builtins.match ".*-darwin" pkgs.stdenv.hostPlatform.system != null;
|
||||
in pkgs.mkShell rec {
|
||||
name = "nix-infra-machine";
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
hcloud
|
||||
] ++ (if !isMacOS then [
|
||||
] else []);
|
||||
|
||||
shellHook = ''
|
||||
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user