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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .containerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.git
.direnv
deploy
*.go
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ jobs:
sudo "$GUIX_BIN/guix" archive --authorize \
< "$GUIX_PROFILE/share/guix/ci.guix.gnu.org.pub"

"$GUIX_BIN/guix" --version
# The 1.4.0 tarball is from 2022 and ships the old guile-libyaml
# (ffi-help-rt API); (hexol import) targets the current one (nyacc
# cdata), so bring Guix up to date before resolving manifest.scm.
sudo "$GUIX_BIN/guix" pull --url=https://codeberg.org/guix/guix.git
GUIX_BIN=/root/.config/guix/current/bin
sudo "$GUIX_BIN/guix" --version

# examples/terraform.scm reads an SSH public key from ~/.ssh at render
# time. The suite runs as root below, so give root a throwaway key —
Expand All @@ -61,3 +66,17 @@ jobs:
# Run as root (daemon owner) to avoid socket-permission friction.
cd "$GITHUB_WORKSPACE"
sudo "$GUIX_BIN/guix" shell -m manifest.scm -- make build test test-examples

# Build the OCI image (no push) so the Containerfile can't rot. Runs on the
# runner's docker; the same file builds under podman via `make image`.
image:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Build the image
run: docker build -f Containerfile -t hexol:ci .
- name: Smoke-test it
run: |
docker run --rm hexol:ci --version
docker run --rm hexol:ci render -o yaml -i examples/kubernetes.scm | head -5
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
.direnv/
*.go
cmdb.log
openrc
*.tf.json
.terraform/
Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Changelog

All notable changes to hexol are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the version is the one
`hexol --version` prints (`%hexol-version` in `hexol/version.scm`).

## [Unreleased]

### Added
- `diff` verb: compare two renders (or a render against live state).
- `doc` verb: documentation for the constructs an inventory uses.
- `import` verb: turn existing manifests/config into an inventory.
- `lint` verb: static checks over an inventory.
- `--validate` flag: schema-check the resolved state before rendering.
- `explain` reports the fix location for ops generated under `hx-each`.

## [0.1.0] - 2026-09-03

First tagged release: the engine, the author surface, the target libraries,
and the CLI as they exist today.

### Added
- Kernel: inventories resolve as a fold of labeled ops (`merge`, `set`,
`append`, `when`, `case`) over one state; every value keeps the op that set
it, so `tree`, `show`, and `explain` can answer "what set this?".
- Author surface: `hx-ops`, `hx-each`, `hx-merge`, `hx-when`, `hx-case`,
`hx-append`; `define-construct` for record-body constructs.
- CLI verbs: `render` (`-o sexp|json|yaml|terraform|ansible`, `--path`),
`apply` (`--only`, `--dry-run`, `--list`), `tree` (`-v` with per-op fold
time), `explain PATH|HASH`, `show HASH`, `secret`, and `--version`.
Inventories add verbs with `defines-action`; global `--color`.
- Inventory is `-i/--inventory` or `$HEXOL_INVENTORY`, never a positional.
- Kubernetes library: standard resources (Deployment, Service, ConfigMap,
Secret, ...), Helm chart expansion, `checksum-config` rollout pass, a
`kubectl` applier.
- Terraform library: providers/resources/data sources rendered to Terraform
JSON; `tofu` applier with output reporting and a `validate` action.
- Ansible library: plays and tasks accumulated into a playbook.
- Inline SOPS/age secrets, path-keyed, with `(secret-ref 'k)` markers;
managed with `hexol secret ls|get|set|edit|rm|rekey|init`.
- Inventory-registered renderers (`renders-with`) and appliers
(`applies-with`); SQL and Ledger renderers as example extensions.
- Render cache for `helm template` / remote-manifest shell-outs.
- Examples: kubernetes, terraform, ansible, secrets, helm
kube-prometheus-stack, database-schema, ledger, and the homelab (3-node
Talos on OVH, deployed end to end).
- Docs: `docs/model.md`, `docs/authoring.md`, `docs/extending.md`.
- Packaging: `manifest.scm` (Guix dev shell, CI), `guix.scm` (Guix package),
`Containerfile` (OCI image), `flake.nix` (Nix package + devShell).
- CI: GitHub Actions runs build, tests, and example renders under Guix, and
builds the container image.

