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
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"Bash(git push *)",
"Bash(gh run *)",
"Bash(python3 -c ' *)",
"Bash(bash -n test/vm.sh)"
"Bash(bash -n test/vm.sh)",
"Bash(git worktree *)",
"Bash(git --no-pager diff --stat)",
"Bash(python3 -c \"import yaml,sys; yaml.safe_load\\(open\\('config.example.yaml'\\)\\); print\\('config.example.yaml: YAML OK'\\)\")"
]
}
}
4 changes: 2 additions & 2 deletions .github/workflows/e2e-disks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
# A full install-to-completion is deliberately NOT run here: a bare container has
# no booted systemd, so archinstall's install phase fails on udev/D-Bus
# (timedatectl, systemctl) assumptions it makes about a live ISO. Real end-to-end
# installs run in the QEMU VM harness instead (test/vm.sh / `make vm`), which
# installs run in the QEMU VM harness instead (test/vm.sh / `task vm`), which
# boots a real systemd. `test/e2e/disks.sh --mode full` still works for manual
# runs on a real host/VM (`make e2e-disks-full`); it's just not container CI.
# runs on a real host/VM (`task e2e-disks-full`); it's just not container CI.
name: e2e-disks

on:
Expand Down
40 changes: 0 additions & 40 deletions Makefile

This file was deleted.

747 changes: 597 additions & 150 deletions README.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Convenience targets. The Go workflow stays `go build/test/vet`; these wrap the
# Tier 2 loopback integration harness, which needs root (losetup/lvm/archinstall).
#
# Tooling is managed by mise (see mise.toml): `mise install` provisions Go + task,
# then `task <name>` or `mise run <name>` drives the workflow.
version: '3'

vars:
# e2e disk-layout knobs, overridable on the CLI:
# task e2e-disks-light LAYOUT=single-disk-lvm FS=ext4
LAYOUT: '{{.LAYOUT | default "multi-disk-lvm"}}'
FS: '{{.FS | default "xfs"}}'

tasks:
build:
desc: Build the archwright binary.
cmds:
- go build -o archwright .

test:
desc: Run unit tests (validation table + per-stage command plans).
cmds:
- go test ./...

vet:
desc: Run go vet.
cmds:
- go vet ./...

e2e-disks-light:
desc: >-
Validate archwright's rendered archinstall JSON against a real archinstall
(--dry-run) on loop devices. No install, no network.
cmds:
- sudo bash test/e2e/disks.sh --mode light --layout {{.LAYOUT}} --fs {{.FS}}

e2e-disks-full:
desc: >-
Real partition/format/pacstrap onto loop devices, then assert layout.
Slow; needs network + disk space.
cmds:
- sudo bash test/e2e/disks.sh --mode full --layout {{.LAYOUT}} --fs {{.FS}} --disk1-size 12G --extra-size 6G

vm:
desc: Interactive QEMU smoke test — boot the Arch live ISO (run Phase A).
cmds:
- bash test/vm.sh iso

vm-fresh:
desc: Boot the Arch live ISO, wiping the virtual disks first.
cmds:
- bash test/vm.sh iso --fresh

vm-disk:
desc: Boot the installed system off disk 1.
cmds:
- bash test/vm.sh disk
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,19 @@ setup:
# Example of a non-clone installer (runs in place, between/after clones):
# - command: curl -sS https://starship.rs/install.sh | sh -s -- -y

# --- services (optional) ----------------------------------------------------
# systemd units to `systemctl enable` last (after dotfiles + setup) so they
# start on the next boot. `enable` is system units (enabled as root); `user` is
# per-user units (enabled with `systemctl --user`). The .service suffix is
# optional. Units are enabled, not started underneath the current session — the
# common case is a login/display-manager unit that takes over after reboot.
# services:
# enable:
# - plasmalogin.service # SDDM/Plasma login on next boot
# - bluetooth.service
# user:
# - syncthing.service

