Skip to content
Closed
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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: pip install -e '.[dev]'

- name: Lint (ruff)
run: ruff check edcloud/ tests/

- name: Type check (mypy)
run: mypy edcloud/

- name: Tests (pytest)
run: pytest --tb=short -q
8 changes: 4 additions & 4 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -133,23 +133,23 @@
"filename": "tests/test_ec2.py",
"hashed_secret": "059a14a1755ad2889811498f0e9011790ef01488",
"is_verified": false,
"line_number": 153
"line_number": 159
},
{
"type": "Secret Keyword",
"filename": "tests/test_ec2.py",
"hashed_secret": "b8d67157d88dc68b2822821147842da5ee55cc05",
"is_verified": false,
"line_number": 154
"line_number": 160
},
{
"type": "Secret Keyword",
"filename": "tests/test_ec2.py",
"hashed_secret": "cc9c39d66e511682ab3fa0bc3cd2c72e25cc30e6",
"is_verified": false,
"line_number": 155
"line_number": 161
}
]
},
"generated_at": "2026-02-16T17:37:43Z"
"generated_at": "2026-03-25T22:33:11Z"
}
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ grep -RInE "TODO|FIXME|TBD|\[ \]" README.md CHANGELOG.md RUNBOOK.md AGENTS.md do

### Git discipline (LLM + operator)

- Never commit directly to `main`.
- Default: do not commit directly to `main`.
- Exception (personal repos only): direct commits/pushes to `main` are allowed for
small, low-risk changes when the user explicitly requests it in the current task.
If there is any uncertainty or elevated risk, use task branch + PR.
- Create one task branch per change: `agent/<topic>-YYYYMMDD`.
- Local WIP commits are allowed, but do not push noisy history (`wip`, `fix typo`,
machine-specific checkpoints).
Expand Down Expand Up @@ -111,7 +114,7 @@ Run when requested:
```bash
pytest -q
ruff check .
mypy edcloud tests
mypy edcloud/
pre-commit run --all-files
```

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ semantic version tags.

### Recently Completed

- Code quality pass for demo readiness:
- Fixed all mypy strict errors (8 across 4 files) and ruff lint violations; tooling now passes clean.
- Added CI workflow (`.github/workflows/ci.yml`) running pytest, ruff, and mypy on push/PR.
- Removed dead code: unused `auto_snapshot_before_destroy`, vestigial `destroy(force=)` parameter, uncalled `_ec2_resource` wrapper, test-only `tailscale.ssh_command`.
- Collapsed redundant wrapper functions in `ec2.py`, `resource_audit.py`, and `cli.py` that added indirection without logic.
- Made `cleanup.py` UI-agnostic by accepting I/O callbacks instead of importing `click` directly, consistent with `lifecycle.py`.
- Fixed N+1 `describe_volumes` API calls in `ec2.status()` (now a single batched call).
- Single-sourced package version via `importlib.metadata` instead of duplicating in `__init__.py` and `pyproject.toml`.
- Hardened dotfiles bootstrap path in cloud-init:
- Added `DOTFILES_REPO` / `DOTFILES_BRANCH` template variables rendered from `InstanceConfig`.
- Added CLI options/env support on `provision` and `reprovision` (`--dotfiles-repo`, `--dotfiles-branch`, `EDCLOUD_DOTFILES_REPO`, `EDCLOUD_DOTFILES_BRANCH`).
- Implemented repo/branch input validation in `edcloud.ec2` to reduce template-injection risk.
- Updated cloud-init logic to resolve dotfiles source with fallback order (`gh` user URL, then persisted local origin for `auto`) and continue bootstrap on non-fatal sync failures.
- Updated tests (`tests/test_ec2.py`, `tests/test_cli.py`) and docs (`README.md`, `RUNBOOK.md`, `docs/ARCHITECTURE.md`) to reflect new behavior.
- Wired Dropbox FUSE mount via rclone: rclone config stored as SecureString at `/edcloud/rclone_config` in SSM; cloud-init fetches it on every rebuild and enables `rclone-dropbox.service` (user systemd, `~/Dropbox` mount); `RCLONE_CONFIG_SSM_PARAMETER` added to `config.py`.
- Added oldspeak MCP bootstrap integration while keeping app code in a separate repo: cloud-init now best-effort syncs `~/src/oldspeak` (via `gh` auth path), bootstraps a local venv/install + spaCy model, and installs local wrappers (`~/.local/bin/oldspeak-mcp-stdio`, `~/.local/bin/oldspeak-mcp-http`) for on-host Cline/Claude Code usage. Docs updated in README, RUNBOOK, and ARCHITECTURE.

