From aff435a0fb3a56b1d7b738c7dede363440884b3a Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Thu, 17 Sep 2026 15:05:36 +0100 Subject: Add the Canonical laptop profile and setup workflows --- scripts/__init__.py | 0 scripts/canonical-system.sh | 18 ++++++ scripts/canonical.py | 137 ++++++++++++++++++++++++++++++++++++++++++ scripts/canonical_profiles.py | 74 +++++++++++++++++++++++ scripts/maintenance-lib.sh | 38 ++++++++++++ scripts/nix-daemon-update.sh | 12 ++++ 6 files changed, 279 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/canonical-system.sh create mode 100644 scripts/canonical.py create mode 100644 scripts/canonical_profiles.py create mode 100644 scripts/maintenance-lib.sh create mode 100644 scripts/nix-daemon-update.sh (limited to 'scripts') diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/canonical-system.sh b/scripts/canonical-system.sh new file mode 100644 index 0000000..e6f0825 --- /dev/null +++ b/scripts/canonical-system.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +source just-lib.sh +[[ $(_machine_role) == canonical ]] +[[ $( + # shellcheck disable=SC1091 + . /etc/os-release + echo "$ID" +) == ubuntu ]] +# Parse before replacing the installed profile. +sudo apparmor_parser --skip-kernel-load --skip-cache canonical/apparmor/dotfiles-nix +sudo install -m 644 canonical/apparmor/dotfiles-nix /etc/apparmor.d/dotfiles-nix +sudo apparmor_parser --replace /etc/apparmor.d/dotfiles-nix +sudo snap connect thunderbird:gpg-keys +systemctl --user daemon-reload +systemctl --user enable --now gpg-agent.socket gpg-agent-ssh.socket podman.socket +echo 'Existing GPG agent processes keep their executable until the next login.' diff --git a/scripts/canonical.py b/scripts/canonical.py new file mode 100644 index 0000000..2ff6310 --- /dev/null +++ b/scripts/canonical.py @@ -0,0 +1,137 @@ +"""Install declared corporate packages without removing existing packages.""" + +import argparse +import json +import os +import platform +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def packages(source: str) -> list[str]: + return [ + line.strip() + for line in (ROOT / "meta/canonical" / f"{source}.txt").read_text().splitlines() + if line.strip() and not line.startswith("#") + ] + + +def snap_install_commands() -> list[list[str]]: + return [ + ["sudo", "snap", "install", name, "--channel=stable"] + + (["--classic"] if name == "ghostty" else []) + for name in packages("snap") + ] + + +def flatpak_install_commands() -> list[list[str]]: + return [ + [ + "flatpak", + "remote-add", + "--user", + "--if-not-exists", + "flathub", + "https://flathub.org/repo/flathub.flatpakrepo", + ], + [ + "flatpak", + "install", + "--user", + "--assumeyes", + "flathub", + *packages("flatpak"), + ], + ] + + +def update_commands() -> list[list[str]]: + # An untargeted refresh respects Snap holds. Explicit targets override them. + return [ + ["sudo", "apt-get", "update"], + ["sudo", "apt-get", "upgrade"], + ["sudo", "snap", "refresh"], + ] + + +def require_canonical() -> None: + data = json.loads( + subprocess.check_output(["chezmoi", "data", "-S", str(ROOT)], text=True) + ) + if data.get("machineRole") != "canonical": + raise SystemExit("This command requires machineRole=canonical.") + if platform.freedesktop_os_release().get("ID") != "ubuntu": + raise SystemExit("This command requires Ubuntu.") + + +def install() -> None: + subprocess.run(["sudo", "apt-get", "update"], check=True) + subprocess.run(["sudo", "apt-get", "install", *packages("apt")], check=True) + for command in snap_install_commands(): + if subprocess.run( + ["snap", "list", command[3]], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode: + subprocess.run(command, check=True) + for command in flatpak_install_commands(): + subprocess.run(command, check=True) + + +def check() -> None: + commands = [ + ["lsb_release", "-ds"], + *[ + ["systemctl", "is-active", unit] + for unit in ["display-manager", "snapd", "apparmor", "nix-daemon"] + ], + ["landscape-config", "--actively-registered"], + ["snap", "connections", "thunderbird"], + ["snap", "list", *packages("snap")], + ["gnome-extensions", "list", "--enabled"], + ["flatpak", "list", "--user", "--app"], + *[["flatpak", "info", "--user", app] for app in packages("flatpak")], + ["getent", "passwd", str(os.getuid())], + ["getsubids", os.environ.get("USER", "")], + ["getsubids", "-g", os.environ.get("USER", "")], + ] + failed = False + for command in commands: + print("\n> " + " ".join(command), flush=True) + try: + failed |= subprocess.run(command, check=False).returncode != 0 + except FileNotFoundError: + print(f"Missing: {command[0]}") + failed = True + if failed: + raise SystemExit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "action", choices=["install", "update", "flatpak-update", "extensions", "check"] + ) + args = parser.parse_args() + require_canonical() + if args.action == "install": + install() + elif args.action == "check": + check() + else: + commands = update_commands() + if args.action == "flatpak-update": + commands = [ + ["flatpak", "update", "--user", "--assumeyes", *packages("flatpak")] + ] + elif args.action == "extensions": + commands = [["gext", "install", *packages("extensions")]] + for command in commands: + subprocess.run(command, check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/canonical_profiles.py b/scripts/canonical_profiles.py new file mode 100644 index 0000000..4c12085 --- /dev/null +++ b/scripts/canonical_profiles.py @@ -0,0 +1,74 @@ +"""Add owned preferences to existing Snap browser profiles.""" + +import configparser +import json +import shutil +from pathlib import Path + +from scripts.canonical import ROOT, require_canonical + +START = "// dotfiles: begin" +END = "// dotfiles: end" + + +def preferences(existing: str, owned: str) -> str: + if START in existing: + before, remainder = existing.split(START, 1) + if END not in remainder: + raise ValueError("Incomplete dotfiles preference block") + _, after = remainder.split(END, 1) + existing = before.rstrip() + after + return existing.rstrip() + "\n" + START + "\n" + owned.rstrip() + "\n" + END + "\n" + + +def profiles(root: Path) -> list[Path]: + ini = configparser.ConfigParser(interpolation=None) + ini.read(root / "profiles.ini") + result = [] + for section in ini.sections(): + if not section.startswith("Profile") or "Path" not in ini[section]: + continue + path = Path(ini[section]["Path"]) + if ini[section].get("IsRelative", "1") == "1": + path = root / path + if path.resolve().is_relative_to(root.resolve()) and path.is_dir(): + result.append(path) + return result + + +def deploy(root: Path, source: Path) -> None: + found = profiles(root) + if not found: + print(f"No profile under {root}. Start the app once, close it, then retry.") + for profile in found: + target = profile / "user.js" + existing = target.read_text() if target.exists() else "" + updated = preferences(existing, source.read_text()) + if updated == existing: + continue + if target.exists() and not target.with_suffix(".js.pre-dotfiles").exists(): + shutil.copy2(target, target.with_suffix(".js.pre-dotfiles")) + target.write_text(updated) + print(f"Updated {target}") + + +def main() -> None: + require_canonical() + home = Path.home() + deploy(home / "snap/firefox/common/.mozilla/firefox", ROOT / "canonical/firefox.js") + deploy( + home / "snap/thunderbird/common/.thunderbird", ROOT / "canonical/thunderbird.js" + ) + source = ( + home + / ".nix-profile/lib/mozilla/native-messaging-hosts/external_editor_revived.json" + ) + manifest = json.loads(source.read_text()) + target = home / ".mozilla/native-messaging-hosts/external_editor_revived.json" + target.parent.mkdir(parents=True, exist_ok=True) + manifest["path"] = str(home / ".nix-profile/bin/external-editor-revived") + target.write_text(json.dumps(manifest, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/maintenance-lib.sh b/scripts/maintenance-lib.sh new file mode 100644 index 0000000..33e7e07 --- /dev/null +++ b/scripts/maintenance-lib.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Set args and maintenance_run for one domain of a maintenance request. +# shellcheck disable=SC2034 +_maintenance_select() { + local scope=$1 domain=$2 role raw target + shift 2 + role=$(_machine_role) || return 1 + args=() + maintenance_run=false + if [ "$scope" = host ]; then + _require_host || return 1 + args=("$@") + maintenance_run=true + return + fi + for raw in "$@"; do + case "$raw" in + /etc/* | etc/*) + [ "$role" = host ] || { + echo 'error: /etc paths require the host role' >&2 + return 1 + } + target=etc + ;; + */*) target=home ;; + *) + echo "error: expected a file path: $raw" >&2 + return 1 + ;; + esac + [ "$target" != "$domain" ] || args+=("$raw") + done + if [ ${#args[@]} -gt 0 ] || { [ $# -eq 0 ] && { [ "$domain" = home ] || [ "$role" = host ]; }; }; then + maintenance_run=true + fi + return 0 +} diff --git a/scripts/nix-daemon-update.sh b/scripts/nix-daemon-update.sh new file mode 100644 index 0000000..2b2d12c --- /dev/null +++ b/scripts/nix-daemon-update.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ -e /nix/nix-installer || -e /etc/nix/nix.custom.conf ]] || dpkg-query -W nix-bin >/dev/null 2>&1; then + echo 'error: this recipe is only for the upstream multi-user installer' >&2 + exit 1 +fi +nix=/nix/var/nix/profiles/default/bin/nix +[[ -x $nix ]] +sudo "$nix" --extra-experimental-features nix-command upgrade-nix --profile /nix/var/nix/profiles/default +sudo systemctl daemon-reload +sudo systemctl restart nix-daemon.service +"$nix" --version -- cgit v1.3.1