# --- hooks (optional) -------------------------------------------------------
# Run your own commands at lifecycle points instead of writing a Go stage — the
# general escape hatch for snap/cargo/gsettings/etc. `at` is one of the global
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ type Config struct {

Setup SetupConfig `yaml:"setup"`

// Services lists systemd units to enable in Phase B so they start on the next
// boot (e.g. a display-manager unit like plasmalogin.service). Enabling is
// idempotent, so the stage is safe to re-run. Unset = no services touched.
Services ServicesConfig `yaml:"services"`

Hooks []Hook `yaml:"hooks" validate:"dive"`

// Bootloader selects which bootloader Phase A installs and Phase B configures.
Expand Down Expand Up @@ -270,6 +275,17 @@ type Clone struct {
Update bool `yaml:"update"`
}

// ServicesConfig drives the Phase B 90-services stage, which runs last (after
// dotfiles + setup) and `systemctl enable`s the listed units so they start on
// the next boot. Enable holds system units (enabled as root); User holds
// per-user units (enabled with `systemctl --user`, unprivileged). Both default
// to empty, so an unset block touches no services. The trailing ".service" is
// optional — systemctl accepts a bare unit name.
type ServicesConfig struct {
Enable []string `yaml:"enable"` // system units: systemctl enable <unit>...
User []string `yaml:"user"` // user units: systemctl --user enable <unit>...
}

// Hook is a user-defined command run at a named lifecycle point. Exactly one of
// Run (an inline shell snippet) or Script (a path to a script file) is set.
// Script and Dir have a leading `~` expanded to the user's home at run time.
Expand Down
4 changes: 2 additions & 2 deletions internal/stages/fromto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ func TestWithin(t *testing.T) {
{
name: "from only by name is inclusive lower bound",
from: "flatpak", to: "",
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"},
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"},
},
{
name: "from only by number resolves same as name",
from: "30", to: "",
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"},
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"},
},
{
name: "to only by name is inclusive upper bound",
Expand Down
43 changes: 43 additions & 0 deletions internal/stages/services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package stages

import "github.com/AdamJHall/archwright/internal/ui"

// services is the Phase B 90-services stage: enable the configured systemd units
// so they start on the next boot. It runs last (after dotfiles + setup) so any
// unit a dotfiles/setup step installs is already present to be enabled.
//
// System units are enabled as root (`systemctl enable`); user units are enabled
// unprivileged (`systemctl --user enable`). Enabling is idempotent, so the stage
// is safe to re-run. We deliberately don't `--now`-start them: the typical case
// is a login/display-manager unit (e.g. plasmalogin.service) that should take
// over on the next boot, not be started underneath the current session.
type services struct{}

func init() { register(services{}) }

func (services) Order() int { return 90 }
func (services) Name() string { return "services" }
func (services) Phase() Phase { return Bootstrap }

func (services) Run(ctx *Context) error {
sys := ctx.Cfg.Services.Enable
usr := ctx.Cfg.Services.User
if len(sys) == 0 && len(usr) == 0 {
ui.Warn("no services in config — skipping")
return nil
}

if len(sys) > 0 {
if err := ctx.R.Root("systemctl", append([]string{"enable"}, sys...)...); err != nil {
return err
}
}
if len(usr) > 0 {
if err := ctx.R.Cmd("systemctl", append([]string{"--user", "enable"}, usr...)...); err != nil {
return err
}
}

ui.OK("services enabled")
return nil
}
47 changes: 47 additions & 0 deletions internal/stages/services_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package stages

import (
"strings"
"testing"
)

// The services stage (90) enables system units as root and user units with
// `systemctl --user`, so they start on the next boot. These assert the recorded
// dry-run plan against an inline config.

func TestServices_SystemAndUser(t *testing.T) {
plan := planForCfg(t, Bootstrap, "services", `
services:
enable:
- plasmalogin.service
- bluetooth.service
user:
- syncthing.service
`)
mustContain(t, plan,
// system units enabled in one privileged systemctl call (Sudo in Phase B)
"sudo systemctl enable plasmalogin.service bluetooth.service",
// user units enabled unprivileged via --user
"systemctl --user enable syncthing.service",
)
}

func TestServices_EmptySkips(t *testing.T) {
// No services block: the stage is a clean no-op — nothing planned.
plan := planForCfg(t, Bootstrap, "services", "{}\n")
if joined := strings.Join(plan, "\n"); strings.Contains(joined, "systemctl") {
t.Errorf("no systemctl call expected when services is unset; plan:\n%s", joined)
}
}

func TestServices_SystemOnly(t *testing.T) {
// Only system units set: no stray `systemctl --user` call.
plan := planForCfg(t, Bootstrap, "services", `
services:
enable: [docker.service]
`)
mustContain(t, plan, "sudo systemctl enable docker.service")
if joined := strings.Join(plan, "\n"); strings.Contains(joined, "--user") {
t.Errorf("no --user call expected when only system units set; plan:\n%s", joined)
}
}
4 changes: 2 additions & 2 deletions internal/stages/stages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ func TestRegistry(t *testing.T) {
[]string{"preflight", "archinstall"},
[]int{0, 10})
check(Bootstrap,
[]string{"yay", "packages", "snapper", "flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"},
[]int{10, 20, 25, 30, 40, 50, 60, 70, 80, 85})
[]string{"yay", "packages", "snapper", "flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"},
[]int{10, 20, 25, 30, 40, 50, 60, 70, 80, 85, 90})
}

func TestPlan_Archinstall(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Tooling for archwright development. `mise install` provisions everything;
# `mise run <task>` (or `task <task>`) drives the workflow. Go stays the build
# tool — these wrap it plus the root-only loopback/QEMU integration harness.
[tools]
go = "1.26"
task = "latest"
Loading