From 7c4ea9affe88fed779cc3191a144ef66c9380c2a Mon Sep 17 00:00:00 2001 From: Nick Yeoman Date: Thu, 26 Feb 2026 22:02:48 -0800 Subject: [PATCH] added dockhand --- _build/portainer_template_build.py | 206 ----------------------------- _docs/smtp-servers.md | 11 -- dockhand/README.md | 42 ++++++ dockhand/docker-compose.yml | 11 ++ dockhand/sample.env | 1 + 5 files changed, 54 insertions(+), 217 deletions(-) delete mode 100644 _build/portainer_template_build.py delete mode 100644 _docs/smtp-servers.md create mode 100644 dockhand/README.md create mode 100644 dockhand/docker-compose.yml create mode 100644 dockhand/sample.env diff --git a/_build/portainer_template_build.py b/_build/portainer_template_build.py deleted file mode 100644 index 233aae3..0000000 --- a/_build/portainer_template_build.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -""" -Portainer templates generator (Portainer CE compatible, v3 format) -Usage: - python3 _build/portainer_template_build.py [--nologo] [--branch BRANCH] [--output PATH] [--no-skip-volpath] -Defaults: - branch = master - output = ./templates.json - skips projects containing "${VOL_PATH" by default (use --no-skip-volpath to disable) - Note: Generates v3 format with unique IDs to fix compatibility issues in Portainer 2.22+. -""" -from pathlib import Path -import argparse -import json -import re -import requests -import sys -from typing import List, Dict -# ------------------------- -# Config / Defaults -# ------------------------- -SCRIPT_DIR = Path(__file__).resolve().parent -REPO_ROOT = SCRIPT_DIR.parent -DEFAULT_OUTPUT = REPO_ROOT / "templates.json" -ASSETS_BASE = "https://i.4lt.ca/cookbooks" -GITHUB_RAW_BASE_TEMPLATE = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}" -GITHUB_OWNER = "nickyeoman" -GITHUB_REPO = "docker-compose-cookbooks" -DEFAULT_BRANCH = "master" -SKIP_VOLPATH_PATTERN = "${VOL_PATH" -# Regex -VAR_RE = re.compile(r"\$\{([^:}]+)(?:[:-]([^}]+))?\}") -# ------------------------- -# Helpers -# ------------------------- -def read_text_safe(p: Path) -> str: - try: - return p.read_text() - except Exception: - return "" -def write_atomic(path: Path, data: str): - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(data) - tmp.replace(path) -def slugify_for_name(s: str) -> str: - # produce a safe component for volume names etc. - s = s.strip().lower() - s = re.sub(r"[^a-z0-9_\-]+", "-", s) - s = re.sub(r"-{2,}", "-", s) - s = s.strip("-") - if not s: - return "x" - return s -# ------------------------- -# README Overview extraction -# ------------------------- -def extract_overview(readme_path: Path, project_name: str) -> str: - text = read_text_safe(readme_path) - if not text: - return f"{project_name} description to come" - lines = text.splitlines() - overview = [] - in_section = False - for line in lines: - stripped = line.strip() - if stripped == "## Overview": - in_section = True - continue - if in_section: - if stripped.startswith("#"): - break - if stripped: - overview.append(stripped) - if not overview: - return f"{project_name} description to come" - return " ".join(overview) -# ------------------------- -# Logo lookup (HEAD check) -# ------------------------- -def find_logo_url(dir_path: Path, nologo: bool, branch: str) -> str: - name = dir_path.name - # Use branch for asset if needed, but since assets are static, ignore branch here - logo = f"{ASSETS_BASE}/{name}.png" - default = f"{ASSETS_BASE}/default.png" - if nologo: - return default - try: - r = requests.head(logo, timeout=4) - if r.status_code == 200: - return logo - except Exception: - pass - return default -# ------------------------- -# Parse environment variables from sample.env/env-sample -# ------------------------- -def parse_env_vars(dir_path: Path) -> List[Dict[str,str]]: - candidates = [dir_path / "sample.env", dir_path / "env-sample"] - env_file = None - for c in candidates: - if c.exists(): - env_file = c - break - if not env_file: - return [] - out = [] - for raw in read_text_safe(env_file).splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, val = line.split("=", 1) - key = key.strip() - # strip inline comment after # - val = val.split("#", 1)[0].strip() - val = val.strip().strip('"').strip("'") - out.append({"name": key, "default": val}) - return out -# ------------------------- -# Build Portainer template object -# ------------------------- -def generate_template_object(dir_path: Path, branch: str, nologo: bool, template_id: int) -> Dict: - name = dir_path.name - readme_path = dir_path / "README.md" - env_vars = parse_env_vars(dir_path) - obj = { - "id": template_id, # Unique ID for v3 format - "type": 3, - "title": name, - "name": name, - "note": "Auto-generated template from repository", - "categories": ["Auto"], - "platform": "linux", - "logo": find_logo_url(dir_path, nologo=nologo, branch=branch), - "description": extract_overview(readme_path, name), - # Git repository for Compose stack - "repository": { - "url": f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}", - "stackfile": f"{name}/docker-compose.yml" - } - } - # env - env_entries = [] - for v in env_vars: - if not v.get("name"): - continue - env_entries.append({ - "name": v["name"], - "label": v["name"], - "default": v.get("default","") - }) - if env_entries: - obj["env"] = env_entries - return obj -# ------------------------- -# Main -# ------------------------- -def main(): - parser = argparse.ArgumentParser(description="Generate Portainer templates.json from compose cookbooks") - parser.add_argument("--nologo", action="store_true", help="Skip HEAD check for logos (faster)") - parser.add_argument("--branch", default=DEFAULT_BRANCH, help="Git branch to use for logo lookup (default: master). Templates use repo default branch.") - parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="Output path for templates.json") - parser.add_argument("--no-skip-volpath", action="store_true", help="Don't skip projects containing ${VOL_PATH") - args = parser.parse_args() - out_path = Path(args.output).resolve() - branch = args.branch - nologo = args.nologo - skip_volpath = not args.no_skip_volpath - templates = [] - skipped = [] - errored = [] - template_counter = 1 # Start ID from 1 - for item in sorted(REPO_ROOT.iterdir()): - if not item.is_dir(): - continue - if item.name.startswith("_"): - continue - if item.name.endswith(("_dev", "_notes")): - continue - compose_path = item / "docker-compose.yml" - if not compose_path.exists(): - continue - text = read_text_safe(compose_path) - if skip_volpath and SKIP_VOLPATH_PATTERN in text: - skipped.append((item.name, "uses ${VOL_PATH}")) - continue - try: - tpl = generate_template_object(item, branch=branch, nologo=nologo, template_id=template_counter) - templates.append(tpl) - template_counter += 1 - except Exception as e: - errored.append((item.name, str(e))) - output = {"version": "3", "templates": templates} - # write atomic - write_atomic(out_path, json.dumps(output, indent=2)) - print(f"Generated {out_path} with {len(templates)} templates (v3 format with IDs).") - if skipped: - print("Skipped projects:") - for n, reason in skipped: - print(f" - {n}: {reason}") - if errored: - print("Errored projects:") - for n, err in errored: - print(f" - {n}: {err}") -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/_docs/smtp-servers.md b/_docs/smtp-servers.md deleted file mode 100644 index dd813db..0000000 --- a/_docs/smtp-servers.md +++ /dev/null @@ -1,11 +0,0 @@ -# SMTP servers - -List of SMTP servers that have a free tier. - -## SendGrid (100 emails per day) - -## MailJet (200 emails per day) - -## SendinBlue (300 emails per day) - -## SMTP2GO (1000 emails per month) diff --git a/dockhand/README.md b/dockhand/README.md new file mode 100644 index 0000000..1a4e1d1 --- /dev/null +++ b/dockhand/README.md @@ -0,0 +1,42 @@ +# Dockhand + +Port: 3000 + +I'm using this to replace portainer. + +## Run an Agent + +``` +docker run -d \ + --name hawser-dockhand-agent \ + --restart unless-stopped \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e DOCKHAND_URL="https://dockhand.example.com" \ + -e DOCKHAND_TOKEN="PFTtPtVFcJ7nCppmbVzqCN4pt4Vxzkjp" \ + ghcr.io/dockhand/hawser:latest +``` + +## Ansible + +This is how I got it working on ansible (roughly): + +``` +- name: HAWSER - Pull Docker Image + docker_image: + name: "ghcr.io/finsys/hawser:{{ hawser_version }}" + source: pull + +- name: HAWSER - Ensure container is running + docker_container: + name: hawser + recreate: true + image: "ghcr.io/finsys/hawser:{{ hawser_version }}" + state: started + restart_policy: always + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + env: + DOCKHAND_SERVER_URL: "{{ dockhand_url }}" + TOKEN: "{{ hawser_token }}" + AGENT_NAME: "{{ hawser_agent_name }}" +``` \ No newline at end of file diff --git a/dockhand/docker-compose.yml b/dockhand/docker-compose.yml new file mode 100644 index 0000000..8aae7d9 --- /dev/null +++ b/dockhand/docker-compose.yml @@ -0,0 +1,11 @@ +services: + dockhand: + image: fnsys/dockhand:latest + container_name: dockhand + restart: unless-stopped + ports: + - 3000:3000 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/dockhand_data:/app/data + diff --git a/dockhand/sample.env b/dockhand/sample.env new file mode 100644 index 0000000..69bc5b4 --- /dev/null +++ b/dockhand/sample.env @@ -0,0 +1 @@ +COMPOSE_PROJECT_NAME=dockhand