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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 273 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,278 @@
# declix-bash

declix bash generator
A Bash script generator for declarative Linux system configuration using [Pkl](https://pkl-lang.org). Transform declarative resource definitions into idempotent shell scripts that can check, diff, and apply system configurations.

Usage:
## Overview

`./generate.sh resources.pkl | bash -s check`
`./generate.sh resources.pkl | bash -s apply`
declix-bash is part of the Declix ecosystem for declarative Linux configuration management. It takes resource definitions written in Pkl and generates safe, idempotent Bash scripts that can manage your system state.

### Key Features

- **Declarative Configuration**: Define desired system state in `.pkl` files
- **Idempotent Operations**: Scripts can be run repeatedly without side effects
- **Three Operation Modes**: `check`, `diff`, and `apply`
- **Safety First**: Generated scripts use strict error handling (`set -euo pipefail`)
- **Resource Types**: Support for packages, files, directories, systemd units, users, and groups
- **Content Validation**: SHA256 checksums for file content integrity

## Quick Start

### Using the Release Build (Recommended)

Download or build the single-file release:

```bash
# Build the release
just release

# Use the single-file script
./out/declix-bash.sh resources.pkl | bash -s check
./out/declix-bash.sh resources.pkl | bash -s diff
./out/declix-bash.sh resources.pkl | bash -s apply
```

### Using Local Development

```bash
# Install dependencies
just deps

# Generate and run scripts
./generate.sh resources.pkl | bash -s check
./generate.sh resources.pkl | bash -s apply
```

### Using Container

```bash
# Build container
just build

# Generate using container
just generate resources.pkl | bash -s check
```

## Resource Types

### APT Packages

```pkl
new apt.Package {
name = "nginx"
state = "installed"
updateBeforeInstall = true
}
```

### Files and Directories

```pkl
new fs.File {
path = "/etc/myapp/config.yml"
state = new fs.FilePresent {
content = "key: value"
owner = "root"
group = "root"
permissions = "644"
}
}

new fs.Directory {
path = "/var/lib/myapp"
state = new fs.DirectoryPresent {
owner = "myapp"
group = "myapp"
permissions = "755"
}
}
```

### Systemd Units

```pkl
new systemd.Unit {
name = "nginx.service"
state = new systemd.Enabled {
active = true
autoStart = true
}
}
```

### Users and Groups

```pkl
new user.User {
name = "webapp"
state = new user.UserPresent {
uid = 1001
gid = 1001
home = "/home/webapp"
shell = "/bin/bash"
comment = "Web application user"
}
}
```

## Operation Modes

### Check Mode
Shows the current status of each resource:
```bash
./generate.sh resources.pkl | bash -s check
```
Output shows "ok", "needs update", "needs creation", etc.

### Diff Mode
Shows detailed differences between current and desired state:
```bash
./generate.sh resources.pkl | bash -s diff
```
Displays file content diffs, permission changes, etc.

### Apply Mode
Makes changes to achieve the desired state:
```bash
./generate.sh resources.pkl | bash -s apply
```
Only makes necessary changes, reports what was modified.

## Example Configuration

```pkl
import "package://pkl.declix.org/pkl-declix@0.6.0#/apt/apt.pkl"
import "package://pkl.declix.org/pkl-declix@0.6.0#/fs/fs.pkl"
import "package://pkl.declix.org/pkl-declix@0.6.0#/systemd/systemd.pkl"

resources = new Listing {
// Install required packages
new apt.Package {
name = "nginx"
state = "installed"
}

// Create configuration file
new fs.File {
path = "/etc/nginx/sites-available/mysite"
state = new fs.FilePresent {
content = """
server {
listen 80;
server_name example.com;
root /var/www/html;
}
"""
owner = "root"
group = "root"
permissions = "644"
}
}

// Enable and start nginx
new systemd.Unit {
name = "nginx.service"
state = new systemd.Enabled {
active = true
autoStart = true
}
}
}
```

## Development Commands

```bash
# Install development dependencies
just deps

# Build container image
just build

# Generate script locally
just generate-local resources.pkl

# Run tests
just test

# Run shellcheck on scripts
just shellcheck

# Create single-file release
just release

# Run all commit checks
just check-commit
```

## Architecture

### Code Generation Flow

1. **Input**: Pkl resource definitions (`.pkl` files)
2. **Processing**: `generate.pkl` transforms resources into Bash functions
3. **Output**: Self-contained shell script with embedded functions
4. **Execution**: Generated script supports check/diff/apply operations

### Generated Script Structure

- **Header**: Strict error handling (`set -euo pipefail`)
- **Common Functions**: Reusable utilities from `src/common.sh`
- **Resource Functions**: One function per resource (named `_<sanitized_id>`)
- **Operation Handlers**: `check()`, `diff()`, `apply()` functions
- **Main Logic**: Command-line argument parsing and dispatch

### Safety Features

- **Input Sanitization**: All user input is properly quoted and escaped
- **Privilege Escalation**: Automatic `sudo` usage for privileged operations
- **Content Validation**: SHA256 checksums verify file content integrity
- **Idempotent Operations**: Check before modify pattern prevents unnecessary changes
- **Error Handling**: Strict bash settings catch errors early

## Testing

The project uses container-based integration tests:

```bash
# Run all tests
just test

# Run specific test
cd tests && just test-one files

# Test release build
just test-release
```

Test structure:
- Each test has a `resources.pkl` defining desired state
- `setup.sh` creates initial conditions
- `verify.sh` checks final state matches expectations
- Tests run in isolated containers

## File Handling

Files use a sophisticated content management system:

- **Content Sources**: String literals, external files, or URLs
- **Encoding**: Base64 encoding for binary compatibility
- **Validation**: SHA256 checksums ensure integrity
- **Atomicity**: Content written to temp files, then moved into place
- **Permissions**: Owner, group, and mode managed separately from content

## Related Projects

- **[pkl-declix](https://github.com/declix/pkl-declix)**: Core Pkl schemas for Linux resources
- **[declix-scraper](../declix-scraper)**: Generate Pkl configurations from existing systems
- **[pkl-systemd](../pkl-systemd)**: Pkl templates for systemd unit files

## Requirements

- **pkl**: Apple's configuration language runtime
- **bash**: Modern bash shell (4.0+)
- **sudo**: For privileged system operations
- **Standard utilities**: `systemctl`, `apt-get`, `useradd`, etc.

## License

See [LICENSE](LICENSE) file for details.
32 changes: 9 additions & 23 deletions generate.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,12 @@ import "pkl:math"

function generate(resources): String = genScript(toGens(resources)).join("\n")

function gen(resource: Any): Gen =
if (resource is apt.Package)
new AptPackageGen { pkg = resource.toDynamic() }
else if (resource is fs.File)
new FsFileGen { file = resource.toDynamic() }
else if (resource is fs.Dir)
new FsDirGen { dir = resource.toDynamic() }
else if (resource is systemd.Unit)
new SystemdUnitGen { unit = resource.toDynamic() }
else if (resource is user.User)
new UserGen { user = resource.toDynamic() }
else if (resource is user.Group)
new GroupGen { group = resource.toDynamic() }
else if (resource.type == "apt")
function gen(resource: Dynamic): Gen =
if (resource.type == "apt")
new AptPackageGen { pkg = resource }
else if (resource.type == "file" || resource.type == "fs:file")
else if (resource.type == "file")
new FsFileGen { file = resource }
else if (resource.type == "dir" || resource.type == "fs:dir")
else if (resource.type == "dir")
new FsDirGen { dir = resource }
else if (resource.type == "systemd")
new SystemdUnitGen { unit = resource }
Expand Down Expand Up @@ -123,10 +111,7 @@ local function genScript(gens: Listing<Gen>): Listing<String> = new Listing {
}

function toGens(resources): Listing<Gen> =
if (resources is Listing)
resources.toList().map((r)->gen(r)).toListing()
else
resources.toList().map((r)->gen(r)).toListing()
resources.toList().map((r)->gen(if (r.hasProperty("toDynamic")) r.toDynamic() else r)).toListing()


abstract class Gen {
Expand Down Expand Up @@ -235,7 +220,7 @@ class UserGen extends Gen {
fixed id = user.id

fixed result = new Listing {
if (user.state is user.UserPresent || (user.state.hasProperty("uid") || user.state.hasProperty("shell") || user.state.hasProperty("comment")))
if (user.state.hasProperty("uid") || user.state.hasProperty("shell") || user.state.hasProperty("comment") || user.state.hasProperty("home"))
let (state = user.state)
let (uid = if (state.hasProperty("uid")) state.uid else "")
let (gid = if (state.hasProperty("gid")) state.gid else "")
Expand All @@ -257,7 +242,7 @@ class GroupGen extends Gen {
fixed id = group.id

fixed result = new Listing {
if (group.state is user.GroupPresent || (group.state.hasProperty("gid") || group.state.hasProperty("members")))
if (group.state.hasProperty("gid") || group.state.hasProperty("members"))
let (state = group.state)
let (gid = if (state.hasProperty("gid")) state.gid else "")
let (members = if (state.hasProperty("members") && state.members != null) state.members.toList().join(",") else "")
Expand All @@ -272,7 +257,8 @@ class GroupGen extends Gen {
}

local function deepToDynamic(x: base.Typed): Dynamic = x.toDynamic().toMap().mapValues((k, v) -> maybeToDynamic(v)).toDynamic()
local function maybeToDynamic(x: Any) = if (x is Typed) deepToDynamic(x) else x
local function maybeToDynamic(x: Any) =
if (x.hasProperty("toDynamic")) deepToDynamic(x as base.Typed) else x

const local function encodeContentBase64(content): String =
if (content is String)
Expand Down
Loading