diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..654575f --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,80 @@ +project_name: comenq + +builds: + - id: comenq + binary: comenq + main: ./crates/comenq + goos: [linux] + goarch: [amd64, arm64] + builder: go + hooks: + pre: + - cmd: cargo build --release --package comenq --target {{ .TARGET }} + - cmd: cp target/{{ .TARGET }}/release/comenq {{ .Path }} + - id: comenqd + binary: comenqd + main: ./crates/comenqd + goos: [linux] + goarch: [amd64, arm64] + builder: go + hooks: + pre: + - cmd: cargo build --release --package comenqd --target {{ .TARGET }} + - cmd: cp target/{{ .TARGET }}/release/comenqd {{ .Path }} + +archives: + - id: default + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format: tar.gz + files: + - LICENSE + - README.md + - packaging/comenqd/config.toml + +nfpms: + - id: comenq-packages + package_name: comenq + vendor: "Comenq" + homepage: "https://github.com/leynos/comenq" + maintainer: "Comenq Maintainers " + description: "Client for the Comenq notification system." + license: MIT + formats: [deb, rpm] + builds: [comenq] + + - id: comenqd-packages + package_name: comenqd + vendor: "Comenq" + homepage: "https://github.com/leynos/comenq" + maintainer: "Comenq Maintainers " + description: "Daemon for the Comenq notification system." + license: MIT + formats: [deb, rpm] + builds: [comenqd] + contents: + - src: packaging/linux/comenqd.service + dst: /lib/systemd/system/comenqd.service + - src: packaging/comenqd/config.toml + dst: /etc/comenq/config.toml + type: config + scripts: + preinstall: packaging/linux/preinstall.sh + postinstall: packaging/linux/postinstall.sh + preremove: packaging/linux/preremove.sh + +release: + github: + owner: leynos + name: comenq + draft: true + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - '^style:' + - 'Merge pull request' + - 'Merge branch' diff --git a/Cargo.lock b/Cargo.lock index 2810db2..2496961 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,7 @@ dependencies = [ "ortho_config", "serde", "serde_json", + "serde_yaml", "tempfile", "tokio", "wiremock", @@ -2187,6 +2188,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "2.0.0" @@ -2789,6 +2803,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index cb75ac9..4923a40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tempfile = "3.10" # latest 3.x at time of writing; update as new patch versions yaque = { workspace = true } wiremock = "0.6" octocrab = { workspace = true } +serde_yaml = "0.9" [[test]] name = "cucumber" diff --git a/docs/automated-cross-platform-packaging.md b/docs/automated-cross-platform-packaging.md index 300bb37..631686d 100644 --- a/docs/automated-cross-platform-packaging.md +++ b/docs/automated-cross-platform-packaging.md @@ -1,18 +1,31 @@ -### Introduction +# Automated Cross-Platform Packaging -This guide provides a step-by-step process for configuring a GitHub Actions workflow to automatically build and package the `comenq` client and `comenqd` daemon for Linux (Fedora, Ubuntu) and macOS. We will use GoReleaser to manage the entire process, from building the Rust binaries to creating platform-native packages (`.rpm`, `.deb`) and a Homebrew formula. +## Introduction -The core of this process involves creating a `.goreleaser.yaml` file that declaratively defines the build, packaging, and release steps. This file will be used by a GitHub Actions workflow that triggers on new git tags. +This guide provides a step-by-step process for configuring a GitHub Actions +workflow to automatically build and package the `comenq` client and `comenqd` +daemon for Linux (Fedora, Ubuntu) and macOS. We will use GoReleaser to manage +the entire process, from building the Rust binaries to creating platform-native +packages (`.rpm`, `.deb`) and a Homebrew formula. + +The core of this process involves creating a `.goreleaser.yaml` file that +declaratively defines the build, packaging, and release steps. This file will +be used by a GitHub Actions workflow that triggers on new git tags. ### Part 1: Packaging for Fedora and Ubuntu with systemd -The first stage is to package `comenqd` as a `systemd` service for modern Linux distributions. +The first stage is to package `comenqd` as a `systemd` service for modern Linux +distributions. #### Step 1: Create the `systemd` Unit File -First, create a `systemd` unit file that will manage the `comenqd` daemon. This file defines how the service should be started, stopped, and managed by `systemd`. It includes security hardening measures by specifying a dedicated user and group and restricting filesystem access. +First, create a `systemd` unit file that will manage the `comenqd` daemon. This +file defines how the service should be started, stopped, and managed by +`systemd`. It includes security hardening measures by specifying a dedicated +user and group and restricting filesystem access. -Create the following file in your repository. A good location is `packaging/linux/comenqd.service`: +Create the following file in your repository. A good location is +`packaging/linux/comenqd.service`: ```systemd,ini [Unit] @@ -56,23 +69,30 @@ RestartSec=5s WantedBy=multi-user.target ``` -**Note:** This unit file assumes a configuration file at `/etc/comenq/config.toml`. You should provide a default configuration file with your package. +**Note:** This unit file assumes a configuration file at +`/etc/comenq/config.toml`. You should provide a default configuration file with +your package. #### Step 2: Create a Default Configuration File -Create a default `config.toml` file to be included in the packages. Place it at `packaging/comenqd/config.toml`. +Create a default `config.toml` file to be included in the packages. Place it at +`packaging/comenqd/config.toml`. ```toml # Default configuration for comenqd -# Example: +# github_token = "" # log_level = "info" +# socket_path = "/run/comenq/comenq.sock" +# queue_path = "/var/lib/comenq/queue" +# cooldown_period_seconds = 960 ``` #### Step 3: Create the `.goreleaser.yaml` Configuration -Now, create the main GoReleaser configuration file in the root of your repository. This file defines the entire release process. +Now, create the main GoReleaser configuration file in the root of your +repository. This file defines the entire release process. -**.goreleaser.yaml** +#### `.goreleaser.yaml` ```yaml # .goreleaser.yaml @@ -182,24 +202,31 @@ changelog: #### Step 4: Create Installation Scripts -The `systemd` unit file requires a dedicated user. These scripts will create the `comenq` user and group upon installation. +The `systemd` unit file requires a dedicated user. These scripts will create +the `comenq` user and group upon installation. **packaging/linux/[preinstall.sh](http://preinstall.sh)** ```bash #!/bin/bash +set -euo pipefail if ! getent group comenq >/dev/null; then - groupadd --system comenq + groupadd --system comenq || { echo "failed to add group" >&2; exit 1; } fi if ! getent passwd comenq >/dev/null; then - useradd --system --gid comenq --home-dir /var/lib/comenq --create-home --shell /sbin/nologin comenq + useradd --system --gid comenq --home-dir /var/lib/comenq \ + --create-home --shell /sbin/nologin comenq \ + || { echo "failed to add user" >&2; exit 1; } fi +chown comenq:comenq /var/lib/comenq +chmod 750 /var/lib/comenq ``` **packaging/linux/[postinstall.sh](http://postinstall.sh)** ```bash #!/bin/bash +set -euo pipefail # Reload systemd to recognize the new service, then enable and start it. systemctl daemon-reload systemctl enable comenqd.service @@ -210,18 +237,24 @@ systemctl start comenqd.service ```bash #!/bin/bash +set -euo pipefail # Stop and disable the service before removal. -systemctl stop comenqd.service -systemctl disable comenqd.service +if systemctl is-active --quiet comenqd.service; then + systemctl stop comenqd.service +fi +if systemctl is-enabled --quiet comenqd.service; then + systemctl disable comenqd.service +fi ``` Make these scripts executable: `chmod +x packaging/linux/*.sh`. #### Step 5: Update the GitHub Actions Workflow -Finally, modify your existing `.github/workflows/release.yml` to use GoReleaser. This workflow will trigger when you push a new tag (e.g., `v1.2.3`). +Finally, modify your existing `.github/workflows/release.yml` to use +GoReleaser. This workflow will trigger when you push a new tag (e.g., `v1.2.3`). -**.github/workflows/release.yml** +#### `.github/workflows/release.yml` ```yaml name: Release @@ -268,11 +301,13 @@ jobs: ### Part 2: Extending to macOS with `launchd` and Homebrew -Now we will extend the configuration to support macOS by creating a `launchd` service and a Homebrew Tap. +Now we will extend the configuration to support macOS by creating a `launchd` +service and a Homebrew Tap. #### Step 1: Create the `launchd` Plist File -On macOS, services are managed by `launchd`. The equivalent of a `systemd` unit file is a `.plist` file. +On macOS, services are managed by `launchd`. The equivalent of a `systemd` unit +file is a `.plist` file. Create `packaging/darwin/comenqd.plist`: @@ -304,7 +339,8 @@ Create `packaging/darwin/comenqd.plist`: #### Step 2: Update `.goreleaser.yaml` for Homebrew -Now, add the `brews` section to your `.goreleaser.yaml` to generate a Homebrew formula. This will create a formula in a separate repository (your "tap"). +Now, add the `brews` section to your `.goreleaser.yaml` to generate a Homebrew +formula. This will create a formula in a separate repository (your "tap"). First, create a new, public GitHub repository named `homebrew-tap`. @@ -366,11 +402,18 @@ brews: #### Step 3: Add the macOS Configuration File -The Homebrew formula will also install a default configuration. Add a copy for macOS, perhaps identical to the Linux one, at `packaging/darwin/config.toml`. Update the `brews.contents` section in `.goreleaser.yaml` to point to it if it differs, or simply add it to the `files` section of the archive if it's universal. For simplicity, let's assume the one at `packaging/comenqd/config.toml` is sufficient and will be picked up by the archive. +The Homebrew formula will also install a default configuration. Add a copy for +macOS, perhaps identical to the Linux one, at `packaging/darwin/config.toml`. +Update the `brews.contents` section in `.goreleaser.yaml` to point to it if it +differs, or simply add it to the `files` section of the archive if it's +universal. For simplicity, let's assume the one at +`packaging/comenqd/config.toml` is sufficient and will be picked up by the +archive. #### Step 4: Final `.goreleaser.yaml` -Here is the complete `.goreleaser.yaml` with both Linux and macOS configurations: +Here is the complete `.goreleaser.yaml` with both Linux and macOS +configurations: ```yaml # .goreleaser.yaml @@ -483,9 +526,13 @@ changelog: ### Final Steps and Usage -1. **Create a Personal Access Token (PAT)** for the Homebrew tap. Go to your GitHub Developer settings, create a new token with the `public_repo` scope, and add it as a repository secret named `HOMEBREW_TAP_TOKEN` in your `comenq` repository. +1. **Create a Personal Access Token (PAT)** for the Homebrew tap. Go to your + GitHub Developer settings, create a new token with the `public_repo` scope, + and add it as a repository secret named `HOMEBREW_TAP_TOKEN` in your + `comenq` repository. -2. **Commit and Push:** Add all the new files (`.goreleaser.yaml`, service files, install scripts) to your repository. +2. **Commit and Push:** Add all the new files (`.goreleaser.yaml`, service + files, install scripts) to your repository. 3. **Tag a Release:** To trigger the workflow, create and push a new tag: @@ -494,4 +541,7 @@ changelog: git push origin v0.1.0 ``` -The GitHub Actions workflow will now run, build your binaries, create the `.deb` and `.rpm` packages, upload them to a new GitHub Release, and finally, publish the Homebrew formula to your `homebrew-tap` repository. Your users can then install `comenq` using their native package managers. +The GitHub Actions workflow will now run, build your binaries, create the +`.deb` and `.rpm` packages, upload them to a new GitHub Release, and finally, +publish the Homebrew formula to your `homebrew-tap` repository. Your users can +then install `comenq` using their native package managers. diff --git a/docs/comenq-design.md b/docs/comenq-design.md index c3475a0..a7e1686 100644 --- a/docs/comenq-design.md +++ b/docs/comenq-design.md @@ -714,6 +714,15 @@ By adhering to these deployment and security practices, `comenq` transitions from a piece of software into a well-behaved, secure, and manageable system service. +### 4.4. Packaging and Release Workflow + +To simplify installation, the project uses GoReleaser. The declarative +`.goreleaser.yaml` invokes `cargo build` for both binaries via custom pre-build +hooks. The `nfpms` section produces signed `.deb` and `.rpm` packages for +Fedora and Ubuntu, embedding the hardened `systemd` service unit and lifecycle +scripts that create the `comenq` user. This keeps packaging logic version +controlled and repeatable. + ## Section 5: Complete Source Code and Project Manifest This final section provides the complete source code and project configuration, diff --git a/docs/roadmap.md b/docs/roadmap.md index 22e2ac9..4292ea6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,32 +81,46 @@ ## Milestone 6: Automated Cross-Platform Packaging and Release -This milestone seeks to produce native packages for major Linux distributions and macOS, simplifying installation and improving security and maintainability. +This milestone seeks to produce native packages for major Linux distributions +and macOS, simplifying installation and improving security and maintainability. -- [ ] **Implement Declarative Packaging with GoReleaser** +- [x] **Implement Declarative Packaging with GoReleaser** - - [ ] Create a comprehensive `.goreleaser.yaml` configuration to define Linux build, packaging, and release process for both `comenq` and `comenqd`. + - [x] Create a comprehensive `.goreleaser.yaml` configuration to define Linux + build, packaging, and release process for both `comenq` and `comenqd`. - - [ ] Use GoReleaser's custom builder hooks to integrate the `cargo build` process for the Rust binaries. + - [x] Use GoReleaser's custom builder hooks to integrate the `cargo build` + process for the Rust binaries. -- [ ] **Package for Linux Distributions (Fedora & Ubuntu)** +- [x] **Package for Linux Distributions (Fedora & Ubuntu)** - - [ ] Create a hardened `systemd` service unit file (`comenqd.service`) for the daemon, incorporating security best practices (`ProtectSystem`, `PrivateTmp`, `NoNewPrivileges`, etc.). + - [x] Create a hardened `systemd` service unit file (`comenqd.service`) for + the daemon, incorporating security best practices (`ProtectSystem`, + `PrivateTmp`, `NoNewPrivileges`, etc.). - - [ ] Author `preinstall`, `postinstall`, and `preremove` scripts to be embedded in the packages. These will handle the creation of the dedicated `comenq` system user and manage the `systemd` service lifecycle. + - [x] Author `preinstall`, `postinstall`, and `preremove` scripts to be + embedded in the packages. These will handle the creation of the dedicated + `comenq` system user and manage the `systemd` service lifecycle. - - [ ] Configure GoReleaser's `nfpms` section to build and sign `.rpm` and `.deb` packages. + - [x] Configure GoReleaser's `nfpms` section to build and sign `.rpm` and + `.deb` packages. - [ ] **Automate the Release Workflow** - - [ ] Implement a GitHub Actions workflow that triggers on new version tags (e.g., `v*`). + - [ ] Implement a GitHub Actions workflow that triggers on new version tags + (e.g., `v*`). - - [ ] The workflow will orchestrate the entire release: checking out the code, installing dependencies, and executing GoReleaser. + - [ ] The workflow will orchestrate the entire release: checking out the + code, installing dependencies, and executing GoReleaser. - - [ ] GoReleaser will then build the binaries, create all packages, publish the Homebrew formula, generate a changelog from git history, and upload all assets to a draft GitHub Release. + - [ ] GoReleaser will then build the binaries, create all packages, publish + the Homebrew formula, generate a changelog from git history, and upload all + assets to a draft GitHub Release. - [ ] **Update Public Documentation** - - [ ] Revise the `README.md` to feature the new, simplified installation instructions using `apt` and `dnf` + - [ ] Revise the `README.md` to feature the new, simplified installation + instructions using `apt` and `dnf` - - [ ] Add a new document to the `/docs` directory detailing the automated packaging process for future maintainers and contributors. + - [ ] Add a new document to the `/docs` directory detailing the automated + packaging process for future maintainers and contributors. diff --git a/packaging/comenqd/config.toml b/packaging/comenqd/config.toml new file mode 100644 index 0000000..9dae044 --- /dev/null +++ b/packaging/comenqd/config.toml @@ -0,0 +1,16 @@ +# Default configuration for comenqd + +# GitHub Personal Access Token used for authentication +# github_token = "" + +# Minimum log level to output +# log_level = "info" + +# Path to the Unix domain socket for client connections +# socket_path = "/run/comenq/comenq.sock" + +# Location for the persistent yaque queue data +# queue_path = "/var/lib/comenq/queue" + +# Cooldown period between posting comments in seconds +# cooldown_period_seconds = 960 diff --git a/packaging/darwin/comenqd.plist b/packaging/darwin/comenqd.plist new file mode 100644 index 0000000..c22c3a0 --- /dev/null +++ b/packaging/darwin/comenqd.plist @@ -0,0 +1,22 @@ + + + + + Label + com.github.leynos.comenqd + ProgramArguments + + /usr/local/bin/comenqd + --config + /usr/local/etc/comenq/config.toml + + RunAtLoad + + KeepAlive + + StandardOutPath + /usr/local/var/log/comenq/comenqd.log + StandardErrorPath + /usr/local/var/log/comenq/comenqd.err + + diff --git a/packaging/linux/comenqd.service b/packaging/linux/comenqd.service new file mode 100644 index 0000000..f46b27b --- /dev/null +++ b/packaging/linux/comenqd.service @@ -0,0 +1,23 @@ +[Unit] +Description=Comenq Daemon +Documentation=https://github.com/leynos/comenq +After=network.target + +[Service] +User=comenq +Group=comenq +ExecStart=/usr/bin/comenqd --config /etc/comenq/config.toml +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true +NoNewPrivileges=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/packaging/linux/postinstall.sh b/packaging/linux/postinstall.sh new file mode 100755 index 0000000..8f3d1ca --- /dev/null +++ b/packaging/linux/postinstall.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable comenqd.service + systemctl start comenqd.service +fi diff --git a/packaging/linux/preinstall.sh b/packaging/linux/preinstall.sh new file mode 100755 index 0000000..92dbf6f --- /dev/null +++ b/packaging/linux/preinstall.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +if ! getent group comenq >/dev/null; then + groupadd --system comenq || { + echo "Failed to create group" >&2 + exit 1 + } +fi + +if ! getent passwd comenq >/dev/null; then + useradd --system --gid comenq --home-dir /var/lib/comenq \ + --create-home --shell /sbin/nologin comenq || { + echo "Failed to create user" >&2 + exit 1 + } +fi + +install -d -o comenq -g comenq -m 750 /var/lib/comenq + +chown -R comenq:comenq /var/lib/comenq +chmod 750 /var/lib/comenq diff --git a/packaging/linux/preremove.sh b/packaging/linux/preremove.sh new file mode 100755 index 0000000..1306ca1 --- /dev/null +++ b/packaging/linux/preremove.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +if systemctl is-active --quiet comenqd.service; then + systemctl stop comenqd.service +fi + +if systemctl is-enabled --quiet comenqd.service; then + systemctl disable comenqd.service +fi diff --git a/tests/cucumber.rs b/tests/cucumber.rs index de5e190..3bb0008 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -1,6 +1,8 @@ mod steps; use cucumber::World as _; -use steps::{CliWorld, ClientWorld, CommentWorld, ConfigWorld, ListenerWorld, WorkerWorld}; +use steps::{ + CliWorld, ClientWorld, CommentWorld, ConfigWorld, ListenerWorld, PackagingWorld, WorkerWorld, +}; #[tokio::main] async fn main() { @@ -10,6 +12,7 @@ async fn main() { CommentWorld::run("tests/features/comment_request.feature"), ConfigWorld::run("tests/features/config.feature"), ListenerWorld::run("tests/features/listener.feature"), + PackagingWorld::run("tests/features/packaging.feature"), WorkerWorld::run("tests/features/worker.feature"), ); } diff --git a/tests/features/packaging.feature b/tests/features/packaging.feature new file mode 100644 index 0000000..25aca1e --- /dev/null +++ b/tests/features/packaging.feature @@ -0,0 +1,10 @@ +Feature: Packaging configuration + + Scenario: goreleaser configuration + Given the goreleaser configuration file + When it is parsed as YAML + Then the nfpms section exists + + Scenario: service unit hardening + Given the systemd unit file + Then it includes hardening directives diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 8ee1779..ed49745 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -8,5 +8,7 @@ pub mod config_steps; pub use config_steps::ConfigWorld; pub mod listener_steps; pub use listener_steps::ListenerWorld; +pub mod packaging_steps; +pub use packaging_steps::PackagingWorld; pub mod worker_steps; pub use worker_steps::WorkerWorld; diff --git a/tests/steps/packaging_steps.rs b/tests/steps/packaging_steps.rs new file mode 100644 index 0000000..a6429ba --- /dev/null +++ b/tests/steps/packaging_steps.rs @@ -0,0 +1,44 @@ +//! Behavioural steps for packaging configuration. +#![expect(clippy::expect_used, reason = "simplify test failure output")] + +use cucumber::{World, given, then, when}; +use serde_yaml::Value; +use std::fs; + +#[derive(Debug, Default, World)] +pub struct PackagingWorld { + content: Option, + yaml: Option, +} + +#[given("the goreleaser configuration file")] +fn the_goreleaser_file(world: &mut PackagingWorld) { + let text = fs::read_to_string(".goreleaser.yaml").expect("read goreleaser"); + world.content = Some(text); +} + +#[when("it is parsed as YAML")] +fn parse_yaml(world: &mut PackagingWorld) { + let text = world.content.take().expect("file loaded"); + world.yaml = Some(serde_yaml::from_str(&text).expect("parse yaml")); +} + +#[then("the nfpms section exists")] +fn nfpms_exists(world: &mut PackagingWorld) { + let yaml = world.yaml.as_ref().expect("yaml parsed"); + assert!(yaml.get("nfpms").is_some(), "missing nfpms section"); +} + +#[given("the systemd unit file")] +fn the_systemd_file(world: &mut PackagingWorld) { + let text = fs::read_to_string("packaging/linux/comenqd.service").expect("read service"); + world.content = Some(text); +} + +#[then("it includes hardening directives")] +fn includes_hardening(world: &mut PackagingWorld) { + let text = world.content.take().expect("service loaded"); + assert!(text.contains("ProtectSystem=strict")); + assert!(text.contains("PrivateTmp=true")); + assert!(text.contains("NoNewPrivileges=true")); +}