Expand Down
25 changes: 12 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,6 @@ Console.

## Public collaboration expectations

- Open issues for bugs, reliability gaps, or unclear operator docs.
- Keep changes scoped and reviewable; prefer focused PRs over broad rewrites.
- Preserve the core guardrails: Tailscale-only access, managed-tag discovery,
and SSM-backed secret handling.
- For lifecycle-risking changes, include validation notes (for example:
`pre-commit run --all-files`, `pytest -q`).

**Core design:**

- Tailscale-only access (zero inbound rules)
Expand Down Expand Up @@ -160,12 +153,18 @@ LazyVim compatibility:

**Baseline:** Docker, Portainer, Node.js, Python, and dev tooling are defined in `cloud-init/user-data.yaml`.

Bootstrap repo sync (when `gh` auth is available on the instance):

- `https://github.com/<gh-user>/dotfiles.git` → `~/src/dotfiles`
- `https://github.com/<gh-user>/bin.git` → `~/src/bin`
- `https://github.com/<gh-user>/llm-config.git` → `~/src/llm-config`
- `https://github.com/<gh-user>/oldspeak.git` → `~/src/oldspeak`
Bootstrap repo sync:

- Dotfiles are always attempted first via cloud-init using configurable inputs:
- `--dotfiles-repo` / `EDCLOUD_DOTFILES_REPO` (`auto` default)
- `--dotfiles-branch` / `EDCLOUD_DOTFILES_BRANCH` (`main` default)
- `--dotfiles-repo auto` resolution order:
1. `https://github.com/<gh-user>/dotfiles.git` when `gh auth` is available
2. existing `~/src/dotfiles` origin URL (if present on persisted home)
- Additional non-secret repos still sync from GitHub user namespace when `gh` auth is available:
- `https://github.com/<gh-user>/bin.git` → `~/src/bin`
- `https://github.com/<gh-user>/llm-config.git` → `~/src/llm-config`
- `https://github.com/<gh-user>/oldspeak.git` → `~/src/oldspeak`

For local MCP usage on edcloud, cloud-init also installs best-effort wrappers:

Expand Down
11 changes: 8 additions & 3 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This file is the stable operator procedure guide.
Open items:

- [x] Add a safe rebuild workflow (`snapshot -> reprovision -> verify`) as a single documented operator path. (`edc reprovision` now prints a post-run reminder to run `edc verify`.)
- [ ] Improve automatic repo loading: currently dotfiles/bin/llm-config/oldspeak cloning depends on gh auth during cloud-init; consider making repo list configurable and/or adding explicit clone step to provision workflow (e.g., `edc provision --sync-repos`).
- [x] Improve automatic repo loading: dotfiles bootstrap now supports explicit CLI/env configuration (`--dotfiles-repo`, `--dotfiles-branch`) with `auto` fallback to gh user or existing local origin; non-dotfiles repo sync remains gh-auth-driven.
- [ ] Evaluate a secure operator login workflow that starts from one memorized string without weakening Tailscale/AWS MFA controls.
- [ ] Centralize default SSH username in repo config (for example `edcloud/config.py`) and have `edc ssh`/`edc verify` read that value.
- [ ] Keep snapshot spend under soft cap `$2/month`; adjust DLM retention (`edc backup-policy apply --daily-keep N --weekly-keep M --monthly-keep K`) if exceeded.
Expand Down Expand Up @@ -631,8 +631,13 @@ Operating policy:

