Skip to content

Commit 98eb2d7

Browse files
committed
add homelab docs generator script
1 parent ae13b94 commit 98eb2d7

File tree

2 files changed

+235
-1
lines changed

2 files changed

+235
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
test
2-
old
2+
old
3+
*.env

generate_docs.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import yaml
2+
from pathlib import Path
3+
from groq import Groq
4+
import re
5+
import os
6+
7+
system_prompt = {
8+
"role": "system",
9+
"content": "Your job is to write a maximum of 4 sentences about the home server / homelab service name provided by the user. Give a brief description of the service, its purpose, and any useful features. Not too formal.",
10+
}
11+
12+
13+
# chat history can be optional due to a user either deleting their chat history or starting a new one
14+
def get_chatbot_response(message):
15+
16+
chatbot_message = [system_prompt]
17+
18+
client = Groq(
19+
api_key=os.getenv("GROQ_API_KEY"),
20+
)
21+
22+
chatbot_message.append({"role": "user", "content": message})
23+
24+
# limiting max tokens to 1000
25+
chat_completion = client.chat.completions.create(
26+
messages=chatbot_message,
27+
model="llama-3.3-70b-versatile",
28+
max_tokens=1000,
29+
)
30+
31+
chatbot_message.append(
32+
{
33+
"role": "assistant",
34+
"content": chat_completion.choices[0].message.content,
35+
}
36+
)
37+
38+
return chat_completion.choices[0].message.content.strip()
39+
40+
41+
def extract_ports(compose_path):
42+
with open(compose_path, "r") as f:
43+
compose_data = yaml.safe_load(f)
44+
45+
ports_info = []
46+
try:
47+
services = compose_data.get("services", {})
48+
for service_name, service_data in services.items():
49+
ports = service_data.get("ports", [])
50+
if ports:
51+
# Use first external port only (format like "8080:80" or "8080:80/tcp")
52+
first_port = ports[0].split(":")[0].split("/")[0]
53+
ports_info.append((service_name, first_port))
54+
except Exception as e:
55+
print(f"Warning: Could not extract ports from {compose_path}: {e}")
56+
return ports_info
57+
58+
59+
def generate_markdown(service_folder_name, compose_content, ports_info, output_dir):
60+
# Build notes section with one line per service
61+
notes_lines = []
62+
# List of services to ignore when generating access notes
63+
ignored_access_notes_services = ["collegeguide-redis", "wings", "mysql-database"]
64+
# If the folder/file name contains '-disabled', skip all access notes
65+
if "-disabled" in service_folder_name:
66+
notes = "None"
67+
else:
68+
# --- Look for -api public URL in the compose file ---
69+
api_url_match = re.search(
70+
r"(https?://[\w\-]+-api\.jakefarrell\.ie)", compose_content
71+
)
72+
backend_url = None
73+
frontend_url = None
74+
if api_url_match:
75+
backend_url = api_url_match.group(1)
76+
frontend_url = backend_url.replace("-api.jakefarrell.ie", ".jakefarrell.ie")
77+
# --- Look for any public URL or bare domain in env vars ---
78+
public_env_urls = {} # service_name -> public_url or domain
79+
try:
80+
compose_data = yaml.safe_load(compose_content)
81+
services = compose_data.get("services", {})
82+
for service_name, service_data in services.items():
83+
envs = service_data.get("environment", [])
84+
for env in envs:
85+
if isinstance(env, str):
86+
# Prefer full URL if present
87+
url_match = re.search(r"https?://[\w\-\.]+\.[a-z]{2,}", env)
88+
if url_match and service_name not in public_env_urls:
89+
public_env_urls[service_name] = url_match.group(0)
90+
# Otherwise, look for bare domain
91+
if service_name not in public_env_urls:
92+
for part in re.split(r"[=, ]", env):
93+
if re.match(
94+
r"[\w\-\.]+\.[a-z]{2,}$", part
95+
) and not part.startswith("http"):
96+
public_env_urls[service_name] = f"https://{part}"
97+
break
98+
elif isinstance(env, dict):
99+
for k, v in env.items():
100+
if isinstance(v, str):
101+
url_match = re.search(
102+
r"https?://[\w\-\.]+\.[a-z]{2,}", v
103+
)
104+
if url_match and service_name not in public_env_urls:
105+
public_env_urls[service_name] = url_match.group(0)
106+
if service_name not in public_env_urls:
107+
for part in re.split(r"[=, ]", v):
108+
if re.match(
109+
r"[\w\-\.]+\.[a-z]{2,}$", part
110+
) and not part.startswith("http"):
111+
public_env_urls[service_name] = (
112+
f"https://{part}"
113+
)
114+
break
115+
# If we didn't find URLs in parsed env vars, search the raw compose content for this service
116+
if service_name not in public_env_urls:
117+
# Try a simpler approach - just search for the service name and environment section
118+
service_start = compose_content.find(f"{service_name}:")
119+
if service_start != -1:
120+
# Find the environment section after this service
121+
env_start = compose_content.find("environment:", service_start)
122+
if env_start != -1:
123+
# Find the next section or end of file
124+
next_section = compose_content.find("\n ", env_start + 20)
125+
if next_section == -1:
126+
next_section = len(compose_content)
127+
env_section = compose_content[env_start:next_section]
128+
# Look for URLs in the raw environment section
129+
url_matches = re.findall(
130+
r"https?://[\w\-\.]+\.[a-z]{2,}", env_section
131+
)
132+
if url_matches:
133+
public_env_urls[service_name] = url_matches[0]
134+
except Exception as e:
135+
print(
136+
f"Warning: Could not extract public env URLs from compose_content: {e}"
137+
)
138+
# --- End new logic ---
139+
for service_name, port in ports_info:
140+
# Skip services in the ignored list
141+
if service_name in ignored_access_notes_services:
142+
continue
143+
# If we found an -api URL, use the public URLs for frontend/backend
144+
if backend_url and frontend_url:
145+
if "frontend" in service_name:
146+
notes_lines.append(
147+
f"- Access `{service_name}` at [{frontend_url}]({frontend_url}) (Publicly Accessible)"
148+
)
149+
elif "backend" in service_name:
150+
notes_lines.append(
151+
f"- Access `{service_name}` at [{backend_url}]({backend_url}) (Publicly Accessible)"
152+
)
153+
else:
154+
notes_lines.append(
155+
f"- Access `{service_name}` at [http://cheeselab:{port}](http://cheeselab:{port}) (Local Network Only)"
156+
)
157+
elif service_name in public_env_urls:
158+
notes_lines.append(
159+
f"- Access `{service_name}` at [{public_env_urls[service_name]}]({public_env_urls[service_name]}) (Publicly Accessible)"
160+
)
161+
else:
162+
notes_lines.append(
163+
f"- Access `{service_name}` at [http://cheeselab:{port}](http://cheeselab:{port}) (Local Network Only)"
164+
)
165+
notes = "\n".join(notes_lines) if notes_lines else "None"
166+
167+
title_name = service_folder_name.replace("-", " ").title()
168+
title_name = title_name.replace("Ca298", "CA298")
169+
title_name = title_name.replace("Cablenetwork", "Cable Network")
170+
title_name = title_name.replace("Collegeguide", "CollegeGuide")
171+
title_name = title_name.replace("Mysql", "MySQL")
172+
173+
print(f"Generating description for: {title_name}")
174+
description = get_chatbot_response(title_name)
175+
# description = "This is a test"
176+
notes = notes.replace(
177+
"[http://cheeselab:1313](http://cheeselab:1313) (Local Network Only)",
178+
"[https://collegeguide-blog.jakefarrell.ie](https://collegeguide-blog.jakefarrell.ie) (Publicly Accessible)",
179+
)
180+
181+
notes = notes.replace(
182+
"[http://cheeselab:802](http://cheeselab:802) (Local Network Only)",
183+
"[https://panel.cablenetwork.xyz](https://panel.cablenetwork.xyz) (Publicly Accessible)",
184+
)
185+
186+
notes = notes.replace("http://cheeselab:9443", "https://cheeselab:9443")
187+
188+
md_content = f"""# {title_name}
189+
190+
191+
192+
## Description
193+
194+
{description}
195+
196+
## Docker Compose File
197+
198+
```yaml
199+
{compose_content}
200+
```
201+
202+
## Notes
203+
204+
{notes}"""
205+
output_path = output_dir / f"{service_folder_name}.md"
206+
output_path.write_text(md_content)
207+
print(f"Generated: {output_path}")
208+
209+
210+
def main(services_dir, output_dir):
211+
services_dir = Path(services_dir)
212+
output_dir = Path(output_dir)
213+
output_dir.mkdir(parents=True, exist_ok=True)
214+
215+
for service_folder in services_dir.iterdir():
216+
if service_folder.is_dir():
217+
# Skip folders with '-disabled' in the name
218+
if "-disabled" in service_folder.name:
219+
continue
220+
compose_path = service_folder / "docker-compose.yml"
221+
if compose_path.exists():
222+
folder_name = service_folder.name
223+
with open(compose_path, "r") as f:
224+
compose_content = f.read()
225+
ports_info = extract_ports(compose_path)
226+
generate_markdown(folder_name, compose_content, ports_info, output_dir)
227+
228+
229+
if __name__ == "__main__":
230+
services_dir = "Y:\\home\\jake\\services"
231+
output_dir = "docs\\homelab\\services"
232+
233+
main(services_dir, output_dir)

0 commit comments

Comments
 (0)