Commit Β·
e110d9d
0
Parent(s):
initial commit
Browse files- Dockerfile +38 -0
- LICENSE +21 -0
- README.md +57 -0
- health-server.js +97 -0
- n8n-sync.py +240 -0
- setup-uptimerobot.sh +67 -0
- start.sh +79 -0
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-slim
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
+
N8N_PORT=5678 \
|
| 5 |
+
HF_HUB_DISABLE_PROGRESS_BARS=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
ca-certificates \
|
| 10 |
+
curl \
|
| 11 |
+
git \
|
| 12 |
+
jq \
|
| 13 |
+
python3 \
|
| 14 |
+
python3-pip \
|
| 15 |
+
sqlite3 \
|
| 16 |
+
tini \
|
| 17 |
+
&& pip3 install --no-cache-dir --break-system-packages huggingface_hub==0.34.4 \
|
| 18 |
+
&& npm install -g n8n@latest \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
RUN mkdir -p /home/node/app /home/node/.n8n && \
|
| 22 |
+
chown -R node:node /home/node
|
| 23 |
+
|
| 24 |
+
WORKDIR /home/node/app
|
| 25 |
+
|
| 26 |
+
COPY --chown=node:node health-server.js /home/node/app/health-server.js
|
| 27 |
+
COPY --chown=node:node n8n-sync.py /home/node/app/n8n-sync.py
|
| 28 |
+
COPY --chown=node:node setup-uptimerobot.sh /home/node/app/setup-uptimerobot.sh
|
| 29 |
+
COPY --chown=node:node start.sh /home/node/app/start.sh
|
| 30 |
+
|
| 31 |
+
RUN chmod +x /home/node/app/start.sh /home/node/app/setup-uptimerobot.sh
|
| 32 |
+
|
| 33 |
+
USER node
|
| 34 |
+
|
| 35 |
+
EXPOSE 7861
|
| 36 |
+
|
| 37 |
+
ENTRYPOINT ["/usr/bin/tini", "--"]
|
| 38 |
+
CMD ["/home/node/app/start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Somrat Sorkar (@somratpro)
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Hugging8n
|
| 3 |
+
emoji: "8"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7861
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Hugging8n
|
| 13 |
+
|
| 14 |
+
Run a self-hosted n8n instance on Hugging Face Spaces without external database services.
|
| 15 |
+
|
| 16 |
+
This Space uses:
|
| 17 |
+
|
| 18 |
+
- `n8n` running locally on port `5678`
|
| 19 |
+
- a small proxy/health server on port `7861` for HF Spaces
|
| 20 |
+
- periodic backup of `/home/node/.n8n` to a private Hugging Face Dataset
|
| 21 |
+
|
| 22 |
+
## Required secret
|
| 23 |
+
|
| 24 |
+
- `HF_TOKEN`: Hugging Face token with write access to create/update a private dataset backup
|
| 25 |
+
|
| 26 |
+
## Recommended variables
|
| 27 |
+
|
| 28 |
+
- `BACKUP_DATASET_NAME`: backup dataset name. Default: `hugging8n-backup`
|
| 29 |
+
- `SYNC_INTERVAL`: backup interval in seconds. Default: `180`
|
| 30 |
+
- `GENERIC_TIMEZONE`: timezone for schedule triggers. Example: `Asia/Dhaka`
|
| 31 |
+
- `N8N_ENCRYPTION_KEY`: optional explicit encryption key. If omitted, n8n stores it inside `.n8n` and the backup preserves it
|
| 32 |
+
- `N8N_BASIC_AUTH_ACTIVE`: set to `true` if you want built-in basic auth
|
| 33 |
+
- `N8N_BASIC_AUTH_USER`: basic auth username
|
| 34 |
+
- `N8N_BASIC_AUTH_PASSWORD`: basic auth password
|
| 35 |
+
|
| 36 |
+
## Optional variables
|
| 37 |
+
|
| 38 |
+
- `HF_USERNAME`: owner of the backup dataset. By default this is inferred from `SPACE_AUTHOR_NAME`
|
| 39 |
+
- `SPACE_HOST_OVERRIDE`: set this only if you want to override the detected Space hostname
|
| 40 |
+
- `N8N_DIAGNOSTICS_ENABLED=false`
|
| 41 |
+
- `N8N_PERSONALIZATION_ENABLED=false`
|
| 42 |
+
|
| 43 |
+
## How persistence works
|
| 44 |
+
|
| 45 |
+
On startup, the Space restores `/home/node/.n8n` from a private dataset repo.
|
| 46 |
+
|
| 47 |
+
While running, it watches for changes and uploads the updated `.n8n` directory back to the dataset. This preserves:
|
| 48 |
+
|
| 49 |
+
- workflows
|
| 50 |
+
- credentials
|
| 51 |
+
- users
|
| 52 |
+
- SQLite database
|
| 53 |
+
- encryption key
|
| 54 |
+
|
| 55 |
+
## Keep-alive
|
| 56 |
+
|
| 57 |
+
If you already solved the sleep problem in your HF setup, use that. If not, `setup-uptimerobot.sh` can create an external monitor for `https://<your-space>.hf.space/health`.
|
health-server.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const http = require("http");
|
| 2 |
+
const net = require("net");
|
| 3 |
+
const fs = require("fs");
|
| 4 |
+
|
| 5 |
+
const PUBLIC_PORT = Number(process.env.PUBLIC_PORT || 7861);
|
| 6 |
+
const TARGET_PORT = Number(process.env.N8N_PORT || 5678);
|
| 7 |
+
const TARGET_HOST = "127.0.0.1";
|
| 8 |
+
const STATUS_FILE = "/tmp/hugging8n-sync-status.json";
|
| 9 |
+
|
| 10 |
+
function getStatus() {
|
| 11 |
+
try {
|
| 12 |
+
return JSON.parse(fs.readFileSync(STATUS_FILE, "utf8"));
|
| 13 |
+
} catch {
|
| 14 |
+
return {
|
| 15 |
+
status: "unknown",
|
| 16 |
+
message: "No sync status yet",
|
| 17 |
+
timestamp: new Date().toISOString(),
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function writeJson(res, statusCode, payload) {
|
| 23 |
+
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
| 24 |
+
res.end(JSON.stringify(payload));
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function buildHeaders(req) {
|
| 28 |
+
return {
|
| 29 |
+
...req.headers,
|
| 30 |
+
host: req.headers.host || "",
|
| 31 |
+
"x-forwarded-for": req.socket.remoteAddress || "",
|
| 32 |
+
"x-forwarded-host": req.headers.host || "",
|
| 33 |
+
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const server = http.createServer((req, res) => {
|
| 38 |
+
if (req.url === "/health") {
|
| 39 |
+
return writeJson(res, 200, {
|
| 40 |
+
ok: true,
|
| 41 |
+
service: "hugging8n",
|
| 42 |
+
n8nPort: TARGET_PORT,
|
| 43 |
+
...getStatus(),
|
| 44 |
+
});
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if (req.url === "/status") {
|
| 48 |
+
return writeJson(res, 200, getStatus());
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const upstream = http.request(
|
| 52 |
+
{
|
| 53 |
+
hostname: TARGET_HOST,
|
| 54 |
+
port: TARGET_PORT,
|
| 55 |
+
path: req.url,
|
| 56 |
+
method: req.method,
|
| 57 |
+
headers: buildHeaders(req),
|
| 58 |
+
},
|
| 59 |
+
(upstreamRes) => {
|
| 60 |
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
|
| 61 |
+
upstreamRes.pipe(res);
|
| 62 |
+
},
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
upstream.on("error", (error) => {
|
| 66 |
+
writeJson(res, 502, {
|
| 67 |
+
ok: false,
|
| 68 |
+
error: "upstream_unavailable",
|
| 69 |
+
detail: error.message,
|
| 70 |
+
});
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
req.pipe(upstream);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
server.on("upgrade", (req, socket, head) => {
|
| 77 |
+
const upstream = net.connect(TARGET_PORT, TARGET_HOST, () => {
|
| 78 |
+
socket.write(
|
| 79 |
+
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
|
| 80 |
+
Object.entries(buildHeaders(req))
|
| 81 |
+
.map(([key, value]) => `${key}: ${value}`)
|
| 82 |
+
.join("\r\n") +
|
| 83 |
+
"\r\n\r\n",
|
| 84 |
+
);
|
| 85 |
+
if (head && head.length) upstream.write(head);
|
| 86 |
+
upstream.pipe(socket);
|
| 87 |
+
socket.pipe(upstream);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
upstream.on("error", () => {
|
| 91 |
+
socket.destroy();
|
| 92 |
+
});
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
server.listen(PUBLIC_PORT, "0.0.0.0", () => {
|
| 96 |
+
console.log(`Hugging8n proxy listening on ${PUBLIC_PORT}, forwarding to ${TARGET_PORT}`);
|
| 97 |
+
});
|
n8n-sync.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import signal
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
| 15 |
+
|
| 16 |
+
from huggingface_hub import HfApi, snapshot_download, upload_folder
|
| 17 |
+
from huggingface_hub.errors import RepositoryNotFoundError
|
| 18 |
+
|
| 19 |
+
N8N_HOME = Path(os.environ.get("N8N_USER_FOLDER", "/home/node/.n8n"))
|
| 20 |
+
STATUS_FILE = Path("/tmp/hugging8n-sync-status.json")
|
| 21 |
+
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
|
| 22 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 23 |
+
HF_USERNAME = (
|
| 24 |
+
os.environ.get("HF_USERNAME", "").strip()
|
| 25 |
+
or os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
| 26 |
+
)
|
| 27 |
+
BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "hugging8n-backup").strip()
|
| 28 |
+
HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 29 |
+
RUNNING = True
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def write_status(status: str, message: str) -> None:
|
| 33 |
+
payload = {
|
| 34 |
+
"status": status,
|
| 35 |
+
"message": message,
|
| 36 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 37 |
+
}
|
| 38 |
+
STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def dataset_repo_id() -> str:
|
| 42 |
+
if not HF_USERNAME:
|
| 43 |
+
raise RuntimeError("HF_USERNAME or SPACE_AUTHOR_NAME is required for backup repo naming")
|
| 44 |
+
return f"{HF_USERNAME}/{BACKUP_DATASET_NAME}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def ensure_repo_exists() -> str:
|
| 48 |
+
repo_id = dataset_repo_id()
|
| 49 |
+
try:
|
| 50 |
+
HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
|
| 51 |
+
except RepositoryNotFoundError:
|
| 52 |
+
HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
|
| 53 |
+
return repo_id
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def fingerprint_dir(root: Path) -> str:
|
| 57 |
+
hasher = hashlib.sha256()
|
| 58 |
+
if not root.exists():
|
| 59 |
+
return hasher.hexdigest()
|
| 60 |
+
|
| 61 |
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
| 62 |
+
rel = path.relative_to(root).as_posix()
|
| 63 |
+
if rel.startswith(".cache/"):
|
| 64 |
+
continue
|
| 65 |
+
hasher.update(rel.encode("utf-8"))
|
| 66 |
+
with path.open("rb") as handle:
|
| 67 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 68 |
+
hasher.update(chunk)
|
| 69 |
+
return hasher.hexdigest()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def create_snapshot_dir(source_root: Path) -> Path:
|
| 73 |
+
staging_root = Path(tempfile.mkdtemp(prefix="hugging8n-sync-"))
|
| 74 |
+
database_path = source_root / "database.sqlite"
|
| 75 |
+
|
| 76 |
+
for path in sorted(source_root.rglob("*")):
|
| 77 |
+
rel = path.relative_to(source_root)
|
| 78 |
+
rel_posix = rel.as_posix()
|
| 79 |
+
if rel_posix.startswith(".cache/"):
|
| 80 |
+
continue
|
| 81 |
+
target = staging_root / rel
|
| 82 |
+
if path.is_dir():
|
| 83 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
continue
|
| 85 |
+
if rel_posix in {"database.sqlite", "database.sqlite-shm", "database.sqlite-wal"}:
|
| 86 |
+
continue
|
| 87 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
shutil.copy2(path, target)
|
| 89 |
+
|
| 90 |
+
if database_path.exists():
|
| 91 |
+
target_db = staging_root / "database.sqlite"
|
| 92 |
+
target_db.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
try:
|
| 94 |
+
subprocess.run(
|
| 95 |
+
["sqlite3", str(database_path), f".backup {target_db}"],
|
| 96 |
+
check=True,
|
| 97 |
+
stdout=subprocess.DEVNULL,
|
| 98 |
+
stderr=subprocess.DEVNULL,
|
| 99 |
+
)
|
| 100 |
+
except Exception:
|
| 101 |
+
shutil.copy2(database_path, target_db)
|
| 102 |
+
for suffix in ("-wal", "-shm"):
|
| 103 |
+
sidecar = source_root / f"database.sqlite{suffix}"
|
| 104 |
+
if sidecar.exists():
|
| 105 |
+
shutil.copy2(sidecar, staging_root / sidecar.name)
|
| 106 |
+
|
| 107 |
+
return staging_root
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def restore() -> bool:
|
| 111 |
+
if not HF_TOKEN:
|
| 112 |
+
write_status("disabled", "HF_TOKEN is not configured.")
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
repo_id = dataset_repo_id()
|
| 116 |
+
write_status("restoring", f"Restoring state from {repo_id}")
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 120 |
+
snapshot_download(
|
| 121 |
+
repo_id=repo_id,
|
| 122 |
+
repo_type="dataset",
|
| 123 |
+
token=HF_TOKEN,
|
| 124 |
+
local_dir=tmpdir,
|
| 125 |
+
local_dir_use_symlinks=False,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
tmp_path = Path(tmpdir)
|
| 129 |
+
if not any(tmp_path.iterdir()):
|
| 130 |
+
write_status("fresh", "Backup dataset is empty. Starting fresh.")
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
N8N_HOME.mkdir(parents=True, exist_ok=True)
|
| 134 |
+
for child in N8N_HOME.iterdir():
|
| 135 |
+
if child.name == ".cache":
|
| 136 |
+
continue
|
| 137 |
+
if child.is_dir():
|
| 138 |
+
shutil.rmtree(child, ignore_errors=True)
|
| 139 |
+
else:
|
| 140 |
+
child.unlink(missing_ok=True)
|
| 141 |
+
|
| 142 |
+
for child in tmp_path.iterdir():
|
| 143 |
+
if child.name == ".cache":
|
| 144 |
+
continue
|
| 145 |
+
destination = N8N_HOME / child.name
|
| 146 |
+
if child.is_dir():
|
| 147 |
+
shutil.copytree(child, destination)
|
| 148 |
+
else:
|
| 149 |
+
shutil.copy2(child, destination)
|
| 150 |
+
|
| 151 |
+
write_status("restored", f"Restored state from {repo_id}")
|
| 152 |
+
return True
|
| 153 |
+
except RepositoryNotFoundError:
|
| 154 |
+
write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
|
| 155 |
+
return True
|
| 156 |
+
except Exception as exc:
|
| 157 |
+
write_status("error", f"Restore failed: {exc}")
|
| 158 |
+
print(f"Restore failed: {exc}", file=sys.stderr)
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def sync_once(last_fingerprint: str | None = None) -> str:
|
| 163 |
+
if not HF_TOKEN:
|
| 164 |
+
write_status("disabled", "HF_TOKEN is not configured.")
|
| 165 |
+
return last_fingerprint or ""
|
| 166 |
+
|
| 167 |
+
repo_id = ensure_repo_exists()
|
| 168 |
+
current_fingerprint = fingerprint_dir(N8N_HOME)
|
| 169 |
+
if last_fingerprint is not None and current_fingerprint == last_fingerprint:
|
| 170 |
+
write_status("idle", "No state changes detected.")
|
| 171 |
+
return last_fingerprint
|
| 172 |
+
|
| 173 |
+
write_status("syncing", f"Uploading state to {repo_id}")
|
| 174 |
+
snapshot_dir = create_snapshot_dir(N8N_HOME)
|
| 175 |
+
try:
|
| 176 |
+
upload_folder(
|
| 177 |
+
folder_path=str(snapshot_dir),
|
| 178 |
+
repo_id=repo_id,
|
| 179 |
+
repo_type="dataset",
|
| 180 |
+
token=HF_TOKEN,
|
| 181 |
+
commit_message=f"Hugging8n sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 182 |
+
ignore_patterns=[".cache/*"],
|
| 183 |
+
)
|
| 184 |
+
finally:
|
| 185 |
+
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 186 |
+
write_status("success", f"Uploaded state to {repo_id}")
|
| 187 |
+
return current_fingerprint
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def handle_signal(_sig, _frame) -> None:
|
| 191 |
+
global RUNNING
|
| 192 |
+
RUNNING = False
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def loop() -> int:
|
| 196 |
+
signal.signal(signal.SIGTERM, handle_signal)
|
| 197 |
+
signal.signal(signal.SIGINT, handle_signal)
|
| 198 |
+
|
| 199 |
+
last_fingerprint = fingerprint_dir(N8N_HOME)
|
| 200 |
+
write_status("configured", f"Backup loop active with {INTERVAL}s interval.")
|
| 201 |
+
|
| 202 |
+
while RUNNING:
|
| 203 |
+
try:
|
| 204 |
+
last_fingerprint = sync_once(last_fingerprint)
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
write_status("error", f"Sync failed: {exc}")
|
| 207 |
+
print(f"Sync failed: {exc}", file=sys.stderr)
|
| 208 |
+
for _ in range(INTERVAL):
|
| 209 |
+
if not RUNNING:
|
| 210 |
+
break
|
| 211 |
+
time.sleep(1)
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
sync_once(None)
|
| 215 |
+
except Exception as exc:
|
| 216 |
+
write_status("error", f"Final sync failed: {exc}")
|
| 217 |
+
print(f"Final sync failed: {exc}", file=sys.stderr)
|
| 218 |
+
return 0
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def main() -> int:
|
| 222 |
+
if len(sys.argv) < 2:
|
| 223 |
+
print("Usage: n8n-sync.py [restore|sync-once|loop]", file=sys.stderr)
|
| 224 |
+
return 1
|
| 225 |
+
|
| 226 |
+
command = sys.argv[1]
|
| 227 |
+
if command == "restore":
|
| 228 |
+
return 0 if restore() else 1
|
| 229 |
+
if command == "sync-once":
|
| 230 |
+
sync_once(None)
|
| 231 |
+
return 0
|
| 232 |
+
if command == "loop":
|
| 233 |
+
return loop()
|
| 234 |
+
|
| 235 |
+
print(f"Unknown command: {command}", file=sys.stderr)
|
| 236 |
+
return 1
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
raise SystemExit(main())
|
setup-uptimerobot.sh
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
API_URL="https://api.uptimerobot.com/v2"
|
| 5 |
+
API_KEY="${UPTIMEROBOT_API_KEY:-}"
|
| 6 |
+
SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
|
| 7 |
+
|
| 8 |
+
if [ -z "$API_KEY" ]; then
|
| 9 |
+
echo "Missing UPTIMEROBOT_API_KEY."
|
| 10 |
+
exit 1
|
| 11 |
+
fi
|
| 12 |
+
|
| 13 |
+
if [ -z "$SPACE_HOST_INPUT" ]; then
|
| 14 |
+
echo "Missing Space host."
|
| 15 |
+
echo "Usage: UPTIMEROBOT_API_KEY=... ./setup-uptimerobot.sh your-space.hf.space"
|
| 16 |
+
exit 1
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
|
| 20 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
|
| 21 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
|
| 22 |
+
|
| 23 |
+
MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
|
| 24 |
+
MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-Hugging8n ${SPACE_HOST_CLEAN}}"
|
| 25 |
+
INTERVAL="${UPTIMEROBOT_INTERVAL:-5}"
|
| 26 |
+
|
| 27 |
+
MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
|
| 28 |
+
-d "api_key=${API_KEY}" \
|
| 29 |
+
-d "format=json" \
|
| 30 |
+
-d "logs=0" \
|
| 31 |
+
-d "response_times=0" \
|
| 32 |
+
-d "response_times_limit=1")
|
| 33 |
+
|
| 34 |
+
MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
|
| 35 |
+
(.monitors // []) | map(select(.url == $url)) | first | .id // empty
|
| 36 |
+
')
|
| 37 |
+
|
| 38 |
+
if [ -n "$MONITOR_ID" ]; then
|
| 39 |
+
echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
|
| 40 |
+
exit 0
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
+
CURL_ARGS=(
|
| 44 |
+
-sS
|
| 45 |
+
-X POST "${API_URL}/newMonitor"
|
| 46 |
+
-d "api_key=${API_KEY}"
|
| 47 |
+
-d "format=json"
|
| 48 |
+
-d "type=1"
|
| 49 |
+
-d "friendly_name=${MONITOR_NAME}"
|
| 50 |
+
-d "url=${MONITOR_URL}"
|
| 51 |
+
-d "interval=${INTERVAL}"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
|
| 55 |
+
CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
|
| 56 |
+
fi
|
| 57 |
+
|
| 58 |
+
CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
|
| 59 |
+
CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
|
| 60 |
+
|
| 61 |
+
if [ "$CREATE_STATUS" != "ok" ]; then
|
| 62 |
+
printf '%s\n' "$CREATE_RESPONSE"
|
| 63 |
+
exit 1
|
| 64 |
+
fi
|
| 65 |
+
|
| 66 |
+
NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
|
| 67 |
+
echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
|
start.sh
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
APP_DIR="/home/node/app"
|
| 5 |
+
N8N_HOME="/home/node/.n8n"
|
| 6 |
+
N8N_PORT="${N8N_PORT:-5678}"
|
| 7 |
+
PUBLIC_PORT="${PUBLIC_PORT:-7861}"
|
| 8 |
+
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
| 9 |
+
|
| 10 |
+
mkdir -p "$N8N_HOME"
|
| 11 |
+
|
| 12 |
+
SPACE_HOST_DETECTED="${SPACE_HOST_OVERRIDE:-${SPACE_HOST:-}}"
|
| 13 |
+
if [ -n "$SPACE_HOST_DETECTED" ]; then
|
| 14 |
+
export N8N_HOST="${N8N_HOST:-$SPACE_HOST_DETECTED}"
|
| 15 |
+
export WEBHOOK_URL="${WEBHOOK_URL:-https://${SPACE_HOST_DETECTED}/}"
|
| 16 |
+
export N8N_EDITOR_BASE_URL="${N8N_EDITOR_BASE_URL:-https://${SPACE_HOST_DETECTED}/}"
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
export N8N_PORT
|
| 20 |
+
export N8N_PROTOCOL="${N8N_PROTOCOL:-https}"
|
| 21 |
+
export N8N_PROXY_HOPS="${N8N_PROXY_HOPS:-1}"
|
| 22 |
+
export N8N_LISTEN_ADDRESS="${N8N_LISTEN_ADDRESS:-0.0.0.0}"
|
| 23 |
+
export N8N_SECURE_COOKIE="${N8N_SECURE_COOKIE:-true}"
|
| 24 |
+
export N8N_DIAGNOSTICS_ENABLED="${N8N_DIAGNOSTICS_ENABLED:-false}"
|
| 25 |
+
export N8N_PERSONALIZATION_ENABLED="${N8N_PERSONALIZATION_ENABLED:-false}"
|
| 26 |
+
export N8N_USER_FOLDER="$N8N_HOME"
|
| 27 |
+
export N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS="${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}"
|
| 28 |
+
export GENERIC_TIMEZONE="${GENERIC_TIMEZONE:-${TZ:-UTC}}"
|
| 29 |
+
export TZ="${TZ:-$GENERIC_TIMEZONE}"
|
| 30 |
+
|
| 31 |
+
echo ""
|
| 32 |
+
echo " ββββββββββββββββββββββββββββββββββββββ"
|
| 33 |
+
echo " β Hugging8n β"
|
| 34 |
+
echo " ββββββββββββββββββββββββββββββββββββββ"
|
| 35 |
+
echo ""
|
| 36 |
+
echo "Public host : ${SPACE_HOST_DETECTED:-not detected}"
|
| 37 |
+
echo "n8n port : ${N8N_PORT}"
|
| 38 |
+
echo "Public port : ${PUBLIC_PORT}"
|
| 39 |
+
echo "Timezone : ${GENERIC_TIMEZONE}"
|
| 40 |
+
echo "Sync every : ${SYNC_INTERVAL}s"
|
| 41 |
+
|
| 42 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 43 |
+
echo "Restoring persisted n8n state from HF Dataset..."
|
| 44 |
+
python3 "$APP_DIR/n8n-sync.py" restore || true
|
| 45 |
+
else
|
| 46 |
+
echo "HF_TOKEN is not set. Running without dataset persistence."
|
| 47 |
+
fi
|
| 48 |
+
|
| 49 |
+
cleanup() {
|
| 50 |
+
echo "Stopping Hugging8n..."
|
| 51 |
+
if [ -n "${SYNC_PID:-}" ] && kill -0 "$SYNC_PID" 2>/dev/null; then
|
| 52 |
+
kill "$SYNC_PID" 2>/dev/null || true
|
| 53 |
+
fi
|
| 54 |
+
if [ -n "${N8N_PID:-}" ] && kill -0 "$N8N_PID" 2>/dev/null; then
|
| 55 |
+
kill "$N8N_PID" 2>/dev/null || true
|
| 56 |
+
fi
|
| 57 |
+
if [ -n "${PROXY_PID:-}" ] && kill -0 "$PROXY_PID" 2>/dev/null; then
|
| 58 |
+
kill "$PROXY_PID" 2>/dev/null || true
|
| 59 |
+
fi
|
| 60 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 61 |
+
echo "Running final backup pass..."
|
| 62 |
+
python3 "$APP_DIR/n8n-sync.py" sync-once || true
|
| 63 |
+
fi
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
trap cleanup EXIT INT TERM
|
| 67 |
+
|
| 68 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 69 |
+
python3 "$APP_DIR/n8n-sync.py" loop &
|
| 70 |
+
SYNC_PID=$!
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
+
node "$APP_DIR/health-server.js" &
|
| 74 |
+
PROXY_PID=$!
|
| 75 |
+
|
| 76 |
+
n8n start &
|
| 77 |
+
N8N_PID=$!
|
| 78 |
+
|
| 79 |
+
wait "$N8N_PID"
|