diff --git a/ansible/inventory/laptops.ini b/ansible/inventory/laptops.ini new file mode 100644 index 0000000..9e320b7 --- /dev/null +++ b/ansible/inventory/laptops.ini @@ -0,0 +1,27 @@ +[laptop_anchor] +# 24/7 anchor agents — lowest idle wattage, reliable adapters +timmy-anchor-a ansible_host=TIMMY_ANCHOR_A_IP ansible_user=timmy + +[laptop_daylight] +# Daylight compute nodes — peak solar hours only +timmy-daylight-a ansible_host=TIMMY_DAYLIGHT_A_IP ansible_user=timmy +timmy-daylight-b ansible_host=TIMMY_DAYLIGHT_B_IP ansible_user=timmy + +[laptop_pending] +# Machines awaiting hardware repair before production duty +timmy-daylight-c ansible_host=TIMMY_DAYLIGHT_C_IP ansible_user=timmy + +[desktop_nas] +# Heavy compute + 4TB SSD NAS — daylight only due to power draw +timmy-desktop-nas ansible_host=TIMMY_DESKTOP_NAS_IP ansible_user=timmy + +[laptops:children] +laptop_anchor +laptop_daylight +laptop_pending +desktop_nas + +[laptops:vars] +ansible_python_interpreter=/usr/bin/python3 +timmy_home=/home/timmy/timmy +timmy_repo=https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home.git diff --git a/ansible/playbooks/deploy_laptop_fleet.yml b/ansible/playbooks/deploy_laptop_fleet.yml new file mode 100644 index 0000000..52176f2 --- /dev/null +++ b/ansible/playbooks/deploy_laptop_fleet.yml @@ -0,0 +1,137 @@ +--- +- name: Deploy Hermes agent fleet on available laptops + hosts: laptops + gather_facts: true + vars: + timmy_user: "{{ ansible_user }}" + timmy_dir: "/home/{{ timmy_user }}/timmy" + hermes_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home.git" + hermes_agent_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git" + + tasks: + - name: Ensure required packages are installed + ansible.builtin.package: + name: + - git + - python3 + - python3-pip + - python3-venv + - tmux + - curl + - jq + - sqlite3 + state: present + become: true + when: ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + + - name: Ensure timmy directory exists + ansible.builtin.file: + path: "{{ timmy_dir }}" + state: directory + mode: "0755" + + - name: Clone timmy-home repository + ansible.builtin.git: + repo: "{{ hermes_repo }}" + dest: "{{ timmy_dir }}/timmy-home" + version: main + depth: 1 + + - name: Clone hermes-agent repository + ansible.builtin.git: + repo: "{{ hermes_agent_repo }}" + dest: "{{ timmy_dir }}/hermes-agent" + version: main + depth: 1 + + - name: Create Python virtual environment + ansible.builtin.command: + cmd: "python3 -m venv {{ timmy_dir }}/venv" + creates: "{{ timmy_dir }}/venv/bin/python" + + - name: Install Python dependencies + ansible.builtin.pip: + name: + - requests + - pyyaml + virtualenv: "{{ timmy_dir }}/venv" + + - name: Ensure systemd user directory exists + ansible.builtin.file: + path: "{{ ansible_env.HOME | default('/home/' + timmy_user) }}/.config/systemd/user" + state: directory + mode: "0755" + when: ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + + - name: Deploy anchor agent systemd user service + ansible.builtin.template: + src: "../../configs/hermes-laptop-anchor.service" + dest: "{{ ansible_env.HOME | default('/home/' + timmy_user) }}/.config/systemd/user/hermes-laptop-anchor.service" + mode: "0644" + when: + - inventory_hostname in groups['laptop_anchor'] + - ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + notify: Reload user systemd + + - name: Deploy daylight agent systemd user service + ansible.builtin.template: + src: "../../configs/hermes-laptop-daylight.service" + dest: "{{ ansible_env.HOME | default('/home/' + timmy_user) }}/.config/systemd/user/hermes-laptop-daylight.service" + mode: "0644" + when: + - inventory_hostname in groups['laptop_daylight'] + - ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + notify: Reload user systemd + + - name: Deploy daylight agent systemd timer + ansible.builtin.template: + src: "../../configs/hermes-laptop-daylight.timer" + dest: "{{ ansible_env.HOME | default('/home/' + timmy_user) }}/.config/systemd/user/hermes-laptop-daylight.timer" + mode: "0644" + when: + - inventory_hostname in groups['laptop_daylight'] + - ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + notify: Reload user systemd + + - name: Enable and start anchor agent service + ansible.builtin.systemd: + name: hermes-laptop-anchor.service + state: started + enabled: true + scope: user + when: + - inventory_hostname in groups['laptop_anchor'] + - ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + + - name: Enable daylight agent timer + ansible.builtin.systemd: + name: hermes-laptop-daylight.timer + state: started + enabled: true + scope: user + when: + - inventory_hostname in groups['laptop_daylight'] + - ansible_os_family in ['Debian', 'RedHat', 'Archlinux'] + + - name: Create fleet status script + ansible.builtin.copy: + dest: "{{ timmy_dir }}/scripts/status.sh" + content: | + #!/bin/bash + echo "=== {{ inventory_hostname }} Status ===" + echo "" + echo "Services:" + systemctl --user is-active hermes-laptop-anchor.service 2>/dev/null && echo " anchor: RUNNING" || true + systemctl --user is-active hermes-laptop-daylight.service 2>/dev/null && echo " daylight: RUNNING" || true + echo "" + echo "Disk Usage:" + df -h $HOME | tail -1 + echo "" + echo "Memory:" + free -h 2>/dev/null | grep Mem || vm_stat 2>/dev/null | head -5 + mode: "0755" + + handlers: + - name: Reload user systemd + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true diff --git a/configs/hermes-laptop-anchor.service b/configs/hermes-laptop-anchor.service new file mode 100644 index 0000000..2c2dd93 --- /dev/null +++ b/configs/hermes-laptop-anchor.service @@ -0,0 +1,15 @@ +[Unit] +Description=Hermes Laptop Anchor Agent (24/7) +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/timmy/hermes-agent +ExecStart=%h/timmy/venv/bin/python %h/timmy/hermes-agent/run_agent.py +Restart=always +RestartSec=30 +Environment="HOME=%h" +Environment="HERMES_HOME=%h/.hermes" + +[Install] +WantedBy=default.target diff --git a/configs/hermes-laptop-daylight.service b/configs/hermes-laptop-daylight.service new file mode 100644 index 0000000..81f16bd --- /dev/null +++ b/configs/hermes-laptop-daylight.service @@ -0,0 +1,16 @@ +[Unit] +Description=Hermes Laptop Daylight Agent +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/timmy/hermes-agent +ExecStart=%h/timmy/venv/bin/python %h/timmy/hermes-agent/run_agent.py +Restart=on-failure +RestartSec=30 +RuntimeMaxSec=6h +Environment="HOME=%h" +Environment="HERMES_HOME=%h/.hermes" + +[Install] +WantedBy=default.target diff --git a/configs/hermes-laptop-daylight.timer b/configs/hermes-laptop-daylight.timer new file mode 100644 index 0000000..989ce35 --- /dev/null +++ b/configs/hermes-laptop-daylight.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run Hermes daylight agent during peak solar hours + +[Timer] +OnCalendar=*-*-* 10:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/configs/laptop-fleet-manifest.yaml b/configs/laptop-fleet-manifest.yaml new file mode 100644 index 0000000..5429af0 --- /dev/null +++ b/configs/laptop-fleet-manifest.yaml @@ -0,0 +1,67 @@ +# LAB-005: Laptop Fleet Manifest +# Production manifest for the 6-machine Timmy Foundation laptop fleet. +# Edit this file when hardware changes, then regenerate the deployment plan: +# python3 scripts/plan_laptop_fleet.py configs/laptop-fleet-manifest.yaml --markdown > docs/LAB-005-laptop-fleet-deployment.md + +fleet_name: timmy-laptop-fleet +machines: + - hostname: timmy-anchor-a + machine_type: laptop + ram_gb: 16 + cpu_cores: 8 + os: macOS + adapter_condition: good + idle_watts: 11 + always_on_capable: true + notes: candidate 24/7 anchor agent + + - hostname: timmy-anchor-b + machine_type: laptop + ram_gb: 8 + cpu_cores: 4 + os: Linux + adapter_condition: good + idle_watts: 13 + always_on_capable: true + notes: candidate 24/7 anchor agent + + - hostname: timmy-daylight-a + machine_type: laptop + ram_gb: 32 + cpu_cores: 10 + os: macOS + adapter_condition: ok + idle_watts: 22 + always_on_capable: true + notes: higher-performance daylight compute + + - hostname: timmy-daylight-b + machine_type: laptop + ram_gb: 16 + cpu_cores: 8 + os: Linux + adapter_condition: ok + idle_watts: 19 + always_on_capable: true + notes: daylight compute node + + - hostname: timmy-daylight-c + machine_type: laptop + ram_gb: 8 + cpu_cores: 4 + os: Windows + adapter_condition: needs_replacement + idle_watts: 17 + always_on_capable: false + notes: repair power adapter before production duty + + - hostname: timmy-desktop-nas + machine_type: desktop + ram_gb: 64 + cpu_cores: 12 + os: Linux + adapter_condition: good + idle_watts: 58 + always_on_capable: false + has_4tb_ssd: true + notes: desktop plus 4TB SSD NAS and heavy compute during peak sun diff --git a/docs/LAB-005-laptop-fleet-deployment.md b/docs/LAB-005-laptop-fleet-deployment.md new file mode 100644 index 0000000..8e1ea18 --- /dev/null +++ b/docs/LAB-005-laptop-fleet-deployment.md @@ -0,0 +1,30 @@ +# Laptop Fleet Deployment Plan + +Fleet: timmy-laptop-fleet +Machine count: 6 +24/7 anchor agents: timmy-anchor-a, timmy-anchor-b +Desktop/NAS: timmy-desktop-nas +Daylight schedule: 10:00-16:00 + +## Role mapping + +| Hostname | Role | Schedule | Duty cycle | +|---|---|---|---| +| timmy-anchor-a | anchor_agent | 24/7 | continuous | +| timmy-anchor-b | anchor_agent | 24/7 | continuous | +| timmy-daylight-a | daylight_agent | 10:00-16:00 | peak_solar | +| timmy-daylight-b | daylight_agent | 10:00-16:00 | peak_solar | +| timmy-daylight-c | daylight_agent | 10:00-16:00 | peak_solar | +| timmy-desktop-nas | desktop_nas | 10:00-16:00 | daylight_only | + +## Machine inventory + +| Hostname | Type | RAM | CPU cores | OS | Adapter | Idle watts | Notes | +|---|---|---:|---:|---|---|---:|---| +| timmy-anchor-a | laptop | 16 | 8 | macOS | good | 11 | candidate 24/7 anchor agent | +| timmy-anchor-b | laptop | 8 | 4 | Linux | good | 13 | candidate 24/7 anchor agent | +| timmy-daylight-a | laptop | 32 | 10 | macOS | ok | 22 | higher-performance daylight compute | +| timmy-daylight-b | laptop | 16 | 8 | Linux | ok | 19 | daylight compute node | +| timmy-daylight-c | laptop | 8 | 4 | Windows | needs_replacement | 17 | repair power adapter before production duty | +| timmy-desktop-nas | desktop | 64 | 12 | Linux | good | 58 | desktop plus 4TB SSD NAS and heavy compute during peak sun | + diff --git a/tests/test_laptop_fleet_planner.py b/tests/test_laptop_fleet_planner.py index 7833527..7f2faa7 100644 --- a/tests/test_laptop_fleet_planner.py +++ b/tests/test_laptop_fleet_planner.py @@ -50,3 +50,43 @@ def test_manifest_template_is_valid_yaml() -> None: data = yaml.safe_load(Path("docs/laptop-fleet-manifest.example.yaml").read_text()) assert data["fleet_name"] == "timmy-laptop-fleet" assert len(data["machines"]) == 6 + + +def test_production_manifest_exists_and_is_valid() -> None: + assert Path("configs/laptop-fleet-manifest.yaml").exists() + data = yaml.safe_load(Path("configs/laptop-fleet-manifest.yaml").read_text()) + assert data["fleet_name"] == "timmy-laptop-fleet" + assert len(data["machines"]) == 6 + plan = build_plan(data) + assert plan["desktop_nas"] == "timmy-desktop-nas" + assert len(plan["anchor_agents"]) == 2 + + +def test_deployment_plan_generated() -> None: + assert Path("docs/LAB-005-laptop-fleet-deployment.md").exists() + content = Path("docs/LAB-005-laptop-fleet-deployment.md").read_text() + assert "24/7 anchor agents: timmy-anchor-a, timmy-anchor-b" in content + assert "Daylight schedule: 10:00-16:00" in content + assert "desktop_nas" in content + + +def test_ansible_playbook_exists() -> None: + assert Path("ansible/playbooks/deploy_laptop_fleet.yml").exists() + + +def test_ansible_laptop_inventory_exists() -> None: + assert Path("ansible/inventory/laptops.ini").exists() + content = Path("ansible/inventory/laptops.ini").read_text() + assert "[laptop_anchor]" in content + assert "[laptop_daylight]" in content + assert "[desktop_nas]" in content + + +def test_systemd_service_templates_exist() -> None: + assert Path("configs/hermes-laptop-anchor.service").exists() + assert Path("configs/hermes-laptop-daylight.service").exists() + assert Path("configs/hermes-laptop-daylight.timer").exists() + anchor = Path("configs/hermes-laptop-anchor.service").read_text() + daylight = Path("configs/hermes-laptop-daylight.service").read_text() + assert "Restart=always" in anchor + assert "RuntimeMaxSec=6h" in daylight