diff --git a/AGENTS.md b/AGENTS.md index 8e39494..7132d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ - Every tool a CI job runs must be in its `with.tools` or declared by the repository with `project-tools: true`. - `mise use --global` and `mise upgrade --global` edit live files; reconcile those changes back into `home/`. - Linux hosts apply this source too: limit macOS-only targets with `.chezmoiignore` or `.chezmoi.os` blocks. +- Workstation Compose definitions live under `.config/services`; Homelab owns Mercury’s boot configuration. Mise service tasks start already-applied definitions. +- Preserve Connect’s existing rootful Podman secret and volume on Linux; keep the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. A runtime migration must not recreate credentials or data. - Keep application-managed hooks, caches, history, account state and generated host inventories outside this repository. - Preserve application destination paths and unrelated local state when changing source attributes or templates. - Use chezmoi's normal conflict handling for apply; do not force routine updates. diff --git a/README.md b/README.md index 299bddb..54860c0 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,8 @@ modify templates to preserve unrelated application settings and local state. MCP endpoints are defined in `home/.chezmoidata.toml`. Bifrost keys are stored in the 1Password Automation vault and referenced through `home/dot_config/fnox/config.toml`. Fnox and the 1Password CLI read vaults -through the machine's 1Password Connect server: on macOS from -[local services](https://github.com/maxexcloo/local-services), on Linux from -[Homelab](https://github.com/maxexcloo/homelab). Save its token as `token` beside +through the machine's 1Password Connect server, managed here with the +`services:connect` Mise task. Save its token as `token` beside the Connect credentials file. `chezmoi apply` writes each agent's Bifrost key into its private configuration. Keep key values out of Git and command output. @@ -35,6 +34,8 @@ then install the global tools it declares: mise exec chezmoi@latest -- chezmoi --source "$PWD" apply "$HOME/.config/mise" mise install mise run setup +chezmoi apply "$HOME/.config/services" +mise run services:connect mise run check chezmoi status chezmoi apply @@ -50,6 +51,20 @@ and run the same commands. Changes to live files aren't imported automatically. `mise use --global` and `mise upgrade --global` edit the live Mise configuration, so copy those edits back into `home/`. +## Workstation Services + +Compose definitions live in `~/.config/services`. Apply them with chezmoi before +using the small `services:*` Mise tasks to start a service. macOS uses Docker +Compose through OrbStack; Mercury uses rootful `podman-compose` so Connect keeps +its existing Podman volume and secret. Homelab owns Mercury’s boot service. + +Mercury’s switch from Quadlets to Compose requires an approved handover: stop the +old Connect pod, start the Compose stack, then verify credential-backed access and +retained data before removing the old Quadlet files. The Compose definition uses +the existing volume and secret as external resources; do not recreate them or run +both owners at once. Keep the old definitions available until that verification. +The macOS project, data volume, credential mount and OrbStack endpoint stay the same. + ## Agent Launch Launch Claude Code, Codex, OpenCode and Pi directly. In Orca, reset each agent's diff --git a/home/.chezmoidata.toml b/home/.chezmoidata.toml index 15514d2..2b19424 100644 --- a/home/.chezmoidata.toml +++ b/home/.chezmoidata.toml @@ -24,3 +24,7 @@ url = "https://bifrost.excloo.com/mcp/reactive-resume" [mcp.unifi_network] url = "https://bifrost.excloo.com/mcp/unifi-network" + +[services.connect] +# renovate: datasource=docker depName=1password/connect-api +version = "1.8.4" diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml new file mode 100644 index 0000000..1578b37 --- /dev/null +++ b/home/dot_config/mise/tasks/services.toml @@ -0,0 +1,9 @@ +["services:connect"] +description = "Start 1Password Connect" +run = ''' +{% if os() == "macos" %} +docker compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach +{% else %} +sudo podman-compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach +{% endif %} +''' diff --git a/home/dot_config/services/onepassword-connect/compose.yaml.tmpl b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl new file mode 100644 index 0000000..2cf0e6a --- /dev/null +++ b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl @@ -0,0 +1,44 @@ +name: onepassword-connect +services: + connect-api: + image: 1password/connect-api:{{ .services.connect.version }} + restart: unless-stopped +{{- if eq .chezmoi.os "linux" }} + ports: + - "127.0.0.1:8080:8080" + secrets: &connect-secrets + - source: onepassword-connect-credentials + target: /home/opuser/.op/1password-credentials.json + uid: "999" + mode: "0400" +{{- end }} + volumes: &connect-volumes + - data:/home/opuser/.op/data +{{- if ne .chezmoi.os "linux" }} + - type: bind + read_only: true + source: ${XDG_CONFIG_HOME:-${HOME}/.config}/op/connect/1password-credentials.json + target: /home/opuser/.op/1password-credentials.json + bind: + create_host_path: false +{{- end }} + connect-sync: + image: 1password/connect-sync:{{ .services.connect.version }} + restart: unless-stopped +{{- if eq .chezmoi.os "linux" }} + environment: + OP_HTTP_PORT: "8081" + secrets: *connect-secrets +{{- end }} + volumes: *connect-volumes +volumes: +{{- if eq .chezmoi.os "linux" }} + data: + external: true + name: onepassword-connect +secrets: + onepassword-connect-credentials: + external: true +{{- else }} + data: {} +{{- end }} diff --git a/tests/services.test.mjs b/tests/services.test.mjs new file mode 100644 index 0000000..82d2240 --- /dev/null +++ b/tests/services.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const base = ["--source", root, "--config", "/dev/null", "--config-format", "toml"]; +const execute = (args, input = "") => + execFileSync("chezmoi", [...base, ...args], { input, encoding: "utf8" }); +const render = (path, platform = "darwin") => + execute([ + "--override-data", + JSON.stringify({ chezmoi: { os: platform } }), + "execute-template", + "--file", + `home/${path}`, + ]); +const yaml = (input) => + JSON.parse( + execute( + ["execute-template", "--with-stdin", "{{ .chezmoi.stdin | fromYaml | toJson }}"], + input, + ), + ); +const serviceRoot = "dot_config/services/onepassword-connect"; + +test("Service configuration can bootstrap into a fresh home before credential templates", () => { + const temp = mkdtempSync(join(tmpdir(), "dotfiles-services-")); + try { + for (const platform of ["linux", "darwin"]) { + const destination = join(temp, platform); + mkdirSync(join(destination, ".config"), { recursive: true }); + execFileSync( + "chezmoi", + [ + ...base, + "--destination", + destination, + "--persistent-state", + join(destination, "state.boltdb"), + "--cache", + join(temp, "cache"), + "--override-data", + JSON.stringify({ chezmoi: { os: platform } }), + "apply", + join(destination, ".config/services"), + ], + { env: { ...process.env, PATH: `${root}tests/bin:${process.env.PATH}` } }, + ); + const file = "compose.yaml"; + assert.ok(existsSync(join(destination, ".config/services/onepassword-connect", file))); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); + +test("Connect keeps its macOS project, named volume and read-only credential mount", () => { + const config = yaml(render(`${serviceRoot}/compose.yaml.tmpl`)); + assert.equal(config.name, "onepassword-connect"); + assert.deepEqual(config.volumes, { data: {} }); + for (const name of ["connect-api", "connect-sync"]) { + const service = config.services[name]; + assert.equal(service.restart, "unless-stopped"); + assert.equal(service.volumes[0], "data:/home/opuser/.op/data"); + assert.deepEqual(service.volumes[1], { + type: "bind", + read_only: true, + source: "${XDG_CONFIG_HOME:-${HOME}/.config}/op/connect/1password-credentials.json", + target: "/home/opuser/.op/1password-credentials.json", + bind: { create_host_path: false }, + }); + } +}); + +test("Linux Compose reuses Connect’s rootful external volume and secret", () => { + const config = yaml(render(`${serviceRoot}/compose.yaml.tmpl`, "linux")); + assert.equal(config.name, "onepassword-connect"); + assert.deepEqual(config.volumes, { data: { external: true, name: "onepassword-connect" } }); + assert.deepEqual(config.secrets, { "onepassword-connect-credentials": { external: true } }); + assert.deepEqual(config.services["connect-api"].ports, ["127.0.0.1:8080:8080"]); + for (const name of ["connect-api", "connect-sync"]) { + assert.deepEqual(config.services[name].volumes, ["data:/home/opuser/.op/data"]); + assert.deepEqual(config.services[name].secrets, [ + { + source: "onepassword-connect-credentials", + target: "/home/opuser/.op/1password-credentials.json", + uid: "999", + mode: "0400", + }, + ]); + } +});