Non-secret repo sync baseline:

- If `gh` is authenticated during cloud-init, bootstrap attempts to pull/update:
- `https://github.com/<gh-user>/dotfiles.git` → `~/src/dotfiles`
- Dotfiles sync is configurable at provision/reprovision time:
- `--dotfiles-repo` / `EDCLOUD_DOTFILES_REPO` (`auto` default)
- `--dotfiles-branch` / `EDCLOUD_DOTFILES_BRANCH` (`main` default)
- For `--dotfiles-repo auto`, cloud-init resolves in order:
1. `https://github.com/<gh-user>/dotfiles.git` when `gh` auth is available
2. existing `~/src/dotfiles` origin URL from persistent home state
- Additional repos still require `gh` auth and are synced from the authenticated user namespace:
- `https://github.com/<gh-user>/bin.git` → `~/src/bin`
- `https://github.com/<gh-user>/llm-config.git` → `~/src/llm-config`
- `https://github.com/<gh-user>/oldspeak.git` → `~/src/oldspeak`
Expand Down
105 changes: 82 additions & 23 deletions cloud-init/user-data.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#cloud-config
# edcloud instance bootstrap
# Variables: ${TAILSCALE_AUTH_KEY_SSM_PARAMETER}, ${TAILSCALE_HOSTNAME}, ${AWS_REGION}
# Variables: ${TAILSCALE_AUTH_KEY_SSM_PARAMETER}, ${TAILSCALE_HOSTNAME}, ${AWS_REGION}, ${DOTFILES_REPO}, ${DOTFILES_BRANCH}

package_update: true
package_upgrade: true
Expand Down Expand Up @@ -459,6 +459,15 @@ runcmd:
BREWENV
chmod 0644 /etc/profile.d/edcloud-brew.sh

# Install default Homebrew packages
runuser -u ubuntu -- bash -lc '
set -euo pipefail
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
brew install lazygit || true
fi
'

runuser -u ubuntu -- bash -lc '
set -euo pipefail
export HOME="${HOME:-/home/ubuntu}"
Expand Down Expand Up @@ -544,39 +553,80 @@ runcmd:
runuser -u ubuntu -- bash -lc '
set -euo pipefail

if ! command -v gh &>/dev/null; then
exit 0
fi
mkdir -p "$HOME/src" "$HOME/.local/bin"

if ! gh auth status &>/dev/null; then
echo "ℹ️ gh not authenticated; skipping dotfiles/bin/llm-config sync"
exit 0
fi
DOTFILES_REPO_INPUT="${DOTFILES_REPO}"
DOTFILES_BRANCH_INPUT="${DOTFILES_BRANCH}"

GH_USER=$(gh api user --jq .login 2>/dev/null || true)
if [ -z "$GH_USER" ]; then
echo "ℹ️ could not resolve GitHub username; skipping repo sync"
exit 0
GH_USER=""
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
GH_USER=$(gh api user --jq .login 2>/dev/null || true)
fi

