From c8b8021335257eb75dc3e8352880b9fc8341a212 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 02:44:54 +0900 Subject: [PATCH 1/8] Add CI with lxd --- .github/workflows/ci.yml | 28 ++ src/installer/cloud-init.yml | 10 + src/installer/config.toml | 66 +++++ src/installer/install.py | 538 +++++++++++++++++++++++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/installer/cloud-init.yml create mode 100644 src/installer/config.toml create mode 100644 src/installer/install.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75a5fff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: null +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: canonical/setup-lxd@v0.1.3 + - name: Launch LXD instance + run: | + lxc launch images:ubuntu/24.04 ci \ + --config security.nesting=true \ + --config cloud-init.user-data="$(cat src/installer/cloud-init.yml)" + --quiet + lxc exec ci -- cloud-init status --wait + lxc file push \ + src/installer/config.toml \ + src/installer/install.py \ + ci/usr/local/libexec/dotfiles/ \ + --create-dirs + - name: Smoke test + run: | + lxc exec ci -- su - ubuntu -c '/usr/bin/python3 /usr/local/libexec/dotfiles/install.py' diff --git a/src/installer/cloud-init.yml b/src/installer/cloud-init.yml new file mode 100644 index 0000000..863a1a4 --- /dev/null +++ b/src/installer/cloud-init.yml @@ -0,0 +1,10 @@ +#cloud-config +package_upgrade: true +packages: + - python3 + - python3-attr + - python3-cattr + - python3-rich + - sudo +runcmd: + - ["/bin/sh", "-c", "curl -fL https://get.docker.com | sh"] diff --git a/src/installer/config.toml b/src/installer/config.toml new file mode 100644 index 0000000..5b57a66 --- /dev/null +++ b/src/installer/config.toml @@ -0,0 +1,66 @@ +[apt_get] +packages = [ + "build-essential", +] + +[snap] +snaps = [ + { name = "aws-cli", options = [ "--classic" ] }, + { name = "google-cloud-sdk", options = [ "--classic" ] }, +] + +[mise] +core = [ + "go", + "node", + "rust", +] +tools = [ + "aqua:BurntSushi/ripgrep", + "aqua:astral-sh/uv", + "aqua:cli/cli", + "aqua:ducaale/xh", + "aqua:eza-community/eza", + "aqua:jdx/usage", + "aqua:jqlang/jq", + "aqua:lsd-rs/lsd", + "aqua:mikefarah/yq", + "aqua:sharkdp/bat", + "aqua:sharkdp/fd", + "aqua:starship/starship", + "npm:@github/copilot", + "npm:@anthropic-ai/claude-code", + "npm:@google/gemini-cli", + "npm:@dotenvx/dotenvx", + "npm:@openai/codex", +] + +[uv_python] +versions = [ + "3.10", + "3.11", + "3.12", + "3.13", + "3.13t", + "3.14", + "3.14t", +] + +[uv_tool] +packages = [ + "hatch", + "httpie", + { name = "openhands", options = [ "--python=3.12" ] }, + "pdm", + "poetry", + { name = "posting", options = [ "--python=3.13" ] }, +] + +[docker] +images = [ + "docker.io/library/buildpack-deps:trixie", + "docker.io/library/buildpack-deps:noble", + "mcr.microsoft.com/devcontainers/base:trixie", + "mcr.microsoft.com/devcontainers/base:noble", + "mcr.microsoft.com/devcontainers/universal:2-linux", +] diff --git a/src/installer/install.py b/src/installer/install.py new file mode 100644 index 0000000..82cebe9 --- /dev/null +++ b/src/installer/install.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import os.path +import platform +import shlex +import shutil +import subprocess +import tarfile +import tomllib +import urllib.parse +import urllib.request +from collections.abc import Sequence +from enum import Enum, auto +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Any + +import attrs +import cattrs +from rich.console import Console + +if TYPE_CHECKING: + from collections.abc import Mapping + from http.client import HTTPResponse + + from _typeshed import StrOrBytesPath + + +class Hooks: + def __init__(self) -> None: + self._base_converter = cattrs.Converter() + + def argument_hook(self, value: Any, type: type[Argument], /) -> Argument: + match value: + case Argument(): + return value + case str(): + return Argument(name=value, options=[]) + case _: + return self._base_converter.structure(value, Argument) + + +def register_structure_hook(converter: cattrs.Converter, /) -> None: + hooks = Hooks() + converter.register_structure_hook(Argument, hooks.argument_hook) + + +@attrs.define(frozen=True, kw_only=True) +class Argument: + name: str + options: Sequence[str] + + +@attrs.define(frozen=True, kw_only=True) +class AptGetConfig: + packages: Sequence[str] + + +@attrs.define(frozen=True, kw_only=True) +class SnapConfig: + snaps: Sequence[Argument] + + +@attrs.define(frozen=True, kw_only=True) +class MiseConfig: + core: Sequence[str] + tools: Sequence[str] + + +@attrs.define(frozen=True, kw_only=True) +class UVPythonConfig: + versions: Sequence[str] + + +@attrs.define(frozen=True, kw_only=True) +class UVToolConfig: + packages: Sequence[Argument] + + +@attrs.define(frozen=True, kw_only=True) +class DockerConfig: + images: Sequence[str] + + +@attrs.define(frozen=True, kw_only=True) +class ConfigRoot: + apt_get: AptGetConfig + snap: SnapConfig + mise: MiseConfig + uv_python: UVPythonConfig + uv_tool: UVToolConfig + docker: DockerConfig + + +def parse_config(data: object, converter: cattrs.Converter, /) -> ConfigRoot: + return converter.structure(data, ConfigRoot) + + +def load_config(path: StrOrBytesPath, /) -> ConfigRoot: + converter = cattrs.Converter() + register_structure_hook(converter) + with open(path, "rb") as stream: + parsed = tomllib.load(stream) + return parse_config(parsed, converter) + + +def sudo(arg: str, /, *args: str) -> list[str]: + return [ + "sudo", + "--", + arg, + *args, + ] + + +class AptGet: + @staticmethod + def update() -> list[str]: + return [ + "apt-get", + "update", + ] + + @staticmethod + def install(package: str, /, *packages: str) -> list[str]: + return [ + "apt-get", + "--no-install-recommends", + "-y", + "install", + "--", + package, + *packages, + ] + + @staticmethod + def dist_upgrade() -> list[str]: + return [ + "apt-get", + "-y", + "dist-upgrade", + ] + + @staticmethod + def autopurge() -> list[str]: + return [ + "apt-get", + "-y", + "autopurge", + ] + + @staticmethod + def autoclean() -> list[str]: + return [ + "apt-get", + "-y", + "autoclean", + ] + + +class Snap: + @staticmethod + def install(snap: str, /, *, options: Sequence[str]) -> list[str]: + return [ + "snap", + "install", + *options, + "--", + snap, + ] + + @staticmethod + def refresh() -> list[str]: + return [ + "snap", + "refresh", + ] + + +class Mise: + @staticmethod + def self_update() -> list[str]: + return [ + "mise", + "self-update", + "-y", + ] + + @staticmethod + def use(tool: str, /, *tools: str) -> list[str]: + return [ + "mise", + "use", + "-g", + "--", + tool, + *tools, + ] + + @staticmethod + def upgrade() -> list[str]: + return [ + "mise", + "upgrade", + "--bump", + ] + + +class UV: + @staticmethod + def python_install(version: str, /, *versions: str) -> list[str]: + return [ + "uv", + "python", + "install", + "--", + version, + *versions, + ] + + @staticmethod + def python_upgrade() -> list[str]: + return [ + "uv", + "python", + "upgrade", + ] + + @staticmethod + def tool_install(tool: str, /, *, options: Sequence[str]) -> list[str]: + return [ + "uv", + "tool", + "install", + *options, + "--", + tool, + ] + + @staticmethod + def tool_upgrade() -> list[str]: + return [ + "uv", + "tool", + "upgrade", + "--all", + ] + + +class Docker: + @staticmethod + def pull(image: str, /) -> list[str]: + return [ + "docker", + "pull", + "--", + image, + ] + + +class InstallResult(Enum): + INSTALLED = auto() + ALREADY_INSTALLED = auto() + + +def fetch_github_latest_tag(org: str, repo: str, /) -> str: + path = PurePosixPath("/", org, repo, "releases", "latest") + split_result = urllib.parse.SplitResult( + "https", + "github.com", + path.as_posix(), + "", + "", + ) + release_url = split_result.geturl() + + response: HTTPResponse = urllib.request.urlopen(release_url) + response.close() + + split_result = urllib.parse.urlsplit(response.url) + path = PurePosixPath(split_result.path) + + match path.parts: + case [ + str() as _root, + str() as _org, + str() as _repo, + "releases", + "tag", + str() as tag, + ]: + pass + case _: + raise ValueError(path) + + return tag + + +class SpecificInstaller: + @staticmethod + def mise() -> InstallResult: + if shutil.which("mise") is not None: + return InstallResult.ALREADY_INSTALLED + + org, repo = "jdx", "mise" + tag = fetch_github_latest_tag(org, repo) + + uname = platform.uname() + match uname.system: + case "Linux": + system = "linux" + case _: + raise ValueError(uname.system) + match uname.machine: + case "x86_64": + machine = "x64" + case _: + raise ValueError(uname.machine) + path = PurePosixPath( + "/", + org, + repo, + "releases", + "download", + tag, + f"mise-{tag}-{system}-{machine}.tar.gz", + ) + split_result = urllib.parse.SplitResult( + "https", + "github.com", + path.as_posix(), + "", + "", + ) + tarball_url = split_result.geturl() + + with TemporaryDirectory() as tmpdir: + with ( + urllib.request.urlopen(tarball_url) as response, + tarfile.open(fileobj=response, mode="r|gz") as tar, + ): + tar.extractall(tmpdir, filter="data") + + mise_bin = Path(tmpdir).joinpath("mise", "bin", "mise") + + home = Path.home() + user_bin = home.joinpath(".local", "bin") + user_bin.mkdir + + shutil.move(mise_bin, user_bin) + + return InstallResult.INSTALLED + + +def ensure_clean_path() -> str: + home = Path.home() + local_bin = home.joinpath(".local", "bin") + local_bin.mkdir(parents=True, exist_ok=True) + + mise_shims = home.joinpath(".local", "share", "mise", "shims") + + path = os.path.pathsep.join( + [ + str(mise_shims), + str(local_bin), + os.path.defpath, + ] + ) + return path + + +def check_call( + console: Console, + environ: Mapping[str, str], + cmd: Sequence[StrOrBytesPath], + /, +) -> int: + console.rule(shlex.join(cmd)) + console.print() + returncode = subprocess.run(cmd, env=environ, check=True).returncode + console.print() + return returncode + + +def execute_apt( + console: Console, + environ: Mapping[str, str], + apt_get_config: AptGetConfig, + /, +) -> None: + check_call( + console, + environ, + sudo(*AptGet.update()), + ) + + check_call( + console, + environ, + sudo(*AptGet.install(*apt_get_config.packages)), + ) + + check_call( + console, + environ, + sudo(*AptGet.dist_upgrade()), + ) + check_call( + console, + environ, + sudo(*AptGet.autopurge()), + ) + check_call( + console, + environ, + sudo(*AptGet.autoclean()), + ) + + +def execute_snap( + console: Console, + environ: Mapping[str, str], + snap_config: SnapConfig, + /, +) -> None: + for snap in snap_config.snaps: + check_call( + console, + environ, + sudo(*Snap.install(snap.name, options=snap.options)), + ) + + check_call( + console, + environ, + sudo(*Snap.refresh()), + ) + + +def execute_mise( + console: Console, + environ: Mapping[str, str], + mise_config: MiseConfig, + /, +) -> None: + console.log("Ensuring Mise is installed...") + result = SpecificInstaller.mise() + + if result == InstallResult.ALREADY_INSTALLED: + check_call( + console, + environ, + Mise.self_update(), + ) + + check_call( + console, + environ, + Mise.use(*mise_config.core), + ) + check_call( + console, + environ, + Mise.use(*mise_config.tools), + ) + + check_call( + console, + environ, + Mise.upgrade(), + ) + + +def execute_uv( + console: Console, + environ: Mapping[str, str], + uv_python_config: UVPythonConfig, + uv_tool_config: UVToolConfig, + /, +) -> None: + check_call( + console, + environ, + UV.python_install(*uv_python_config.versions), + ) + + check_call( + console, + environ, + UV.python_upgrade(), + ) + + for package in uv_tool_config.packages: + check_call( + console, + environ, + UV.tool_install(package.name, options=package.options), + ) + + check_call( + console, + environ, + UV.tool_upgrade(), + ) + + +def execute_docker( + console: Console, + environ: Mapping[str, str], + docker_config: DockerConfig, + /, +) -> None: + for image in docker_config.images: + check_call( + console, + environ, + Docker.pull(image), + ) + + +def main() -> None: + console = Console() + + path = ensure_clean_path() + environ = {"PATH": path, "HOME": str(Path.home())} + + config_path = Path(__file__).with_name("config.toml") + config = load_config(config_path) + + execute_apt(console, environ, config.apt_get) + # execute_snap(console, environ, config.snap) + execute_mise(console, environ, config.mise) + execute_uv(console, environ, config.uv_python, config.uv_tool) + # execute_docker(console, environ, config.docker) + + +if __name__ == "__main__": + main() From 843a274759cf7846f2eb27f739fcc568389e9010 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 02:50:00 +0900 Subject: [PATCH 2/8] Fix image name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75a5fff..8fb410e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: canonical/setup-lxd@v0.1.3 - name: Launch LXD instance run: | - lxc launch images:ubuntu/24.04 ci \ + lxc launch ubuntu:24.04 ci \ --config security.nesting=true \ --config cloud-init.user-data="$(cat src/installer/cloud-init.yml)" --quiet From 18e39f94f783b9e70de67b8e31eb8dda3a0ad685 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 02:52:14 +0900 Subject: [PATCH 3/8] Fix line breaks with backslashes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fb410e..dd797f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: run: | lxc launch ubuntu:24.04 ci \ --config security.nesting=true \ - --config cloud-init.user-data="$(cat src/installer/cloud-init.yml)" + --config cloud-init.user-data="$(cat src/installer/cloud-init.yml)" \ --quiet lxc exec ci -- cloud-init status --wait lxc file push \ From 9a577fe7d4ba315f9470f31a1db5b5930d1edd26 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 02:57:52 +0900 Subject: [PATCH 4/8] Fix permissions --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd797f9..32f510d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: src/installer/install.py \ ci/usr/local/libexec/dotfiles/ \ --create-dirs + --mode 0644 - name: Smoke test run: | lxc exec ci -- su - ubuntu -c '/usr/bin/python3 /usr/local/libexec/dotfiles/install.py' From 0385cdbe3aaeb72daa6294ccdcaf8facb0ba3473 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 02:59:50 +0900 Subject: [PATCH 5/8] Fix line breaks with backslashes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32f510d..fe8e389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: src/installer/config.toml \ src/installer/install.py \ ci/usr/local/libexec/dotfiles/ \ - --create-dirs + --create-dirs \ --mode 0644 - name: Smoke test run: | From 85ea9311440b81bc532c3bd555af3a2e05c10d8d Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 03:05:59 +0900 Subject: [PATCH 6/8] Fix file push permissions in CI workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe8e389..198f948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: src/installer/install.py \ ci/usr/local/libexec/dotfiles/ \ --create-dirs \ - --mode 0644 + --uid 1000 --gid 1000 - name: Smoke test run: | lxc exec ci -- su - ubuntu -c '/usr/bin/python3 /usr/local/libexec/dotfiles/install.py' From 8fd708dd18f58bb77cd50e452b7edf63ee6163ac Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 03:34:34 +0900 Subject: [PATCH 7/8] Enable execution of Snap and Docker commands --- src/installer/install.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/installer/install.py b/src/installer/install.py index 82cebe9..09b27da 100644 --- a/src/installer/install.py +++ b/src/installer/install.py @@ -528,10 +528,10 @@ def main() -> None: config = load_config(config_path) execute_apt(console, environ, config.apt_get) - # execute_snap(console, environ, config.snap) + execute_snap(console, environ, config.snap) execute_mise(console, environ, config.mise) execute_uv(console, environ, config.uv_python, config.uv_tool) - # execute_docker(console, environ, config.docker) + execute_docker(console, environ, config.docker) if __name__ == "__main__": From b3393827b5bb8cc39a7e05a686e4accad7a9501d Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Sat, 1 Nov 2025 03:40:28 +0900 Subject: [PATCH 8/8] Add usermod command to add ubuntu user to docker group --- src/installer/cloud-init.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/installer/cloud-init.yml b/src/installer/cloud-init.yml index 863a1a4..a2cf9b3 100644 --- a/src/installer/cloud-init.yml +++ b/src/installer/cloud-init.yml @@ -8,3 +8,4 @@ packages: - sudo runcmd: - ["/bin/sh", "-c", "curl -fL https://get.docker.com | sh"] + - ["usermod", "-aG", "docker", "ubuntu"]