init
This commit is contained in:
commit
0adeb39f88
0
saturn/.codex
Normal file
0
saturn/.codex
Normal file
2
saturn/.gitignore
vendored
Normal file
2
saturn/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.env
|
||||
.aider*
|
||||
10
saturn/.idea/.gitignore
vendored
Normal file
10
saturn/.idea/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
5
saturn/.idea/codeStyles/codeStyleConfig.xml
Normal file
5
saturn/.idea/codeStyles/codeStyleConfig.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
9
saturn/.idea/homelab-codex-ws.iml
Normal file
9
saturn/.idea/homelab-codex-ws.iml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
7
saturn/.idea/misc.xml
Normal file
7
saturn/.idea/misc.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="23" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
saturn/.idea/modules.xml
Normal file
8
saturn/.idea/modules.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/homelab-codex-ws.iml" filepath="$PROJECT_DIR$/.idea/homelab-codex-ws.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
saturn/.idea/vcs.xml
Normal file
6
saturn/.idea/vcs.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
62
saturn/README.md
Normal file
62
saturn/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Homelab Current State
|
||||
|
||||
## Description
|
||||
|
||||
This repository documents the current known state of the homelab.
|
||||
|
||||
The documentation is based only on stated facts. Missing details are recorded as unknowns and need clarification.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Main server hardware: Raspberry Pi 5
|
||||
- Core stack:
|
||||
- Docker
|
||||
- Portainer
|
||||
- Nginx Proxy Manager
|
||||
- Network position: behind NAT
|
||||
- Public access path: Nginx Proxy Manager with HTTPS using Let's Encrypt
|
||||
- Private access path: Tailscale
|
||||
- Known port forwarding:
|
||||
- External ports `80-81` forward to internal ports `4480-4481`
|
||||
- External port `443` forwards to internal port `4443`
|
||||
|
||||
## Documentation index
|
||||
|
||||
- [Hardware](docs/hardware.md)
|
||||
- [Core stack](docs/core-stack.md)
|
||||
- [Networking](docs/networking.md)
|
||||
- [Access](docs/access.md)
|
||||
- [Services](docs/services.md)
|
||||
- [Hetzner VPS](docs/hetzner-vps.md)
|
||||
- [Joplin Server](docs/joplin-server.md)
|
||||
- [Unknowns and clarification questions](docs/questions.md)
|
||||
|
||||
## Known facts
|
||||
|
||||
- The homelab has one known main server: Raspberry Pi 5.
|
||||
- Docker is part of the current stack.
|
||||
- Portainer is part of the current stack.
|
||||
- Nginx Proxy Manager is part of the current stack.
|
||||
- The homelab is behind NAT.
|
||||
- Public services are exposed through Nginx Proxy Manager with HTTPS certificates from Let's Encrypt.
|
||||
- Private access is provided through Tailscale.
|
||||
- A Hetzner VPS handoff has been received from another Codex session.
|
||||
- The Hetzner VPS hostname is `ubuntu-4gb-hel1-1`.
|
||||
- The Hetzner VPS Tailscale IP is `100.95.58.48`.
|
||||
- `100.108.208.3` is explicitly not the Hetzner VPS Tailscale IP.
|
||||
- Nginx Proxy Manager is running on the Hetzner VPS as container `npm`.
|
||||
- Joplin Server files exist on the Hetzner VPS, but Joplin is not running yet.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Operating system and version on the Raspberry Pi 5.
|
||||
- Storage layout and attached disks.
|
||||
- Network interface configuration.
|
||||
- LAN IP addresses.
|
||||
- Public domain names for the Raspberry Pi 5 services.
|
||||
- List of all running containers.
|
||||
- Exact Nginx Proxy Manager proxy host configuration.
|
||||
- Tailscale tailnet, device name, and subnet/exit-node configuration if any.
|
||||
- Backup configuration.
|
||||
- Monitoring and alerting configuration.
|
||||
- Whether the Hetzner VPS is part of the homelab, a separate public edge, or both.
|
||||
BIN
saturn/__pycache__/ollama_client.cpython-313.pyc
Normal file
BIN
saturn/__pycache__/ollama_client.cpython-313.pyc
Normal file
Binary file not shown.
2
saturn/codex_context
Normal file
2
saturn/codex_context
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly.
|
||||
Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly
|
||||
134
saturn/codex_context.yaml
Normal file
134
saturn/codex_context.yaml
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
SESSION_STATE:
|
||||
meta:
|
||||
goal: "Maintain compressed lossless session memory in ./codex_context.yaml"
|
||||
environment:
|
||||
cwd: "/home/oskar/projects/homelab-codex-ws"
|
||||
shell: "zsh"
|
||||
date: "2026-05-03"
|
||||
tz: "Europe/Warsaw"
|
||||
systems:
|
||||
S1:
|
||||
name: "session_state"
|
||||
file: "./codex_context.yaml"
|
||||
format: "YAML"
|
||||
root: "SESSION_STATE"
|
||||
ops:
|
||||
save: "overwrite after every meaningful change/decision"
|
||||
load: "on startup if file exists"
|
||||
export: "print file content only"
|
||||
import: "load user-provided YAML"
|
||||
constraints:
|
||||
- "lossless"
|
||||
- "compressed"
|
||||
- "valid_yaml"
|
||||
- "no_fluff"
|
||||
- "dedupe"
|
||||
- "use_ids"
|
||||
- "never_delete_unless_explicit"
|
||||
- "no_confirm_on_save"
|
||||
S2:
|
||||
name: "saturn_tailscale_llm_check"
|
||||
obs:
|
||||
O1: "SATURN hostname=saturn; ts_ipv4=100.121.168.72."
|
||||
O2: "tailscale status: piha=100.108.208.3 active relay:waw; solaria=100.100.231.104 listed; DNS health warning."
|
||||
O3: "tailscale ping piha: DERP(waw) 230/33/47ms; no direct; exit=1."
|
||||
O4: "tailscale ping solaria: DERP(waw) 223/66/32ms; no direct; exit=1."
|
||||
O5: "direct curl 100.100.231.104:11434/api/tags: run1 http=200 total=0.323280s connect=0.273345s size=690; run2 http=200 total=0.118377s connect=0.064582s size=690."
|
||||
O6: "gateway curl 100.108.208.3:8080/api/tags: run1 exit=7 http=000 total=0.247810s; run2 exit=7 http=000 total=0.063145s."
|
||||
O7: "direct response models: deepseek-coder:latest, deepcoder:14b."
|
||||
configs:
|
||||
CFG1:
|
||||
name: "local_model_gateway"
|
||||
base_url: "http://piha:8080"
|
||||
preflight: "GET /"
|
||||
routes:
|
||||
coding: "/api/code"
|
||||
general: "/api/chat"
|
||||
body:
|
||||
prompt: "<task>"
|
||||
stream: false
|
||||
constraints:
|
||||
- "use_piha_only"
|
||||
- "never_call_solaria_direct"
|
||||
- "never_call_localhost_direct"
|
||||
- "retry_once_on_failure"
|
||||
- "report_endpoint_summary_errors"
|
||||
output:
|
||||
- "endpoint_used"
|
||||
- "result_summary"
|
||||
- "errors"
|
||||
decisions:
|
||||
D1: "No prior codex_context.yaml existed; initialized state file."
|
||||
D2: "User requested commit; include current repo changes: ./codex_context.yaml, ./.gitignore, ./codex_context."
|
||||
D3: "Git commit created with message: Add session context state."
|
||||
D4: "User requested SATURN network verification: Tailscale active, piha/solaria reachable, test direct LLM 100.100.231.104:11434 and gateway 100.108.208.3:8080; no remote modifications."
|
||||
D5: "Created ./start-codex.sh launcher to start Codex with embedded SESSION_STATE policy prompt and auto-load ./codex_context.yaml when present."
|
||||
D6: "Startup 2026-04-21: loaded user-provided SESSION_STATE as authoritative memory; retained prior entries."
|
||||
D7: "Gateway policy set: use http://piha:8080 only; coding->POST /api/code; general->POST /api/chat; preflight GET / before tasks; retry once on failure."
|
||||
D8: "Startup 2026-04-22: loaded provided SESSION_STATE, verified disk state parity, refreshed meta.environment.date, overwrote ./codex_context.yaml."
|
||||
D9: "Created ./ollama_client.py: minimal Python Ollama client using POST http://localhost:11434/api/chat, model=deepseek-coder, stream=false, ask(prompt)->message.content, with inline test call."
|
||||
D10: "Updated ./ollama_client.py for reliability: urlopen timeout=10, try/except guards for HTTPError, URLError, JSONDecodeError, invalid response shape, fallback Exception; errors return 'ERROR: <message>'."
|
||||
D11: "Created ./deploy_agent.py: imports ask from ollama_client; generate_compose(service)->strict YAML-only prompt; propagates 'ERROR:' responses; inline test generate_compose('nginx')."
|
||||
D12: "User requested git commit on 2026-04-22; commit scope includes ./codex_context.yaml, ./ollama_client.py, ./deploy_agent.py, ./start-codex.sh."
|
||||
D13: "Git commit created on 2026-04-22: 4cf42fc 'Add local Ollama automation scripts'."
|
||||
D14: "Updated ./deploy_agent.py: added PyYAML validation, requires top-level services key, retries invalid output up to 2 times with corrective prompt, returns 'ERROR: invalid docker-compose' after exhaustion."
|
||||
D15: "Extended ./deploy_agent.py with deploy_service(service): generates compose, writes ./deployments/<service[-n]>/docker-compose.yml without overwriting existing directories, runs 'docker compose up -d' via subprocess, returns DEPLOYED or ERROR."
|
||||
D16: "Updated ./deploy_agent.py with get_service_status(path), post-deploy 'docker compose ps' verification requiring 'Up', error outputs including ps output when available, and pre-deploy 'docker ps' port-80 check that adds prompt note 'Use a different port than 80'."
|
||||
D17: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for deployment status and safety updates."
|
||||
D18: "Git commit created on 2026-04-22: 0abe9cb 'Improve deploy agent safety checks'."
|
||||
D19: "Updated ./deploy_agent.py to use local LLM for one bounded deployment-failure retry: capture service/error/status, request corrected YAML only, replace docker-compose.yml, retry once, then return final error plus last status if still failing."
|
||||
D20: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for one-shot LLM-assisted deployment failure recovery."
|
||||
D21: "Git commit created on 2026-04-22: 185a866 'Add LLM-assisted deploy retry'."
|
||||
D22: "Updated ./deploy_agent.py failure analysis to collect 'docker compose ps -q' container IDs, fetch per-container 'docker logs --tail=50', cap combined logs at 2000 chars, and include logs in the single-retry LLM correction prompt."
|
||||
D23: "Fixed malformed duplicate function header introduced during D22 patch; deploy_agent.py function structure restored."
|
||||
D24: "Updated deploy_agent.py status validation: deployment success now requires status containing 'Up' and not containing 'unhealthy' case-insensitively."
|
||||
D25: "User reiterated file-only output expectation after status-validation request; no code change beyond D24."
|
||||
D26: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for log-analysis and status-validation updates."
|
||||
D27: "Git commit created on 2026-04-22: 72290cd 'Improve deploy failure analysis'."
|
||||
D28: "Updated deploy_agent.py second-failure path to return 'ESCALATE_TO_CODEX' with formatted debug block containing service, error, status, and logs instead of returning plain ERROR."
|
||||
D29: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for Codex escalation-path update."
|
||||
D30: "Git commit created on 2026-04-22: 104d8dc 'Add deploy escalation output'."
|
||||
D31: "Startup 2026-04-23: loaded user-provided SESSION_STATE as authoritative memory, found existing ./codex_context.yaml, refreshed meta.environment.date, overwrote state file."
|
||||
D32: "Startup 2026-05-03: loaded user-provided SESSION_STATE as authoritative memory, found existing ./codex_context.yaml, refreshed meta.environment.date, overwrote state file."
|
||||
D33: "Updated ./ollama_client.py to import os, define OLLAMA_URL from env defaulting to http://localhost:11434 with trailing-slash trim, and replace hardcoded /api/chat base URL with f'{OLLAMA_URL}/api/chat'."
|
||||
D34: "User requested identical Aider setup on solaria, piha, vpshetzner via SSH using ~/.ssh/config; per-host flow: install uv if missing, ensure ~/.local/bin PATH in ~/.zshrc, install aider-chat with uv tool install --python 3.12, ensure OLLAMA_API_BASE export in ~/.zshrc, source ~/.zshrc, verify aider, run one-line model test; retry each failed step once; continue across hosts."
|
||||
D35: "Aider install run 2026-05-03: solaria reachable via unrestricted ssh -F ~/.ssh/config; installed aider-chat with uv on remote Python 3.12, ensured ~/.zshrc contains PATH export for ~/.local/bin and OLLAMA_API_BASE=http://100.100.231.104:11434; verify: which aider=/home/oskar/.local/bin/aider, version=aider 0.86.2."
|
||||
D36: "Aider host access results 2026-05-03: piha ssh auth failed for oskar@piha (Permission denied publickey,password); vpshetzner alias unresolved locally; ssh probes to configured IP-only hosts 92.43.115.112 and 92.43.115.118 timed out on port 22; requested exact aider test command on solaria exited 0 but only opened interactive session and echoed prompt without visible model reply."
|
||||
D37: "User corrected remaining SSH targets on 2026-05-03: piha via pi@piha; vps via ubuntu-4gb-hel1-1. Scope narrowed: do not reinstall solaria; only install/verify Aider on remaining hosts; do not run interactive aider test; verify version only; update ~/.zshrc and/or ~/.bashrc idempotently."
|
||||
D38: "Aider retry run 2026-05-03 succeeded on both corrected targets. piha via pi@piha: installed uv when missing, updated existing shell rc files idempotently for PATH and OLLAMA_API_BASE, installed aider-chat with uv tool install --python 3.12, verify=aider 0.86.2. VPS via ubuntu-4gb-hel1-1: same actions, verify=aider 0.86.2."
|
||||
D39: "Shared context bootstrap update 2026-05-03: start-codex.sh now runs from repo root, prints that it is loading ./codex_context.yaml, and injects the required initial instruction 'Before doing any task, read codex_context.yaml and treat it as shared project memory.' before existing SESSION_STATE bootstrap content."
|
||||
D40: "Created ./start-aider.sh and ./update-context.md on 2026-05-03. start-aider.sh runs from repo root, defaults OLLAMA_API_BASE to http://100.100.231.104:11434, uses model ollama/deepseek-coder:latest, and attaches ./codex_context.yaml via aider --read after confirming read-only support from local aider help. update-context.md documents shared context rules for Codex and Aider; scripts set executable."
|
||||
D41: "Startup 2026-05-03: read existing ./codex_context.yaml before task work, verified parity with user-provided SESSION_STATE, retained state, overwrote file."
|
||||
D42: "Aider is installed as a local coding assistant, but current local Ollama models are not reliable enough for context-file editing."
|
||||
todos:
|
||||
T1: "For all future meaningful changes/decisions, update and overwrite ./codex_context.yaml."
|
||||
T2: "DONE: Commit current changes."
|
||||
T3: "DONE: Tailscale active."
|
||||
T4: "DONE: piha and solaria reachable via DERP(waw); direct TS path not established."
|
||||
T5: "DONE: direct vs gateway /api/tags measured."
|
||||
T6: "DONE: Add local launcher script for Codex session memory bootstrap."
|
||||
T7: "DONE: Add minimal local Ollama Python client."
|
||||
T8: "DONE: Harden local Ollama Python client error handling."
|
||||
T9: "DONE: Add compose-generation agent using local LLM client."
|
||||
T10: "DONE: Commit local Ollama automation scripts."
|
||||
T11: "DONE: Add docker-compose YAML validation and retry logic."
|
||||
T12: "DONE: Add automatic service deployment workflow."
|
||||
T13: "DONE: Add deployment status verification and basic port-80 safety check."
|
||||
T14: "DONE: Commit deploy agent safety/status updates."
|
||||
T15: "DONE: Add one-shot LLM-assisted deployment failure recovery."
|
||||
T16: "DONE: Commit LLM-assisted deploy retry changes."
|
||||
T17: "DONE: Add bounded container log analysis to deploy failure recovery."
|
||||
T18: "DONE: Tighten deploy status validation against unhealthy containers."
|
||||
T19: "DONE: Commit deploy failure analysis and status validation updates."
|
||||
T20: "DONE: Add Codex escalation output on second deployment failure."
|
||||
T21: "DONE: Commit deploy escalation output changes."
|
||||
T22: "DONE: Retry Aider setup on remaining hosts using corrected SSH targets pi@piha and ubuntu-4gb-hel1-1; both verified at aider 0.86.2."
|
||||
T23: "DONE: Add shared Codex/Aider context bootstrap scripts and update-context protocol doc."
|
||||
T24: "Use Codex for codex_context.yaml updates; use Aider only for simple code edits until a better local model/edit format is validated."
|
||||
issues:
|
||||
I1: "Tailscale DNS health warning: configured DNS servers unreachable."
|
||||
I2: "Preferred gateway path unavailable: 100.108.208.3:8080 connection failed."
|
||||
I3: "Prior direct solaria/gateway-IP checks remain historical only; current policy forbids direct solaria/localhost use."
|
||||
I4: "SSH access mismatch vs user expectation: ~/.ssh/config lacks solaria/piha/vpshetzner host aliases; only raw IP host entries 92.43.115.112 and 92.43.115.118 exist."
|
||||
I5: "piha unreachable for task execution with current ssh config/identity: oskar@piha returns Permission denied (publickey,password)."
|
||||
I6: "vpshetzner target unresolved/unreachable: hostname vpshetzner does not resolve locally; configured IP-only hosts 92.43.115.112 and 92.43.115.118 timed out on port 22."
|
||||
219
saturn/deploy_agent.py
Normal file
219
saturn/deploy_agent.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from ollama_client import ask
|
||||
import yaml
|
||||
|
||||
|
||||
def _build_prompt(service: str, extra: str = "") -> str:
|
||||
prompt = (
|
||||
"Output ONLY valid docker-compose YAML. No explanations. No markdown.\n\n"
|
||||
f"Generate a docker-compose file for this service: {service}"
|
||||
)
|
||||
if extra:
|
||||
prompt = f"{prompt}\n{extra}"
|
||||
return prompt
|
||||
|
||||
|
||||
def _generate_compose(service: str, extra: str = "") -> str:
|
||||
base_prompt = (
|
||||
_build_prompt(service, extra)
|
||||
)
|
||||
retry_prompt = (
|
||||
"Previous output was invalid. Fix YAML and return valid docker-compose only."
|
||||
)
|
||||
|
||||
prompt = base_prompt
|
||||
for _ in range(3):
|
||||
response = ask(prompt)
|
||||
if response.startswith("ERROR:"):
|
||||
return response
|
||||
try:
|
||||
parsed = yaml.safe_load(response)
|
||||
except yaml.YAMLError:
|
||||
prompt = f"{base_prompt}\n\n{retry_prompt}"
|
||||
continue
|
||||
if isinstance(parsed, dict) and "services" in parsed:
|
||||
return response
|
||||
prompt = f"{base_prompt}\n\n{retry_prompt}"
|
||||
return "ERROR: invalid docker-compose"
|
||||
|
||||
|
||||
def generate_compose(service: str) -> str:
|
||||
return _generate_compose(service)
|
||||
|
||||
|
||||
def _is_valid_compose(compose: str) -> bool:
|
||||
try:
|
||||
parsed = yaml.safe_load(compose)
|
||||
except yaml.YAMLError:
|
||||
return False
|
||||
return isinstance(parsed, dict) and "services" in parsed
|
||||
|
||||
def _get_compose_logs(path: Path) -> str:
|
||||
try:
|
||||
ids_result = subprocess.run(
|
||||
["docker", "compose", "ps", "-q"],
|
||||
cwd=path,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
container_ids = [line.strip() for line in ids_result.stdout.splitlines() if line.strip()]
|
||||
if not container_ids:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
total = 0
|
||||
limit = 2000
|
||||
for container_id in container_ids:
|
||||
try:
|
||||
logs_result = subprocess.run(
|
||||
["docker", "logs", container_id, "--tail=50"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
chunk = f"{container_id}:\n{exc}\n"
|
||||
else:
|
||||
chunk_body = logs_result.stdout or logs_result.stderr
|
||||
chunk = f"{container_id}:\n{chunk_body}\n"
|
||||
remaining = limit - total
|
||||
if remaining <= 0:
|
||||
break
|
||||
if len(chunk) > remaining:
|
||||
chunk = chunk[:remaining]
|
||||
parts.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= limit:
|
||||
break
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _fix_compose(service: str, error: str, status: str, logs: str) -> str:
|
||||
prompt = (
|
||||
"Deployment failed.\n\n"
|
||||
f"Service: {service}\n\n"
|
||||
f"Error: {error}\n\n"
|
||||
f"Status: {status}\n\n"
|
||||
f"Logs: {logs}\n\n"
|
||||
"Fix the docker-compose YAML. Return ONLY corrected YAML."
|
||||
)
|
||||
response = ask(prompt)
|
||||
if response.startswith("ERROR:"):
|
||||
return response
|
||||
if not _is_valid_compose(response):
|
||||
return "ERROR: invalid docker-compose"
|
||||
return response
|
||||
|
||||
|
||||
def get_service_status(path: Path) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "ps"],
|
||||
cwd=path,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"ERROR: {exc}"
|
||||
output = result.stdout.strip()
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or output or "docker compose ps failed"
|
||||
return f"ERROR: {error}"
|
||||
return output
|
||||
|
||||
|
||||
def _run_compose_up(path: Path) -> tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "up", "-d"],
|
||||
cwd=path,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or result.stdout.strip() or "docker compose up failed"
|
||||
return False, error
|
||||
return True, result.stdout.strip()
|
||||
|
||||
|
||||
def deploy_service(service: str) -> str:
|
||||
prompt_extra = ""
|
||||
try:
|
||||
docker_ps = subprocess.run(
|
||||
["docker", "ps"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
docker_ps_output = docker_ps.stdout
|
||||
if ":80->" in docker_ps_output or "0.0.0.0:80-" in docker_ps_output or "[::]:80-" in docker_ps_output:
|
||||
prompt_extra = "Use a different port than 80"
|
||||
except Exception:
|
||||
docker_ps_output = ""
|
||||
|
||||
compose = _generate_compose(service, prompt_extra)
|
||||
if compose.startswith("ERROR:"):
|
||||
return compose
|
||||
|
||||
deployments_dir = Path("./deployments")
|
||||
target_dir = deployments_dir / service
|
||||
suffix = 1
|
||||
while target_dir.exists():
|
||||
target_dir = deployments_dir / f"{service}-{suffix}"
|
||||
suffix += 1
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=False)
|
||||
compose_path = target_dir / "docker-compose.yml"
|
||||
compose_path.write_text(compose, encoding="utf-8")
|
||||
|
||||
ok, error = _run_compose_up(target_dir)
|
||||
if not ok:
|
||||
status = get_service_status(target_dir)
|
||||
logs = _get_compose_logs(target_dir)
|
||||
fixed_compose = _fix_compose(service, error, status, logs)
|
||||
if fixed_compose.startswith("ERROR:"):
|
||||
if status and not status.startswith("ERROR:"):
|
||||
return f"ERROR: {error}\n{status}"
|
||||
return f"ERROR: {error}"
|
||||
compose_path.write_text(fixed_compose, encoding="utf-8")
|
||||
ok, error = _run_compose_up(target_dir)
|
||||
if not ok:
|
||||
status = get_service_status(target_dir)
|
||||
logs = _get_compose_logs(target_dir)
|
||||
debug = f"""
|
||||
ESCALATION REQUIRED
|
||||
|
||||
SERVICE: {service}
|
||||
|
||||
ERROR:
|
||||
{error}
|
||||
|
||||
STATUS:
|
||||
{status}
|
||||
|
||||
LOGS:
|
||||
{logs}
|
||||
"""
|
||||
return f"ESCALATE_TO_CODEX\n{debug}"
|
||||
|
||||
status = get_service_status(target_dir)
|
||||
if status.startswith("ERROR:"):
|
||||
return status
|
||||
if "Up" not in status or "unhealthy" in status.lower():
|
||||
return f"ERROR: no running services\n{status}"
|
||||
|
||||
return f"DEPLOYED: {target_dir.name}\n{status}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(deploy_service("nginx"))
|
||||
34
saturn/docs/access.md
Normal file
34
saturn/docs/access.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Access
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the currently known access methods for the homelab.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Public services are accessed through Nginx Proxy Manager.
|
||||
- Public HTTPS certificates are issued using Let's Encrypt.
|
||||
- Private access is provided through Tailscale.
|
||||
|
||||
## Known facts
|
||||
|
||||
- Nginx Proxy Manager is the public reverse proxy.
|
||||
- HTTPS is used for public services.
|
||||
- Let's Encrypt is used for public TLS certificates.
|
||||
- Tailscale is used for private access.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Public domain names and subdomains.
|
||||
- Which services are public.
|
||||
- Which services are private-only.
|
||||
- Nginx Proxy Manager proxy hosts.
|
||||
- Nginx Proxy Manager SSL certificate settings.
|
||||
- Whether HTTP-to-HTTPS redirection is enabled.
|
||||
- Whether Nginx Proxy Manager access lists are used.
|
||||
- Tailscale device name for the Raspberry Pi 5.
|
||||
- Whether Tailscale SSH is enabled.
|
||||
- Whether the Raspberry Pi 5 advertises subnet routes.
|
||||
- Whether the Raspberry Pi 5 is an exit node.
|
||||
- User accounts or groups with access through Tailscale.
|
||||
- Local administrator access method for the Raspberry Pi 5.
|
||||
32
saturn/docs/core-stack.md
Normal file
32
saturn/docs/core-stack.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Core Stack
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the known core software stack running in the homelab.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Docker
|
||||
- Portainer
|
||||
- Nginx Proxy Manager
|
||||
|
||||
## Known facts
|
||||
|
||||
- Docker is used as part of the core stack.
|
||||
- Portainer is used as part of the core stack.
|
||||
- Nginx Proxy Manager is used as part of the core stack.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Docker version.
|
||||
- Docker installation method.
|
||||
- Whether Docker Compose is used.
|
||||
- Location of Compose files, stack files, or deployment manifests.
|
||||
- Portainer deployment method.
|
||||
- Portainer exposed URL or access method.
|
||||
- Nginx Proxy Manager deployment method.
|
||||
- Nginx Proxy Manager exposed URL or access method.
|
||||
- Container restart policies.
|
||||
- Container network names and topology.
|
||||
- Persistent volume locations.
|
||||
- Backup method for Portainer and Nginx Proxy Manager data.
|
||||
24
saturn/docs/hardware.md
Normal file
24
saturn/docs/hardware.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hardware
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the currently known physical hardware for the homelab.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Main server: Raspberry Pi 5
|
||||
|
||||
## Known facts
|
||||
|
||||
- The Raspberry Pi 5 is the main server.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Raspberry Pi 5 RAM size.
|
||||
- Raspberry Pi 5 operating system boot media.
|
||||
- Storage devices attached to the Raspberry Pi 5.
|
||||
- Power supply model or rating.
|
||||
- Case, cooling, fan, or heatsink details.
|
||||
- UPS presence or absence.
|
||||
- Network connection type: Ethernet or Wi-Fi.
|
||||
- Physical location of the server.
|
||||
90
saturn/docs/hetzner-vps.md
Normal file
90
saturn/docs/hetzner-vps.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Hetzner VPS
|
||||
|
||||
## Description
|
||||
|
||||
This page documents facts received from the Codex session running on the Hetzner VPS / homelab server.
|
||||
|
||||
The relationship between this VPS and the Raspberry Pi 5 homelab is not yet clarified.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Hostname: `ubuntu-4gb-hel1-1`
|
||||
- Public IPv4: `135.181.153.108`
|
||||
- Public IPv6: `2a01:4f9:c014:98f0::1`
|
||||
- Tailscale IP: `100.95.58.48`
|
||||
- Incorrect Tailscale IP explicitly ruled out: `100.108.208.3`
|
||||
|
||||
Network interfaces reported:
|
||||
|
||||
- `docker0`: `172.17.0.1/16`, `DOWN`
|
||||
- `br-b467702c0f28`: `172.18.0.1/16`, `DOWN`
|
||||
- `br-40cc27c6ea24`: `172.19.0.1/16`, `DOWN`
|
||||
|
||||
Docker networks:
|
||||
|
||||
- `bridge`
|
||||
- `host`
|
||||
- `none`
|
||||
- `npm_default`
|
||||
- `proxy`
|
||||
- Planned after Joplin start: `joplin-net`
|
||||
|
||||
Docker volumes:
|
||||
|
||||
- No Docker named volumes currently exist.
|
||||
- Planned after Joplin start: `joplin_postgres_data`
|
||||
|
||||
Running containers:
|
||||
|
||||
- `npm`
|
||||
|
||||
## Known facts
|
||||
|
||||
- `npm` uses image `jc21/nginx-proxy-manager:latest`.
|
||||
- `npm` status was reported as `Up about an hour`.
|
||||
- `npm` Compose path is `/home/dockeruser/docker/npm`.
|
||||
- `npm` uses `network_mode: host`.
|
||||
- Because `npm` uses host networking, Nginx Proxy Manager binds directly to host ports.
|
||||
- Nginx Proxy Manager admin UI responds `200 OK` internally at `http://127.0.0.1:81`.
|
||||
- Nginx Proxy Manager HTTP listener responds `200 OK` internally at `http://127.0.0.1:80`.
|
||||
- Nginx Proxy Manager responded `200 OK` at `http://100.95.58.48:81`.
|
||||
- Nginx Proxy Manager responded `200 OK` at `http://135.181.153.108:81`.
|
||||
- Nginx config test passes.
|
||||
- Laptop-side diagnostics on 2026-04-15 verified:
|
||||
- `tailscale status` shows `ubuntu-4gb-hel1-1` at `100.95.58.48` as active.
|
||||
- `tailscale ping 100.95.58.48` returns pong responses through DERP relay `hel`.
|
||||
- `tailscale ping 100.95.58.48` reports direct connection not established.
|
||||
- `ping -c 4 100.95.58.48` returns 4 received, 0% packet loss.
|
||||
- `ping -c 4 135.181.153.108` returns 4 received, 0% packet loss.
|
||||
- `curl -v --connect-timeout 5 http://100.95.58.48:81` connects and returns `HTTP/1.1 200 OK`.
|
||||
- `curl -I --connect-timeout 5 http://100.95.58.48:81` returns `HTTP/1.1 200 OK`.
|
||||
- `curl -v --connect-timeout 5 http://135.181.153.108:81` connects and returns `HTTP/1.1 200 OK`.
|
||||
- `curl -I --connect-timeout 5 http://135.181.153.108:81` returns `HTTP/1.1 200 OK`.
|
||||
- From a laptop/browser, the reported Nginx Proxy Manager admin UI URLs are:
|
||||
- `http://100.95.58.48:81` over Tailscale
|
||||
- `http://135.181.153.108:81` publicly if firewall allows it
|
||||
|
||||
Nginx Proxy Manager Compose file:
|
||||
|
||||
- Path: `/home/dockeruser/docker/npm/docker-compose.yml`
|
||||
- Service:
|
||||
- `container_name: npm`
|
||||
- `image: jc21/nginx-proxy-manager:latest`
|
||||
- `restart: unless-stopped`
|
||||
- `network_mode: host`
|
||||
- `TZ: Europe/Warsaw`
|
||||
- Volumes:
|
||||
- `./data:/data`
|
||||
- `./letsencrypt:/etc/letsencrypt`
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Whether this Hetzner VPS is part of the homelab, a separate public edge, or both.
|
||||
- Operating system version.
|
||||
- Firewall rules.
|
||||
- Whether port `81` is intentionally reachable on public IPv4.
|
||||
- Whether ports `80` and `443` are publicly reachable from the internet.
|
||||
- Why Tailscale direct connection is not established and traffic uses DERP relay `hel`.
|
||||
- Whether any services other than `npm` are running outside Docker.
|
||||
- Backup configuration.
|
||||
- Monitoring and alerting configuration.
|
||||
177
saturn/docs/joplin-server.md
Normal file
177
saturn/docs/joplin-server.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Joplin Server
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the current Joplin Server state on the Hetzner VPS.
|
||||
|
||||
Joplin Server is running on the VPS and is reachable through Nginx Proxy Manager when requests resolve to the VPS IP.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- Compose path: `/home/dockeruser/docker/joplin-server`
|
||||
- Files:
|
||||
- `/home/dockeruser/docker/joplin-server/docker-compose.yml`
|
||||
- `/home/dockeruser/docker/joplin-server/.env`
|
||||
- `/home/dockeruser/docker/joplin-server/README.md`
|
||||
- Current runtime state: running
|
||||
- `docker compose ps` in `/home/dockeruser/docker/joplin-server` shows:
|
||||
- `joplin-db`: healthy
|
||||
- `joplin-server`: up, bound to `127.0.0.1:22300`
|
||||
- Intended public URL: `https://joplin.okit.pl`
|
||||
|
||||
Current DNS issue:
|
||||
|
||||
- `joplin.okit.pl` currently returns `CNAME okit.pl`, but no valid A or AAAA answer.
|
||||
- Let's Encrypt failed with: `no valid A records found for joplin.okit.pl; no valid AAAA records found for joplin.okit.pl`.
|
||||
- DNS needs to be fixed before normal public HTTPS works.
|
||||
|
||||
Fixes applied on 2026-04-15:
|
||||
|
||||
- Recreated the Joplin compose stack so `joplin-db` used the current Postgres 18 mount layout.
|
||||
- Confirmed the Joplin `.env` password is no longer the placeholder.
|
||||
- Joplin app started successfully and auto-migrated the database.
|
||||
- Updated Nginx Proxy Manager's `proxy_host` database row for `joplin.okit.pl` to forward to `http://127.0.0.1:22300`, with websockets and block-exploits enabled.
|
||||
- Manually updated active NPM config at `/home/dockeruser/docker/npm/data/nginx/proxy_host/1.conf` to use `127.0.0.1:22300`, because this NPM instance did not regenerate the config from SQLite on restart.
|
||||
- Reloaded nginx successfully.
|
||||
|
||||
Successful tests on 2026-04-15:
|
||||
|
||||
```sh
|
||||
curl -sS http://127.0.0.1:22300/api/ping -H 'Host: joplin.okit.pl'
|
||||
# {"status":"ok","message":"Joplin Server is running"}
|
||||
|
||||
curl -sS http://127.0.0.1/api/ping -H 'Host: joplin.okit.pl'
|
||||
# {"status":"ok","message":"Joplin Server is running"}
|
||||
|
||||
curl -sS --resolve joplin.okit.pl:80:135.181.153.108 http://joplin.okit.pl/api/ping
|
||||
# {"status":"ok","message":"Joplin Server is running"}
|
||||
```
|
||||
|
||||
Laptop-side diagnostics on 2026-04-15:
|
||||
|
||||
- Direct test to Joplin over Tailscale:
|
||||
- Command: `curl -v --connect-timeout 5 http://100.95.58.48:22300`
|
||||
- Result: connection refused.
|
||||
- Observed source address: `100.121.168.72`.
|
||||
- TCP test to SSH over Tailscale:
|
||||
- Command: `nc -vz 100.95.58.48 22`
|
||||
- Result: connection succeeded.
|
||||
- TCP test to Joplin over Tailscale:
|
||||
- Command: `nc -vz 100.95.58.48 22300`
|
||||
- Result: connection refused.
|
||||
- Classical SSH test:
|
||||
- Command: `ssh -o BatchMode=yes -o ConnectTimeout=5 dockeruser@100.95.58.48 true`
|
||||
- Result: local SSH client refused to run because `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` has bad owner or permissions.
|
||||
- Classical SSH test with global config bypassed:
|
||||
- Command: `ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5 dockeruser@100.95.58.48 true`
|
||||
- Result: `Permission denied (publickey,password).`
|
||||
- Tailscale SSH wrapper test:
|
||||
- Command: `tailscale ssh dockeruser@100.95.58.48 true`
|
||||
- Result: host key verification failed because no ED25519 host key is known for `ubuntu-4gb-hel1-1.tailedf7b1.ts.net`.
|
||||
- SSH tunnel was not established from the laptop during this test because SSH authentication or host-key verification was not completed.
|
||||
|
||||
## Known facts
|
||||
|
||||
Joplin Compose design:
|
||||
|
||||
- `app`
|
||||
- `image: joplin/server:latest`
|
||||
- `container_name: joplin-server`
|
||||
- `restart: unless-stopped`
|
||||
- `env_file: .env`
|
||||
- Binds only to localhost:
|
||||
- `127.0.0.1:22300:22300`
|
||||
- Depends on `db` with condition `service_healthy`
|
||||
- Network: `joplin-net`
|
||||
- `db`
|
||||
- `image: postgres:18`
|
||||
- `container_name: joplin-db`
|
||||
- `restart: unless-stopped`
|
||||
- No exposed ports
|
||||
- Network: `joplin-net`
|
||||
- Volume:
|
||||
- `postgres_data:/var/lib/postgresql`
|
||||
- Healthcheck:
|
||||
- `pg_isready` using `POSTGRES_USER` and `POSTGRES_DB`
|
||||
- Named volume:
|
||||
- `joplin_postgres_data`
|
||||
- Named network:
|
||||
- `joplin-net`
|
||||
|
||||
Joplin `.env`:
|
||||
|
||||
```env
|
||||
POSTGRES_PASSWORD=<set on VPS; not placeholder>
|
||||
POSTGRES_USER=joplin
|
||||
POSTGRES_DB=joplin
|
||||
APP_PORT=22300
|
||||
APP_BASE_URL=https://joplin.okit.pl
|
||||
DB_CLIENT=pg
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
```
|
||||
|
||||
Important notes from handoff:
|
||||
|
||||
- `POSTGRES_PASSWORD` has been changed from the original placeholder.
|
||||
- Joplin is intentionally localhost-only.
|
||||
- External access must go through Nginx Proxy Manager.
|
||||
- Because Nginx Proxy Manager uses host networking, Nginx Proxy Manager should forward to `127.0.0.1:22300`.
|
||||
- PostgreSQL is internal-only and should not be exposed publicly.
|
||||
|
||||
Required Nginx Proxy Manager proxy host for Joplin:
|
||||
|
||||
- Domain Names: `joplin.okit.pl`
|
||||
- Scheme: `http`
|
||||
- Forward Hostname / IP: `127.0.0.1`
|
||||
- Forward Port: `22300`
|
||||
- Websockets Support: enabled
|
||||
- Block Common Exploits: enabled
|
||||
- SSL:
|
||||
- Request Let's Encrypt certificate
|
||||
- Force SSL enabled
|
||||
- HTTP/2 enabled
|
||||
|
||||
DNS plan from handoff:
|
||||
|
||||
- Create A record:
|
||||
- `joplin.okit.pl -> 135.181.153.108`
|
||||
- Optional AAAA record:
|
||||
- `joplin.okit.pl -> 2a01:4f9:c014:98f0::1`
|
||||
- For normal Let's Encrypt through Nginx Proxy Manager, ports `80` and `443` must reach this VPS publicly.
|
||||
- Public DNS should not point to the Tailscale IP if using standard Let's Encrypt HTTP validation.
|
||||
|
||||
Commands provided in handoff to start Joplin:
|
||||
|
||||
```sh
|
||||
cd /home/dockeruser/docker/joplin-server
|
||||
nano .env
|
||||
# replace POSTGRES_PASSWORD
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Local tests on VPS after Joplin start:
|
||||
|
||||
```sh
|
||||
curl -I http://127.0.0.1:22300
|
||||
curl -I http://127.0.0.1:81
|
||||
curl -I http://127.0.0.1:80
|
||||
```
|
||||
|
||||
Public tests after DNS and Nginx Proxy Manager config:
|
||||
|
||||
```sh
|
||||
dig joplin.okit.pl
|
||||
curl -I https://joplin.okit.pl
|
||||
```
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Whether Nginx Proxy Manager will preserve the manual generated-conf fix after future UI edits/restarts. The SQLite row is correct, but the active generated config did not update automatically during this fix.
|
||||
- Whether `joplin.okit.pl` DNS has been created or fixed.
|
||||
- Whether the optional AAAA record is intended.
|
||||
- Whether Let's Encrypt certificate issuance has succeeded.
|
||||
- Whether the laptop has a valid SSH key or password for `dockeruser@100.95.58.48`.
|
||||
- Whether the Tailscale SSH host key for `ubuntu-4gb-hel1-1.tailedf7b1.ts.net` should be accepted on the laptop.
|
||||
35
saturn/docs/networking.md
Normal file
35
saturn/docs/networking.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Networking
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the current known network position and port forwarding for the homelab.
|
||||
|
||||
## Current configuration
|
||||
|
||||
- The homelab is behind NAT.
|
||||
- Port forwarding is configured as follows:
|
||||
- External ports `80-81` forward to internal ports `4480-4481`
|
||||
- External port `443` forwards to internal port `4443`
|
||||
|
||||
## Known facts
|
||||
|
||||
- NAT is present between the public internet and the homelab.
|
||||
- Public HTTP/HTTPS traffic reaches the homelab through forwarded ports.
|
||||
- External ports `80`, `81`, and `443` are known to be forwarded.
|
||||
- Internal ports `4480`, `4481`, and `4443` are known forwarding targets.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Router or firewall model.
|
||||
- Whether the WAN IP is static, dynamic, or CGNAT.
|
||||
- Internal IP address of the Raspberry Pi 5.
|
||||
- Whether the Raspberry Pi 5 uses DHCP or static addressing.
|
||||
- Exact mapping for external ports `80-81` to internal ports `4480-4481`:
|
||||
- Whether `80` maps to `4480`.
|
||||
- Whether `81` maps to `4481`.
|
||||
- Protocols forwarded for each port: TCP, UDP, or both.
|
||||
- Whether any other ports are forwarded.
|
||||
- LAN subnet and gateway.
|
||||
- DNS provider and DNS records.
|
||||
- IPv6 availability or absence.
|
||||
- Firewall rules on the Raspberry Pi 5.
|
||||
65
saturn/docs/questions.md
Normal file
65
saturn/docs/questions.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Unknowns and Clarification Questions
|
||||
|
||||
## Description
|
||||
|
||||
This page lists information that is missing or unclear from the current homelab documentation.
|
||||
|
||||
## Current configuration
|
||||
|
||||
The currently documented configuration is limited to:
|
||||
|
||||
- Raspberry Pi 5 as the main server.
|
||||
- Docker, Portainer, and Nginx Proxy Manager as the core stack.
|
||||
- NAT with forwarded ports:
|
||||
- `80-81` to `4480-4481`
|
||||
- `443` to `4443`
|
||||
- Public access through Nginx Proxy Manager with Let's Encrypt HTTPS.
|
||||
- Private access through Tailscale.
|
||||
- Hetzner VPS handoff:
|
||||
- Hostname: `ubuntu-4gb-hel1-1`
|
||||
- Tailscale IP: `100.95.58.48`
|
||||
- Public IPv4: `135.181.153.108`
|
||||
- Public IPv6: `2a01:4f9:c014:98f0::1`
|
||||
- Running container: `npm`
|
||||
- Joplin files created but not running.
|
||||
|
||||
## Known facts
|
||||
|
||||
- The homelab is documented only from the known facts above.
|
||||
- Anything not listed as known remains unconfirmed.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
1. What operating system and version is running on the Raspberry Pi 5?
|
||||
2. What is the Raspberry Pi 5 RAM size?
|
||||
3. What storage devices are used, and where is persistent service data stored?
|
||||
4. What is the Raspberry Pi 5 LAN IP address?
|
||||
5. Is the Raspberry Pi 5 using DHCP or a static IP address?
|
||||
6. What router or firewall performs NAT and port forwarding?
|
||||
7. Is the WAN IP static, dynamic, or behind CGNAT?
|
||||
8. Does external port `80` map to internal port `4480`, and does external port `81` map to internal port `4481`?
|
||||
9. Are the forwarded ports TCP only, UDP only, or both?
|
||||
10. Are any other ports forwarded?
|
||||
11. What domain names or subdomains point to the homelab?
|
||||
12. What are the Nginx Proxy Manager proxy hosts?
|
||||
13. Which services are public, and which are private-only?
|
||||
14. Is HTTP-to-HTTPS redirection enabled in Nginx Proxy Manager?
|
||||
15. Are Nginx Proxy Manager access lists used?
|
||||
16. How are Docker, Portainer, and Nginx Proxy Manager deployed?
|
||||
17. Are Docker Compose files, Portainer stacks, or other manifests available?
|
||||
18. What containers are currently running?
|
||||
19. What Docker networks and volumes exist?
|
||||
20. What is the Tailscale device name for the Raspberry Pi 5?
|
||||
21. Does the Raspberry Pi 5 advertise Tailscale subnet routes?
|
||||
22. Is the Raspberry Pi 5 configured as a Tailscale exit node?
|
||||
23. Is Tailscale SSH enabled?
|
||||
24. What backup system exists, if any?
|
||||
25. What monitoring or alerting exists, if any?
|
||||
26. Is the Hetzner VPS part of the homelab documentation scope, a separate system, or both?
|
||||
27. What is the operating system version on `ubuntu-4gb-hel1-1`?
|
||||
28. Is public Nginx Proxy Manager admin access on port `81` intentionally reachable on `135.181.153.108`?
|
||||
29. Has DNS record `joplin.okit.pl -> 135.181.153.108` been created?
|
||||
30. Has optional AAAA record `joplin.okit.pl -> 2a01:4f9:c014:98f0::1` been created?
|
||||
31. Has `POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD` been changed before first Joplin production start?
|
||||
32. Has the Nginx Proxy Manager proxy host for `joplin.okit.pl` been created?
|
||||
33. Are ports `80` and `443` publicly reachable on the Hetzner VPS for Let's Encrypt HTTP validation?
|
||||
51
saturn/docs/services.md
Normal file
51
saturn/docs/services.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Services
|
||||
|
||||
## Description
|
||||
|
||||
This page documents the currently known services in the homelab.
|
||||
|
||||
## Current configuration
|
||||
|
||||
Known Raspberry Pi 5 services:
|
||||
|
||||
- Portainer
|
||||
- Nginx Proxy Manager
|
||||
|
||||
Known Hetzner VPS services:
|
||||
|
||||
- Nginx Proxy Manager
|
||||
|
||||
Known Hetzner VPS service files:
|
||||
|
||||
- Joplin Server
|
||||
|
||||
Known supporting platform:
|
||||
|
||||
- Docker
|
||||
|
||||
## Known facts
|
||||
|
||||
- Portainer is present in the homelab.
|
||||
- Nginx Proxy Manager is present in the homelab.
|
||||
- Public services are exposed through Nginx Proxy Manager using HTTPS.
|
||||
- Private access is available through Tailscale.
|
||||
- On the Hetzner VPS, Nginx Proxy Manager is running as container `npm`.
|
||||
- On the Hetzner VPS, Joplin Server files have been created but the service is not running yet.
|
||||
|
||||
## Unknown / needs clarification
|
||||
|
||||
- Full list of running services and containers on the Raspberry Pi 5.
|
||||
- Service names.
|
||||
- Service purposes.
|
||||
- Public or private exposure for each service.
|
||||
- Internal ports for each service.
|
||||
- External domains for each public service.
|
||||
- Docker image names and versions.
|
||||
- Data volume paths.
|
||||
- Environment variables and secrets handling.
|
||||
- Service dependencies.
|
||||
- Restart policies.
|
||||
- Health checks.
|
||||
- Backup coverage for each service.
|
||||
- Restore process for each service.
|
||||
- Whether Joplin Server should be documented as part of the current homelab, as a VPS service, or both.
|
||||
39
saturn/ollama_client.py
Normal file
39
saturn/ollama_client.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/")
|
||||
|
||||
|
||||
def ask(prompt: str) -> str:
|
||||
payload = {
|
||||
"model": "deepseek-coder",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_URL}/api/chat",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
body = json.loads(raw)
|
||||
return body["message"]["content"]
|
||||
except urllib.error.HTTPError as exc:
|
||||
return f"ERROR: HTTP {exc.code} {exc.reason}"
|
||||
except urllib.error.URLError as exc:
|
||||
return f"ERROR: {exc.reason}"
|
||||
except json.JSONDecodeError as exc:
|
||||
return f"ERROR: Invalid JSON response: {exc}"
|
||||
except (KeyError, TypeError) as exc:
|
||||
return f"ERROR: Invalid response format: {exc}"
|
||||
except Exception as exc:
|
||||
return f"ERROR: {exc}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(ask("Write docker-compose for nginx"))
|
||||
47
saturn/start-aider.sh
Executable file
47
saturn/start-aider.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STATE_FILE="$SCRIPT_DIR/codex_context.yaml"
|
||||
DEFAULT_OLLAMA_API_BASE="http://100.100.231.104:11434"
|
||||
AIDER_BIN="${AIDER_BIN:-$HOME/.local/bin/aider}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./start-aider.sh [aider-args...]
|
||||
|
||||
Starts Aider from this repository with shared project context.
|
||||
EOF
|
||||
}
|
||||
|
||||
if (($# > 0)); then
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
export OLLAMA_API_BASE="${OLLAMA_API_BASE:-$DEFAULT_OLLAMA_API_BASE}"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
printf 'Loading %s\n' "$STATE_FILE"
|
||||
|
||||
if [[ ! -x "$AIDER_BIN" ]]; then
|
||||
echo "Aider executable not found at $AIDER_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if "$AIDER_BIN" --help 2>/dev/null | grep -q -- '--read FILE'; then
|
||||
printf '%s\n' 'Starting Aider with codex_context.yaml attached as read-only shared context.'
|
||||
exec "$AIDER_BIN" \
|
||||
--model ollama/deepseek-coder:latest \
|
||||
--read "$STATE_FILE" \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Aider does not support read-only context files in this environment.'
|
||||
printf '%s\n' 'Ask Aider to read codex_context.yaml first, then continue with your task.'
|
||||
exec "$AIDER_BIN" --model qwen2.5-coder:14b"$@"
|
||||
124
saturn/start-codex.sh
Executable file
124
saturn/start-codex.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STATE_FILE="$SCRIPT_DIR/codex_context.yaml"
|
||||
INITIAL_INSTRUCTION="Before doing any task, read codex_context.yaml and treat it as shared project memory."
|
||||
MEMORY_POLICY_PROMPT=$(cat <<'EOF'
|
||||
You are an autonomous coding agent with persistent memory.
|
||||
|
||||
GOAL:
|
||||
Continuously maintain and save a compressed, lossless SESSION_STATE to disk.
|
||||
|
||||
RULES:
|
||||
|
||||
1. After every meaningful change or decision:
|
||||
- Update SESSION_STATE
|
||||
- Save it to file: ./codex_context.yaml
|
||||
|
||||
2. SESSION_STATE must be:
|
||||
- Lossless (no important info lost)
|
||||
- Compressed (no fluff, structured)
|
||||
|
||||
FORMAT:
|
||||
|
||||
SESSION_STATE:
|
||||
meta:
|
||||
goal:
|
||||
environment:
|
||||
systems:
|
||||
configs:
|
||||
decisions:
|
||||
todos:
|
||||
issues:
|
||||
|
||||
3. Compression rules:
|
||||
- No natural language fluff
|
||||
- Use short keys
|
||||
- Deduplicate
|
||||
- Use IDs (S1, CFG1, etc.)
|
||||
|
||||
4. File operations:
|
||||
- Always overwrite ./codex_context.yaml
|
||||
- Ensure valid YAML
|
||||
- Never delete info unless explicitly told
|
||||
|
||||
5. On startup:
|
||||
- If ./codex_context.yaml exists -> load and use it as memory
|
||||
|
||||
6. Commands:
|
||||
|
||||
EXPORT STATE -> print file content only
|
||||
IMPORT STATE -> load given YAML
|
||||
|
||||
7. Never ask for confirmation when saving context.
|
||||
|
||||
Act like a stateful system, not a stateless chat.
|
||||
EOF
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./start-codex.sh [--print-prompt] [codex-args...]
|
||||
|
||||
Starts Codex in this repository with the persistent-memory bootstrap prompt.
|
||||
|
||||
Options:
|
||||
--print-prompt Print the generated startup prompt and exit.
|
||||
-h, --help Show this help and exit.
|
||||
|
||||
Examples:
|
||||
./start-codex.sh
|
||||
./start-codex.sh --full-auto
|
||||
./start-codex.sh --model gpt-5.4
|
||||
./start-codex.sh --print-prompt
|
||||
EOF
|
||||
}
|
||||
|
||||
build_prompt() {
|
||||
printf '%s\n\n' "$INITIAL_INSTRUCTION"
|
||||
printf '%s\n\n' "$MEMORY_POLICY_PROMPT"
|
||||
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
printf '%s\n\n' 'Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly.'
|
||||
cat "$STATE_FILE"
|
||||
printf '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local print_prompt=0
|
||||
local -a codex_args=()
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--print-prompt)
|
||||
print_prompt=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
codex_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local prompt
|
||||
prompt="$(build_prompt)"
|
||||
|
||||
if ((print_prompt)); then
|
||||
printf '%s' "$prompt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
printf 'Loading %s\n' "$STATE_FILE"
|
||||
exec codex --cd "$SCRIPT_DIR" "${codex_args[@]}" "$prompt"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
8
saturn/update-context.md
Normal file
8
saturn/update-context.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Shared Context Protocol
|
||||
|
||||
- Shared memory file: `codex_context.yaml`
|
||||
- Codex and Aider must both read `codex_context.yaml` before starting work.
|
||||
- After any meaningful work, update `codex_context.yaml`.
|
||||
- Record decisions, todos, issues, and host-specific state.
|
||||
- Keep the file compressed, structured, and lossless.
|
||||
- Do not store secrets in `codex_context.yaml`.
|
||||
Loading…
Reference in a new issue