mkdir -p "$HOME/src" "$HOME/.local/bin"
sync_git_repo() {
local repo_url="$1"
local target="$2"
local branch="$3"

sync_repo() {
local name="$1"
local repo_url="https://github.com/${GH_USER}/${name}.git"
local target="$HOME/src/${name}"
if [ -z "$repo_url" ]; then
return 1
fi

if [ -d "$target/.git" ]; then
git -C "$target" pull --ff-only || true
git -C "$target" remote set-url origin "$repo_url" || true
if git -C "$target" fetch --depth=1 origin "$branch"; then
git -C "$target" checkout -B "$branch" "origin/$branch" || true
else
git -C "$target" pull --ff-only || true
fi
else
git clone "$repo_url" "$target" || true
if ! git clone --depth=1 --branch "$branch" "$repo_url" "$target"; then
git clone "$repo_url" "$target" || return 1
git -C "$target" checkout "$branch" || true
fi
fi
}

sync_repo dotfiles
sync_repo bin
sync_repo llm-config
sync_repo oldspeak
sync_named_repo_for_gh_user() {
local name="$1"
local target="$HOME/src/$name"

if [ -z "$GH_USER" ]; then
return 0
fi

local repo_url="https://github.com/${GH_USER}/${name}.git"
sync_git_repo "$repo_url" "$target" "main" || true
}

DOTFILES_REPO_URL=""
if [ "$DOTFILES_REPO_INPUT" = "auto" ]; then
if [ -n "$GH_USER" ]; then
DOTFILES_REPO_URL="https://github.com/${GH_USER}/dotfiles.git"
elif [ -d "$HOME/src/dotfiles/.git" ]; then
DOTFILES_REPO_URL=$(git -C "$HOME/src/dotfiles" remote get-url origin 2>/dev/null || true)
fi
else
DOTFILES_REPO_URL="$DOTFILES_REPO_INPUT"
fi

if [ -n "$DOTFILES_REPO_URL" ]; then
if sync_git_repo "$DOTFILES_REPO_URL" "$HOME/src/dotfiles" "$DOTFILES_BRANCH_INPUT"; then
echo "✅ dotfiles synced from ${DOTFILES_REPO_URL} (${DOTFILES_BRANCH_INPUT})"
else
echo "⚠️ dotfiles sync failed for ${DOTFILES_REPO_URL}; continuing bootstrap"
fi
else
echo "ℹ️ dotfiles repo not resolved (DOTFILES_REPO=auto without gh auth or existing clone); skipping dotfiles sync"
fi

if [ -n "$GH_USER" ]; then
sync_named_repo_for_gh_user bin
sync_named_repo_for_gh_user llm-config
sync_named_repo_for_gh_user oldspeak
else
echo "ℹ️ gh not authenticated; skipping bin/llm-config/oldspeak sync"
fi

# Link bashrc from dotfiles (includes git-prompt PS1)
if [ -x "$HOME/src/dotfiles/install.sh" ]; then
Expand Down Expand Up @@ -669,6 +719,15 @@ HTTP_EOF
- systemctl daemon-reload
- systemctl enable --now edcloud-idle-shutdown.timer

# --- browsh (text-based browser, requires Firefox) ---
- |
BROWSH_VERSION="1.8.2"
BROWSH_DEB="/tmp/browsh_${BROWSH_VERSION}_linux_amd64.deb"
curl -fsSL "https://github.com/browsh-org/browsh/releases/download/v${BROWSH_VERSION}/browsh_${BROWSH_VERSION}_linux_amd64.deb" \
-o "$BROWSH_DEB"
dpkg -i "$BROWSH_DEB"
rm -f "$BROWSH_DEB"

# --- Signal completion ---
- echo "edcloud bootstrap complete $(date -Iseconds)" > /tmp/edcloud-ready

Expand Down
9 changes: 5 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ edcloud/
├── verify_catalog.py # Declarative `edc verify` check catalog
├── resource_queries.py # Shared managed-resource query/filter helpers
├── ec2.py # EC2 lifecycle core (provision/start/stop/status/destroy/resize)
├── snapshot.py # Snapshot create/list/prune + auto pre-destroy snapshots
├── snapshot.py # Snapshot create/list/prune
├── backup_policy.py # AWS DLM backup policy management
├── cleanup.py # Tailscale + orphaned volume cleanup workflow
├── tailscale.py # Tailscale discovery/conflict/SSH helpers
├── cleanup.py # Tailscale + orphaned volume cleanup (UI-agnostic)
├── tailscale.py # Tailscale discovery/conflict helpers
├── iam.py # IAM role/profile setup + teardown
├── resource_audit.py # Drift + cost audit
├── aws_clients.py # Shared boto3 session/client factories
Expand All @@ -28,6 +28,7 @@ edcloud/
- **Declarative checks:** verification checks live in `verify_catalog.py`, not inline command code.
- **Shared query primitives:** managed-resource filter/query composition lives in `resource_queries.py`.
- **Tag-based source of truth:** no local state file; AWS tags define ownership and discovery.
- **UI-agnostic library modules:** only `cli.py` depends on `click`. Library modules accept I/O callbacks when they need user interaction.

## Architecture decisions (ADR summary)

Expand All @@ -37,7 +38,7 @@ edcloud/
- **Durable state volume + disposable root:** host runtime is replaceable; durable data lives under `/opt/edcloud/state`.
- **CLI-managed snapshot queue:** a single flat pool capped at 3 snapshots, enforced by the CLI. Every snapshot trigger runs `prune(3) → snapshot → prune(3)` so drift self-heals within one cycle. Triggers: `edc up` (on-start, fire-and-forget), `edc provision`/`edc reprovision`/`edc destroy` (blocking, pre-destructive-op). DLM (`backup-policy`) remains available but is not wired automatically.
- **SSM-backed runtime secrets:** secrets stay out of git and host bootstrap payloads. The instance IAM role grants `ssm:GetParameter` on `/edcloud/*`. Three parameters are consumed automatically by cloud-init: `tailscale_auth_key` (required), `github_token` (optional, authenticates `gh`), and `rclone_config` (optional, writes rclone config and enables the Dropbox FUSE mount).
- **Separate app/infrastructure repos:** application MCP code (for example `oldspeak`) remains in its own repository (`~/src/oldspeak` on-host). edcloud bootstraps checkout/update and local wrappers (`oldspeak-mcp-stdio`, `oldspeak-mcp-http`) without vendoring app code into this infra repo.
- **Separate app/infrastructure repos:** application MCP code (for example `oldspeak`) remains in its own repository (`~/src/oldspeak` on-host). edcloud bootstraps checkout/update and local wrappers (`oldspeak-mcp-stdio`, `oldspeak-mcp-http`) without vendoring app code into this infra repo. Dotfiles bootstrap is configurable via `InstanceConfig.dotfiles_repo` / `dotfiles_branch` and rendered into cloud-init at provision time.
- **Cloud-init as baseline contract:** reproducible host/tooling baseline is codified in `cloud-init/user-data.yaml`.
- **CLI-first operations model:** commands must remain safe/repeatable from lightweight ARM/Linux operator nodes.

Expand Down
4 changes: 3 additions & 1 deletion edcloud/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""edcloud — Personal cloud lab CLI."""

__version__ = "0.1.0"
from importlib.metadata import version

__version__ = version("edcloud")
4 changes: 2 additions & 2 deletions edcloud/aws_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ def aws_region() -> str | None:

def aws_client(service_name: str) -> Any:
"""Return a boto3 client for ``service_name``."""
return aws_session().client(service_name)
return aws_session().client(service_name) # type: ignore[call-overload]


def aws_resource(service_name: str) -> Any:
"""Return a boto3 resource for ``service_name``."""
return aws_session().resource(service_name)
return aws_session().resource(service_name) # type: ignore[call-overload]


def ec2_client() -> Any:
Expand Down
2 changes: 1 addition & 1 deletion edcloud/backup_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def _find_policy_summary() -> dict[str, Any] | None:
resp = dlm.get_lifecycle_policies()
for policy in resp.get("Policies", []):
if policy.get("Description") == DLM_LIFECYCLE_POLICY_NAME:
return policy
return dict(policy)
return None


Expand Down
Loading
Loading