[Unreleased]: https://github.com/Polyedre/hexol/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/Polyedre/hexol/releases/tag/v0.1.0
50 changes: 50 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Containerfile — hexol as a small OCI image.
#
# Trade-off: Alpine ships guile 3 + guile-json + jq, but not guile-libyaml
# (nor does Debian), so the first stage builds it from source with nyacc's
# ffi-helper — the same recipe Guix's guile-libyaml package uses. The
# alternative, `guix pack -f docker -m manifest.scm` (see `make image-guix`),
# is a one-liner but needs a Guix install and yields a ~300 MB image; this
# stays plain OCI, builds anywhere podman/docker runs, and lands ~60 MB.
#
# podman build -t hexol .
# podman run --rm -v "$PWD:/w" -w /w hexol render -i examples/kubernetes.scm

ARG ALPINE=3.21
ARG NYACC=3.02.0
ARG LIBYAML=3.0.2

FROM alpine:${ALPINE} AS libyaml
ARG NYACC
ARG LIBYAML
RUN apk add --no-cache guile guile-dev gcc musl-dev make pkgconf yaml yaml-dev curl
WORKDIR /src
# nyacc: pure Guile; provides `guild compile-ffi`.
RUN curl -fsSL --retry 5 --retry-all-errors "https://download-mirror.savannah.gnu.org/releases/nyacc/nyacc-${NYACC}.tar.gz" | tar xz \
&& cd "nyacc-${NYACC}" && ./configure --prefix=/usr && make && make install
# guile-libyaml: generate the FFI bindings, compile, install into guile's site dirs.
RUN curl -fsSL --retry 5 --retry-all-errors "https://github.com/mwette/guile-libyaml/archive/refs/tags/V${LIBYAML}.tar.gz" | tar xz \
&& cd "guile-libyaml-${LIBYAML}" \
&& GUILE_AUTO_COMPILE=0 guild compile-ffi --no-exec yaml/libyaml.ffi \
&& sed -i 's| "libyaml"| "/usr/lib/libyaml-0.so.2"|' yaml/libyaml.scm \
&& site=/usr/share/guile/site/3.0 ccache=/usr/lib/guile/3.0/site-ccache \
&& for f in yaml.scm yaml/*.scm; do \
mkdir -p "$site/$(dirname $f)" "$ccache/$(dirname $f)"; \
cp "$f" "$site/$f"; \
guild compile -L . -o "$ccache/${f%.scm}.go" "$f"; \
done

FROM alpine:${ALPINE}
RUN apk add --no-cache guile guile-json jq yaml make
COPY --from=libyaml /usr/share/guile/site/3.0/ /usr/share/guile/site/3.0/
COPY --from=libyaml /usr/lib/guile/3.0/site-ccache/ /usr/lib/guile/3.0/site-ccache/
COPY . /opt/hexol
WORKDIR /opt/hexol
# Precompile into the image so first run isn't a compile; XDG cache is where
# guile's auto-compiler looks, so point it at a fixed, world-readable dir.
ENV XDG_CACHE_HOME=/opt/hexol/.cache
RUN make build && guile -L . -e main -s bin/hexol --version && chmod -R a+rX /opt/hexol/.cache
# busybox `env` has no -S, so bin/hexol's shebang can't run as-is: invoke guile
# the way the shebang would (same shape as guix.scm's wrapper).
ENTRYPOINT ["guile", "-L", "/opt/hexol", "-e", "main", "-s", "/opt/hexol/bin/hexol"]
CMD ["--help"]
30 changes: 26 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
.PHONY: help test test-examples build clean
.PHONY: help test test-examples build image image-guix clean

GUILE ?= guile

help:
@echo "targets:"
@echo " make test run the smoke tests (kernel, surface, res, k8s)"
@echo " make test run the smoke tests (kernel, surface, res, k8s, import)"
@echo " make test-examples render the standalone examples, check they exit 0"
@echo " make build compile all modules (surfaces any load/compile error)"
@echo " make clean remove this project's Guile compile cache"
@echo " make image build the OCI image from Containerfile (podman or docker)"
@echo " make image-guix same via guix pack (no container build needed)"
@echo " make clean remove this project's Guile compile cache"
@echo
@echo "everything else is the CLI — ./bin/hexol --help:"
@echo " ./bin/hexol render [-o sexp|json|yaml|terraform|ansible] [--query K=V,…] [--path P] [-i INVENTORY]"
@echo " ./bin/hexol apply [--only SPEC] [--dry-run] [--list] [-i INVENTORY]"
@echo " ./bin/hexol diff [--only SPEC] [--explain] [-i INVENTORY]"
@echo " ./bin/hexol tree [-i INVENTORY]"
@echo " ./bin/hexol explain [--query K=V,…] PATH|HASH [-i INVENTORY]"
@echo " ./bin/hexol show HASH [-i INVENTORY]"
@echo " ./bin/hexol lint [-i INVENTORY]"
@echo " ./bin/hexol doc [CONSTRUCT] [-i INVENTORY]"
@echo " ./bin/hexol import -f FILE|- [--from yaml|terraform] [--sugar] [--no-clean]"
@echo " ./bin/hexol --version"

test:
$(GUILE) -L . test.scm
$(GUILE) -L . test/construct.scm
$(GUILE) -L . test/k8s-res.scm
$(GUILE) -L . test/import.scm
$(GUILE) -L . test/apply-mode.scm
GUILE=$(GUILE) ./test/diff-cli.sh

test-examples:
GUILE=$(GUILE) ./test/examples.sh

build:
@$(GUILE) -L . -c '(begin (use-modules (hexol) (hexol k8s) (hexol terraform) (hexol apply) (hexol ansible) (hexol ledger) (hexol sql) (hexol json)) (display "build ok\n"))'
@$(GUILE) -L . -c '(begin (use-modules (hexol) (hexol k8s) (hexol terraform) (hexol apply) (hexol ansible) (hexol ledger) (hexol sql) (hexol json) (hexol lint) (hexol import)) (display "build ok\n"))'

# Prefer podman, fall back to docker. IMAGE is the tag.
IMAGE ?= hexol
OCI ?= $(shell command -v podman || command -v docker)
image:
$(OCI) build -f Containerfile -t $(IMAGE) .

# Alternative: let Guix assemble the image from guix.scm (bigger image, but no
# source build of guile-libyaml). Loads the tarball into $(OCI).
image-guix:
$(OCI) load < "$$(guix pack -f docker -S /bin=bin --entry-point=bin/hexol -e '(load "guix.scm")')"

clean:
rm -rf ~/.cache/guile/ccache/*$(CURDIR)*
46 changes: 39 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,40 @@ A few things this buys you over plain manifests:

## Install

**Status:** 0.1.0 (`hexol --version`); see [CHANGELOG.md](CHANGELOG.md) for
what's in it and what's coming.

Hexol runs on [Guile](https://www.gnu.org/software/guile/) 3.x and needs two
Guile libraries: **guile-json** (the `(json)` module) and **guile-libyaml**
(the `(yaml)` module). It also uses `jq`. All of these are declared in
[`manifest.scm`](manifest.scm), which is the source of truth for dependencies.
Three ways to get them:

The easy path is [Guix](https://guix.gnu.org/), which reads that manifest
directly — no manual install:
**From source** — install Guile 3.x, guile-json, guile-libyaml, and jq however
your distro provides them (or let [Guix](https://guix.gnu.org/) read the
manifest), then run from the checkout:

```sh
git clone https://github.com/Polyedre/hexol && cd hexol
guix shell -m manifest.scm -- ./bin/hexol render -i examples/kubernetes.scm
./bin/hexol render -i examples/kubernetes.scm # auto-compiles on first use
guix shell -m manifest.scm -- ./bin/hexol render -i examples/kubernetes.scm # with Guix
```

(The repo's `.envrc` does this automatically under [direnv](https://direnv.net/).)
(The repo's `.envrc` runs the `guix shell` for you under
[direnv](https://direnv.net/).)

Without Guix, install Guile 3.x plus guile-json, guile-libyaml, and jq however
your distro provides them, then:
**Container** — an OCI image built from [`Containerfile`](Containerfile)
(`make image` builds it locally); mount your inventories in:

```sh
./bin/hexol render -i examples/kubernetes.scm # auto-compiles on first use
podman run --rm -v "$PWD:/w" -w /w ghcr.io/polyedre/hexol render -i examples/kubernetes.scm
```

**Nix** — [`flake.nix`](flake.nix) packages the CLI and provides a dev shell:

```sh
nix run github:Polyedre/hexol -- render -i examples/kubernetes.scm
nix develop github:Polyedre/hexol # guile + deps in a shell
```

The CLI itself shells out to nothing. Individual features do, and only when you
Expand All @@ -90,13 +104,30 @@ use them: `helm`/`yq` to expand charts, `sops` for inline secrets, and
# act on it (appliers an inventory registers via `applies-with`)
./bin/hexol apply --list -i examples/kubernetes.scm # show the applier pipeline
./bin/hexol apply --dry-run -i examples/kubernetes.scm # delegate to each tool's dry-run
./bin/hexol diff -i examples/kubernetes.scm # drift vs the world: exit 0 clean, 1 drift, 2 error
./bin/hexol diff --explain -i examples/kubernetes.scm # each changed field + the op that set it (kubectl)
./bin/hexol render -o yaml --validate -i examples/kubernetes.scm # pipe the stream through kubeconform (if on PATH)

# introspect: rendering is not opaque
./bin/hexol tree -i examples/kubernetes.scm # op tree (with hashes)
./bin/hexol show OP_HASH -i examples/kubernetes.scm # one op: source + delta
./bin/hexol explain regions.alpha5.network.cni -i examples/inventory.scm # what touched a path

# discover the library: which (key value) entries a construct accepts
./bin/hexol doc # every construct (built-in libs)
./bin/hexol doc app -i examples/kubernetes.scm # one construct: fields + example
```

**Migrating.** You don't rewrite what you already have — you wrap it.
`hexol import -f manifests.yaml > app.scm` turns a k8s manifest stream (or a
`kubectl get -o yaml` dump; server-populated fields are stripped) into an
inventory with one `(resource …)` per document that renders back to the same
YAML; `--sugar` lifts objects to the typed constructs (`deployment`,
`service`, `configmap`, …) wherever they fit exactly. `hexol import -f
main.tf.json --from terraform` does the same for a Terraform JSON config
(HCL text is out of scope). From there you refactor at your own pace. See
[`docs/authoring.md`](docs/authoring.md#migrating-hexol-import).

## The Library

While the kernel is target-agnostic, the library provide a few syntaxic sugar helpers:
Expand Down Expand Up @@ -154,6 +185,7 @@ live homelab. Kick the tires before you bet a cluster on it.
- [`docs/extending.md`](docs/extending.md) — building target libraries,
the kernel/library/example boundary, worked Terraform and Helm
conversions, and introspection.
- The event-sourced CMDB prototype lives on the `cmdb` branch.

## License

Expand Down
Loading
Loading