Contributing
Contributing to ncps#
Thank you for your interest in contributing to ncps! This document provides guidelines and instructions for contributing to the project.
Getting Started#
Prerequisites#
The project uses Nix flakes with direnv for reproducible development environments. You'll need:
- Nix with flakes enabled - Installation guide
- direnv - Installation guide
Initial Setup#
Clone the repository:
git clone https://github.com/kalbasit/ncps.git cd ncpsAllow direnv:
direnv allowThis will automatically load the development environment with all required tools:
- Go
- golangci-lint
- delve (debugger)
- watchexec
- sqlfluff
- Garage (for S3 testing)
- PostgreSQL (for database testing)
- MySQL/MariaDB (for database testing)
- Redis (for distributed locking testing)
Development Environment#
Available Tools#
Once in the development shell, you have access to:
| Tool | Purpose |
|---|---|
go |
Go compiler and toolchain |
golangci-lint |
Code linting with 30+ linters |
delve |
Go debugger |
watchexec |
File watcher for hot-reloading |
sqlfluff |
SQL linting and formatting |
garage |
S3-compatible object storage |
postgresql |
PostgreSQL database server |
mariadb |
MySQL/MariaDB database server |
redis |
Redis server for distributed locks |
Development Dependencies#
The project uses process-compose-flake for managing development services. Start dependencies with:
nix run .#depsThis starts:
- Garage - S3-compatible object store (S3 API on port 9000, admin on 3903)
- Test bucket:
test-bucket - Credentials:
GK1234567890abcdef12345678/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - Self-validation ensures proper setup
- Test bucket:
- PostgreSQL - Database server (port 5432)
- Test database:
test-db - Credentials:
test-user/test-password - Connection URL:
postgresql://test-user:[email protected]:5432/test-db?sslmode=disable
- Test database:
- MariaDB - MySQL-compatible database server (port 3306)
- Test database:
test-db - Credentials:
test-user/test-password - Connection URL:
mysql://test-user:[email protected]:3306/test-db
- Test database:
- Redis - Distributed locking server (port 6379)
- No authentication required (test environment)
- Used for distributed lock testing
Development Workflow#
Running the Development Server#
The development server supports hot-reloading and multiple storage backends:
# Using local filesystem storage (default, no dependencies required)
./dev-scripts/run.sh
# or explicitly
./dev-scripts/run.sh local
# Using S3/Garage storage (requires dependencies to be running)
# In a separate terminal:
nix run .#deps
# Then run the dev server:
./dev-scripts/run.sh s3The server automatically restarts when you modify code files.
Editing Documentation#
The documentation for this project is managed using Trilium. Follow these steps to contribute to the documentation:
Run the documentation editor:
trilium-edit-docsThis tool is available in the
PATHprovided by the Nix flake, see Development Environment in the Developer Guide for more information.Edit the documentation through Trilium: The Trilium interface will open, allowing you to edit the notes. Trilium automatically exports the markdown files back to the repository as you make changes.
Wait and close: Wait 5 minutes after you have finished all your edits to ensure all changes are synced, then close Trilium.
Format the documentation:
Run the project formatter to ensure the markdown files follow the project's standards:
nix fmtSubmit your changes: Submit a Pull Request with your changes.
Database Migrations#
The schema is defined in Ent (ent/schema/*.go), per-dialect migrations are generated by Atlas (used as a Go library, never the CLI), and Goose applies them at runtime via the embedded migrations/<dialect>/ directories.
Creating a new migration:
Edit
ent/schema/<entity>.go(field/edge/index/annotation change).Regenerate the Ent client:
go generate ./ent/...(ortask ent:generate).Generate per-dialect Atlas migrations:
task migrations:gen NAME=descriptive_snake_caseThis writes one timestamp-prefixed
.sqlper dialect undermigrations/sqlite/,migrations/postgres/, andmigrations/mysql/, plus updates each dialect'satlas.sumintegrity file.
Applying migrations:
ncps migrate up --cache-database-url=sqlite:/path/to/db.sqlite
ncps migrate up --cache-database-url=postgresql://user:pass@host:port/db
ncps migrate up --cache-database-url=mysql://user:pass@host:port/db
# Preview without touching the DB:
ncps migrate up --cache-database-url=... --dry-runThe same flag also accepts the env var CACHE_DATABASE_URL. Down migrations are intentionally not supported — see the expand-contract policy in CLAUDE.md for the safe column-change recipe.
Skills under .agent/skills/ codify the day-to-day workflows:
/migrate-new— edit an Ent schema + generate the per-dialect migrations/migrate-up— apply migrations/migrate-down— read this to learn why you don't want to roll back
The cmd/ent-lint AST linter (run via task ent:lint or task ent:check) enforces the five Ent codegen invariants documented at the top of cmd/ent-lint/main.go.
Code Quality Standards#
Formatting#
IMPORTANT: Always run formatters first before making manual changes:
# Format all files (Go, Nix, SQL, YAML, Markdown)
nix fmtThe project uses:
- gofumpt - Stricter Go formatting
- goimports - Import organization
- gci - Import grouping (standard → default → alias → localmodule)
- nixfmt - Nix code formatting
- sqlfluff - SQL formatting and linting
- yamlfmt - YAML formatting
- mdformat - Markdown formatting
Linting#
IMPORTANT: Always run golangci-lint run --fix first to automatically fix issues:
# Auto-fix all fixable linting issues
golangci-lint run --fix
# Lint without auto-fix
golangci-lint run
# Lint specific package
golangci-lint run ./pkg/server/...The project uses 30+ linters including:
- err113 - Explicit error wrapping
- exhaustive - Exhaustive switch statements
- gosec - Security checks
- paralleltest - Parallel test detection
- testpackage - Test package naming
See .golangci.yml for complete linter configuration.
SQL Linting#
# Lint SQL files
sqlfluff lint migrations/sqlite/*.sql
sqlfluff lint migrations/postgres/*.sql
sqlfluff lint migrations/mysql/*.sql
# Format SQL files
sqlfluff format migrations/sqlite/*.sqlTesting#
Running Tests#
# Run all tests with race detector (recommended)
go test -race ./...
# Run tests for specific package
go test -race ./pkg/server/...
# Run a single test
go test -race -run TestName ./pkg/server/...Integration Tests#
The project includes integration tests for S3, PostgreSQL, MySQL, and Redis. Integration tests are disabled by default and must be explicitly enabled using shell helper functions.
Quick Start:
# In terminal 1: Start development dependencies
nix run .#deps
# In terminal 2: Enable integration tests and run tests
eval "$(enable-integration-tests)"
go test -race ./...Available Helper Commands:
The development shell provides commands to easily enable/disable integration tests:
| Command | Description |
|---|---|
eval "$(enable-s3-tests)" |
Enable S3/Garage integration tests |
eval "$(enable-postgres-tests)" |
Enable PostgreSQL integration tests |
eval "$(enable-redis-tests)" |
Enable Redis integration tests |
eval "$(enable-mysql-tests)" |
Enable MySQL integration tests |
eval "$(enable-integration-tests)" |
Enable all integration tests at once |
eval "$(disable-integration-tests)" |
Disable all integration tests |
Running Specific Integration Tests:
# Start dependencies (in a separate terminal)
nix run .#deps
# Enable and run S3 tests only
eval "$(enable-s3-tests)"
go test -race ./pkg/storage/s3
# Enable and run database tests only
eval "$(enable-postgres-tests)"
eval "$(enable-mysql-tests)"
go test -race ./pkg/database
# Enable all tests and run everything
eval "$(enable-integration-tests)"
go test -race ./...
# Disable integration tests when done
eval "$(disable-integration-tests)"What the Helper Commands Do:
The helper commands output shell export statements that you evaluate in your current shell:
**enable-s3-tests**exports:NCPS_TEST_S3_BUCKET=test-bucketNCPS_TEST_S3_ENDPOINT=http://127.0.0.1:9000NCPS_TEST_S3_REGION=us-east-1NCPS_TEST_S3_ACCESS_KEY_ID=GK1234567890abcdef12345678NCPS_TEST_S3_SECRET_ACCESS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
**enable-postgres-tests**exports:NCPS_TEST_POSTGRES_URL=postgresql://test-user:[email protected]:5432/test-db?sslmode=disable
**enable-mysql-tests**exports:NCPS_TEST_MYSQL_URL=mysql://test-user:[email protected]:3306/test-db
**enable-redis-tests**exports:NCPS_ENABLE_REDIS_TESTS=1
Tests automatically skip if these environment variables aren't set, so you can run go test -race ./... without enabling integration tests and only unit tests will run.
Test Requirements#
- Use testify for assertions
- Enable race detector (
-raceflag) - Use
_testpackage suffix (enforced bytestpackagelinter) - Write parallel tests where possible (checked by
paralleltestlinter) - Each test should be isolated and not depend on other tests
Nix Build Tests#
# Run all checks including integration tests
nix flake check
# Build package (includes test phase)
nix buildThe Nix build automatically:
- Starts Garage, PostgreSQL, MariaDB, and Redis in
preCheckphase - Creates test databases and buckets
- Exports test environment variables
- Runs all tests (including integration tests)
- Stops services in
postCheckphase
Helm Chart Testing#
The project includes comprehensive Helm chart testing using a local Kind cluster with Garage, PostgreSQL, MariaDB, and Redis. A unified CLI tool (k8s-tests) manages the complete testing workflow.
Prerequisites:
All tools are provided by the Nix development environment:
- Docker (for Kind)
- kubectl, helm, kind (via Nix)
- k8s-tests CLI (automatically available in
nix develop)
Quick Start (Complete Workflow):
# Run all 5 steps in one command
k8s-tests allThis command:
- Creates Kind cluster with all dependencies
- Builds and pushes Docker image
- Generates 13 test values files
- Installs all test deployments
- Runs comprehensive tests
Individual Steps:
# 1. Create Kind cluster with all dependencies (Garage, PostgreSQL, MariaDB, Redis)
k8s-tests cluster create
# 2. Build Docker image, push to local registry, and generate test values
k8s-tests generate --push
# 3. Install all test deployments
k8s-tests install
# 4. Run tests across all deployments
k8s-tests test
# 5. Cleanup when done
k8s-tests cleanupThe cluster can be reused across test runs. Use k8s-tests cluster destroy to clean up when done.
Alternative: Use External Image
If you've already built and pushed an image:
# Use a specific tag
k8s-tests generate sha-cf09394
# Use custom registry and repository
k8s-tests generate 0.5.1 docker.io kalbasit/ncps
# Or build externally and use the tag
DOCKER_IMAGE_TAGS="yourregistry.com/ncps:sha$(git rev-parse --short HEAD)" nix run .#push-docker-image
k8s-tests generate sha$(git rev-parse --short HEAD)-x86_64-linux yourregistry.com ncpsTest Permutations:
The tool generates 13 different test configurations:
- Single Instance (7 scenarios):
- Local storage: SQLite, PostgreSQL, MariaDB
- S3 storage: SQLite, PostgreSQL, MariaDB
- S3 + PostgreSQL + CDC enabled
- External Secrets (2 scenarios):
- S3 + PostgreSQL/MariaDB with existing Kubernetes secrets
- High Availability (4 scenarios):
- 2 replicas with S3 storage
- PostgreSQL or MariaDB database
- Redis locks
- With/without CDC enabled
Testing Individual Deployments:
# Install a single deployment
k8s-tests install single-local-sqlite
# Test specific deployment (verbose)
k8s-tests test single-local-sqlite -v
# Test specific deployment (non-verbose)
k8s-tests test single-s3-postgres
# Cleanup specific deployment
k8s-tests cleanup single-local-sqliteManual Installation:
# Install individual deployment manually using generated values
helm upgrade --install ncps-single-local-postgres charts/ncps \
-f charts/ncps/test-values/single-local-postgres.yaml \
--create-namespace \
--namespace ncps-single-local-postgresCluster Management:
# Show cluster connection information
k8s-tests cluster info
# Destroy cluster
k8s-tests cluster destroy
# Help
k8s-tests --helpAdding New Test Scenarios:
To add a custom test permutation, edit nix/k8s-tests/config.nix:
{
permutations = [
# ... existing permutations
{
name = "my-new-scenario";
description = "My custom test scenario";
replicas = 1;
storage = { type = "s3"; };
database = { type = "postgresql"; };
redis.enabled = false;
features = []; # Optional: ["cdc", "ha", "pod-disruption-budget"]
}
];
}Then regenerate: k8s-tests generate --push
For more details: See nix/k8s-tests/README.md
Pull Request Process#
Before Submitting#
Format your code:
nix fmtFix linting issues:
golangci-lint run --fixRun tests:
go test -race ./...Build successfully:
nix build
Commit Guidelines#
- Use clear, descriptive commit messages
- Follow Conventional Commits when possible:
feat:- New featuresfix:- Bug fixesdocs:- Documentation changesrefactor:- Code refactoringtest:- Test additions/changeschore:- Build/tooling changes
Pull Request Guidelines#
Create a feature branch:
git checkout -b feature/your-feature-nameMake your changes following code quality standards
Update documentation if needed (README.md, CLAUDE.md, etc.)
Add tests for new functionality
Submit PR with:
- Clear description of changes
- Reference to related issues
- Screenshots/examples if applicable
CI/CD Notes#
The project uses GitHub Actions for CI/CD:
- Workflows only run on PRs targeting
mainbranch - This supports Graphite-style stacked PRs efficiently
- When modifying workflows, maintain the
branches: [main]restriction
Project Structure#
ncps/
├── cmd/ # CLI commands
│ ├── serve.go # Main serve command
│ ├── ent-lint/ # AST linter for Ent codegen invariants
│ ├── generate-migrations/ # Atlas-driven per-dialect generator
│ └── atlas-sum-check/ # CI helper verifying atlas.sum integrity
├── pkg/
│ ├── cache/ # Core caching logic
│ ├── storage/ # Storage abstraction
│ │ ├── local/ # Local filesystem storage
│ │ └── s3/ # S3-compatible storage
│ ├── database/ # Thin facade over the generated Ent client
│ │ └── migrate/ # State detection + adoption + apply
│ ├── lock/ # Lock abstraction
│ │ ├── local/ # Local locks (single-instance)
│ │ └── redis/ # Redis distributed locks (HA)
│ ├── server/ # HTTP server (Chi router)
│ └── nar/ # NAR format handling
├── ent/ # Ent schema definitions
│ ├── schema/ # Hand-authored entity schemas (*.go)
│ └── ... # Generated client (committed)
├── migrations/ # Per-dialect Atlas migrations
│ ├── sqlite/ # SQLite migrations + atlas.sum
│ ├── postgres/ # PostgreSQL migrations + atlas.sum
│ └── mysql/ # MySQL migrations + atlas.sum
├── nix/ # Nix configuration
│ ├── packages/ # Package definitions
│ ├── devshells/ # Development shells
│ ├── formatter/ # Formatter configuration
│ └── process-compose/ # Development services
└── dev-scripts/ # Development helper scripts
└── run.py # Development server orchestratorKey Interfaces#
Storage (**pkg/storage/store.go**):
ConfigStore- Secret key storageNarInfoStore- NarInfo metadata storageNarStore- NAR file storage
Both local and S3 backends implement these interfaces.
Locks (**pkg/lock/lock.go**):
Locker- Exclusive locking for download deduplicationRWLocker- Read-write locking for LRU coordination
Both local (sync.Mutex) and Redis (Redlock) backends implement these interfaces. Redis locks enable high-availability deployments with multiple instances.
Database:
- Supports SQLite, PostgreSQL, and MySQL via Ent (the type-safe ORM)
- Database selection via URL scheme in
--cache-database-url - Schema lives in
ent/schema/*.go; generated client lives underent/ - Per-dialect migrations under
migrations/<dialect>/are produced by Atlas (as a Go library) and applied at runtime by Goose
Common Development Tasks#
Adding a New Database Migration#
Edit
ent/schema/<entity>.go— add the field/edge/index/annotation. The five codegen invariants are enforced bycmd/ent-lint; seecmd/ent-lint/main.gofor the full list (table-levelentsql.Check,entsql.OnDeleteonedge.To, etc.).Regenerate the Ent client:
go generate ./ent/...(ortask ent:generate).Generate per-dialect Atlas migrations + update each
atlas.sum:task migrations:gen NAME=descriptive_snake_caseReview the generated
.sqlfiles undermigrations/sqlite/,migrations/postgres/,migrations/mysql/. Each file is a single timestamp-prefixed file with+goose Up/+goose Downmarkers. SQLite files that needPRAGMA foreign_keys = OFFmust also carry-- +goose NO TRANSACTION.Apply locally to verify:
ncps migrate up --cache-database-url=sqlite:./test.db --dry-run ncps migrate up --cache-database-url=sqlite:./test.db
The expand-contract policy in CLAUDE.md documents the four-step recipe for non-additive column changes (e.g. adding NOT NULL). Down migrations are intentionally not supported.
Regenerating the Ent client#
After editing any ent/schema/*.go:
go generate ./ent/... # or: task ent:generate
go run ./cmd/ent-lint --root . # or: task ent:linttask ent:check runs both and verifies the generated tree is up to date.
Adding a New Storage Backend#
- Implement the storage interfaces in
pkg/storage/ - Add configuration flags in
cmd/serve.go - Update documentation in README.md and CLAUDE.md
- Add integration tests
- Update Docker and Kubernetes examples if applicable
Debugging#
Use delve for debugging:
# Debug the application
dlv debug . -- serve --cache-hostname=localhost --cache-storage-local=/tmp/ncps
# Debug a specific test
dlv test ./pkg/server -- -test.run TestNameNote: The dev shell disables fortify hardening to allow delve to work.
Building Docker Images#
# Build Docker image
nix build .#docker
# Load into Docker
docker load < result
# Push to registry (requires DOCKER_IMAGE_TAGS environment variable)
DOCKER_IMAGE_TAGS="kalbasit/ncps:latest kalbasit/ncps:v1.0.0" nix run .#push-docker-imageGetting Help#
- Documentation Issues: Check CLAUDE.md for detailed development guidance
- Bug Reports: Open an issue
- Questions: Start a discussion
- Security Issues: Contact maintainers privately
Code of Conduct#
- Be respectful and inclusive
- Provide constructive feedback
- Focus on what's best for the project
- Show empathy towards other contributors
License#
By contributing to ncps, you agree that your contributions will be licensed under the MIT License.
Thank you for contributing to ncps! 🎉