arcsu1 commited on
Commit
08abefd
·
1 Parent(s): 9cc1cef

Refactor project structure: remove unused files, add FastAPI app and dependencies, and configure supervisord for process management

Browse files
Files changed (11) hide show
  1. .env.example +0 -50
  2. Dockerfile +25 -53
  3. README.md +1 -1
  4. app/main.py +50 -0
  5. init.sql +0 -79
  6. migrations/0001_chat_history.sql +0 -74
  7. proxy.py +0 -123
  8. requirements.txt +2 -0
  9. start.sh +0 -59
  10. supervisord.conf +33 -0
  11. tunnel.sh +56 -0
.env.example DELETED
@@ -1,50 +0,0 @@
1
- # ============================================================
2
- # PostgREST + PostgreSQL (Hugging Face Space) — env vars
3
- # Copy to `.env` and adjust. HF Spaces only allows PORT to be
4
- # set via the Space settings; everything else can live here.
5
- # ============================================================
6
-
7
- # --- Server ---
8
- # Port PostgREST listens on. HF Spaces requires 7860.
9
- PORT=7860
10
- # Bind address (keep 0.0.0.0 for HF Spaces)
11
- PGRST_SERVER_HOST=0.0.0.0
12
-
13
- # --- Database connection ---
14
- # Full connection URI. Inside this container Postgres runs on a
15
- # local unix socket, so the default below is usually fine.
16
- # For an external DB, use e.g.:
17
- # postgresql://authenticator:secret@db.example.com:5432/prod
18
- PGRST_DB_URI=postgresql://?host=/var/run/postgresql&port=5432&dbname=postgres
19
-
20
- # Comma-separated schemas exposed by the API
21
- PGRST_DB_SCHEMA=api
22
-
23
- # Role used for unauthenticated (anonymous) requests
24
- PGRST_DB_ANON_ROLE=web_anon
25
-
26
- # Role used to apply schema changes / own objects
27
- AUTHENTICATOR_PASSWORD=secret
28
-
29
- # --- Auth (optional) ---
30
- # Secret used to sign/verify JWTs. Generate one with:
31
- # echo "secret" | openssl dgst -sha256 -hmac "your-key" -binary | base64
32
- # Leave empty to disable JWT auth (public API).
33
- PGRST_JWT_SECRET=
34
- # Claim that carries the DB role in the JWT.
35
- # Must be a JSON path — it MUST start with a dot (or be a bracket
36
- # expression). "role" alone is a parse error and crashes PostgREST.
37
- PGRST_JWT_ROLE_CLAIM_KEY=.role
38
- # Audience claim to require, if any
39
- PGRST_JWT_AUDIENCE=
40
-
41
- # --- API behavior (optional) ---
42
- # Max rows per request (Content-Range limiting)
43
- PGRST_MAX_ROWS=
44
- # Allow CORS from any origin (useful for browser clients)
45
- PGRST_CORS_ALLOWED_ORIGINS=*
46
- # Path prefix if served behind a proxy (usually empty on HF)
47
- PGRST_SERVER_PATH_PREFIX=
48
-
49
- # --- PostgREST version pinned in the Dockerfile ---
50
- PGRST_VERSION=16.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,65 +1,37 @@
1
- # PostgREST + PostgreSQL in a single container (Hugging Face Space)
2
- # Serves a fully RESTful API from a Postgres database on port 7860.
3
 
4
- FROM debian:bookworm-slim
5
 
6
- ENV DEBIAN_FRONTEND=noninteractive \
7
- PGRST_VERSION=16.2 \
8
- PORT=7860 \
9
- PGRST_DB_SCHEMAS=api \
10
- PGRST_DB_ANON_ROLE=web_anon \
11
- PGRST_SERVER_HOST=0.0.0.0
12
-
13
- # --- System dependencies: PostgreSQL server + tools ---
14
  RUN apt-get update && apt-get install -y --no-install-recommends \
15
- ca-certificates \
16
- curl \
17
- libpq5 \
18
- postgresql \
19
- postgresql-contrib \
20
- postgresql-client \
21
- sudo \
22
- xz-utils \
23
- python3 \
24
  && rm -rf /var/lib/apt/lists/*
25
 
26
- # --- PostgREST binary (static build from GitHub releases) ---
27
- # v16+ assets are named linux-static-x86-64 / linux-static-aarch64
28
- RUN ARCH="$(dpkg --print-architecture)" \
29
- && case "$ARCH" in \
30
- amd64) PGRST_ARCH=x86-64 ;; \
31
- arm64) PGRST_ARCH=aarch64 ;; \
32
- *) echo "unsupported arch: $ARCH" && exit 1 ;; \
33
- esac \
34
- && curl -fsSL \
35
- "https://github.com/PostgREST/postgrest/releases/download/v${PGRST_VERSION}/postgrest-v${PGRST_VERSION}-linux-static-${PGRST_ARCH}.tar.xz" \
36
- -o /tmp/postgrest.tar.xz \
37
- && tar -xJf /tmp/postgrest.tar.xz -C /usr/local/bin \
38
- && chmod +x /usr/local/bin/postgrest \
39
- && rm /tmp/postgrest.tar.xz \
40
- && postgrest --version
41
 
42
- # --- Swagger UI static files (served at /docs by proxy.py) ---
43
- RUN curl -fsSL "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.15.tgz" \
44
- -o /tmp/swagger-ui.tgz \
45
- && mkdir -p /opt/swagger-ui \
46
- && tar -xzf /tmp/swagger-ui.tgz -C /opt/swagger-ui --strip-components=1 \
47
- && rm /tmp/swagger-ui.tgz
48
 
49
- # --- Non-root user (HF Spaces runs containers as a random UID) ---
50
- RUN useradd -m -u 10001 appuser \
51
- && mkdir -p /var/lib/postgresql/data /var/run/postgresql \
52
- && chown -R appuser:appuser /var/lib/postgresql /var/run/postgresql /usr/local/bin/postgrest
53
 
54
- USER appuser
55
- WORKDIR /home/appuser
56
 
57
- # --- Sample schema: a tiny "todos" API to demo PostgREST ---
58
- COPY --chown=appuser:appuser init.sql /docker-entrypoint-init.d/init.sql
59
- COPY --chown=appuser:appuser start.sh /usr/local/bin/start.sh
60
- COPY --chown=appuser:appuser proxy.py /usr/local/bin/proxy.py
61
- RUN chmod +x /usr/local/bin/start.sh /usr/local/bin/proxy.py
62
 
63
  EXPOSE 7860
64
 
65
- CMD ["start.sh"]
 
 
1
+ FROM postgres:16
 
2
 
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
 
5
+ # python for the FastAPI app, ssh client for the tunnel, supervisor to run
6
+ # postgres + the tunnel loop + the api as one PID-1-managed process tree.
 
 
 
 
 
 
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ python3 python3-pip \
9
+ openssh-client \
10
+ supervisor \
 
 
 
 
 
 
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
+ # Key so pinggy never blocks on an interactive password prompt (there's no
14
+ # TTY in a container). Accepted as an unauthenticated client key by pinggy's
15
+ # free tier -- it's just there to skip the "press enter" prompt.
16
+ RUN mkdir -p /root/.ssh \
17
+ && ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N "" -q
 
 
 
 
 
 
 
 
 
 
18
 
19
+ WORKDIR /app
20
+ COPY requirements.txt .
21
+ RUN pip3 install --no-cache-dir --break-system-packages --ignore-installed -r requirements.txt
22
+ COPY app/ ./app/
 
 
23
 
24
+ COPY tunnel.sh /usr/local/bin/tunnel.sh
25
+ RUN chmod +x /usr/local/bin/tunnel.sh
 
 
26
 
27
+ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
 
28
 
29
+ # Override these at `docker run -e ...` (don't ship real creds in the image).
30
+ ENV POSTGRES_PASSWORD=changeme
31
+ ENV POSTGRES_USER=postgres
32
+ ENV POSTGRES_DB=postgres
 
33
 
34
  EXPOSE 7860
35
 
36
+ ENTRYPOINT ["/usr/bin/supervisord"]
37
+ CMD ["-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Test
3
  emoji: 🔥
4
  colorFrom: yellow
5
  colorTo: indigo
 
1
  ---
2
+ title: All DB
3
  emoji: 🔥
4
  colorFrom: yellow
5
  colorTo: indigo
app/main.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ import os
5
+
6
+ from fastapi import FastAPI, Header, HTTPException
7
+
8
+ app = FastAPI(title="pinggy-db-url")
9
+
10
+ URL_FILE = Path("/tmp/pinggy_url.txt")
11
+ SECRET_KEY = os.environ.get("SECRET_KEY", "")
12
+
13
+
14
+ def _check_api_key(api_key: str):
15
+ if not SECRET_KEY:
16
+ raise HTTPException(status_code=500, detail="SECRET_KEY is not configured")
17
+ if api_key != SECRET_KEY:
18
+ raise HTTPException(status_code=401, detail="invalid api key")
19
+
20
+
21
+ def _read_url() -> Optional[str]:
22
+ if not URL_FILE.exists():
23
+ return None
24
+ value = URL_FILE.read_text().strip()
25
+ return value or None
26
+
27
+
28
+ @app.get("/")
29
+ def root(x_api_key: str = Header(default="")):
30
+ _check_api_key(x_api_key)
31
+ return {"status": "ok", "hint": "GET /db-url for the current connection info"}
32
+
33
+
34
+ @app.get("/db-url")
35
+ def db_url(x_api_key: str = Header(default="")):
36
+ _check_api_key(x_api_key)
37
+ url = _read_url()
38
+ if url is None:
39
+ raise HTTPException(status_code=503, detail="tunnel not ready yet, try again shortly")
40
+
41
+ # url looks like tcp://<host>:<port>
42
+ host_port = url[len("tcp://"):] if url.startswith("tcp://") else url
43
+ host, _, port = host_port.partition(":")
44
+
45
+ return {
46
+ "tunnel_url": url,
47
+ "host": host,
48
+ "port": int(port) if port.isdigit() else None,
49
+ "psql_example": f"psql -h {host} -p {port} -U postgres -d postgres",
50
+ }
init.sql DELETED
@@ -1,79 +0,0 @@
1
- -- Schema + roles for PostgREST, executed once at container startup.
2
- -- Model: chat history (conversations -> messages).
3
- -- (Mirrors migrations/0001_chat_history.sql)
4
-
5
- -- Role PostgREST connects as (anonymous / public access)
6
- CREATE ROLE web_anon LOGIN NOINHERIT;
7
-
8
- -- Role used to apply schema changes. Password comes from start.sh
9
- -- via psql variable `auth_pw` (sourced from AUTHENTICATOR_PASSWORD).
10
- CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD :'auth_pw';
11
- GRANT web_anon TO authenticator;
12
-
13
- CREATE SCHEMA api;
14
- GRANT USAGE ON SCHEMA api TO web_anon;
15
- -- authenticator owns the tables, so foreign-key checks (which run as the
16
- -- table owner) need USAGE on the schema too. NOINHERIT means it does NOT
17
- -- inherit web_anon's grants.
18
- GRANT USAGE ON SCHEMA api TO authenticator;
19
-
20
- -- One conversation (a chat thread)
21
- CREATE TABLE api.chats (
22
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
23
- title text NOT NULL,
24
- created_at timestamptz NOT NULL DEFAULT now()
25
- );
26
-
27
- -- Messages belonging to a conversation
28
- CREATE TABLE api.messages (
29
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
30
- chat_id bigint NOT NULL REFERENCES api.chats (id) ON DELETE CASCADE,
31
- role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
32
- content text NOT NULL,
33
- created_at timestamptz NOT NULL DEFAULT now()
34
- );
35
-
36
- CREATE INDEX messages_chat_id_idx ON api.messages (chat_id);
37
-
38
- -- Change ownership BEFORE granting: ALTER TABLE ... OWNER TO drops
39
- -- privileges granted by the previous owner.
40
- ALTER TABLE api.chats OWNER TO authenticator;
41
- ALTER TABLE api.messages OWNER TO authenticator;
42
-
43
- GRANT SELECT, INSERT, UPDATE, DELETE ON api.chats TO web_anon;
44
- GRANT SELECT, INSERT, UPDATE, DELETE ON api.messages TO web_anon;
45
- GRANT USAGE, SELECT ON SEQUENCE api.chats_id_seq TO web_anon;
46
- GRANT USAGE, SELECT ON SEQUENCE api.messages_id_seq TO web_anon;
47
-
48
- INSERT INTO api.chats (title) VALUES
49
- ('Getting started'),
50
- ('PostgREST questions');
51
-
52
- INSERT INTO api.messages (chat_id, role, content) VALUES
53
- (1, 'user', 'How do I list all chats?'),
54
- (1, 'assistant', 'GET /chats — or GET /chats?select=*,messages(*) to embed messages.'),
55
- (2, 'user', 'How do I add a message to chat 1?'),
56
- (2, 'assistant', 'POST /messages with {"chat_id": 1, "role": "user", "content": "..."}');
57
-
58
- -- Row counts for Content-Range headers
59
- CREATE OR REPLACE FUNCTION api.chats_count()
60
- RETURNS bigint
61
- LANGUAGE sql
62
- AS $$
63
- SELECT count(*) FROM api.chats;
64
- $$;
65
-
66
- CREATE OR REPLACE FUNCTION api.messages_count()
67
- RETURNS bigint
68
- LANGUAGE sql
69
- AS $$
70
- SELECT count(*) FROM api.messages;
71
- $$;
72
-
73
- -- OpenAPI description endpoint
74
- CREATE OR REPLACE FUNCTION api.openapi_description()
75
- RETURNS text
76
- LANGUAGE sql
77
- AS $$
78
- SELECT 'A demo PostgREST chat-history API running on Hugging Face Spaces';
79
- $$;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
migrations/0001_chat_history.sql DELETED
@@ -1,74 +0,0 @@
1
- -- Schema + roles for PostgREST, executed once at container startup.
2
- -- Model: chat history (conversations -> messages).
3
-
4
- -- Role PostgREST connects as (anonymous / public access)
5
- CREATE ROLE web_anon LOGIN NOINHERIT;
6
-
7
- -- Role used to apply schema changes. Password comes from start.sh
8
- -- via psql variable `auth_pw` (sourced from AUTHENTICATOR_PASSWORD).
9
- CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD :'auth_pw';
10
- GRANT web_anon TO authenticator;
11
-
12
- CREATE SCHEMA api;
13
- GRANT USAGE ON SCHEMA api TO web_anon;
14
-
15
- -- One conversation (a chat thread)
16
- CREATE TABLE api.chats (
17
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
18
- title text NOT NULL,
19
- created_at timestamptz NOT NULL DEFAULT now()
20
- );
21
-
22
- -- Messages belonging to a conversation
23
- CREATE TABLE api.messages (
24
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
25
- chat_id bigint NOT NULL REFERENCES api.chats (id) ON DELETE CASCADE,
26
- role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
27
- content text NOT NULL,
28
- created_at timestamptz NOT NULL DEFAULT now()
29
- );
30
-
31
- CREATE INDEX messages_chat_id_idx ON api.messages (chat_id);
32
-
33
- -- Change ownership BEFORE granting: ALTER TABLE ... OWNER TO drops
34
- -- privileges granted by the previous owner.
35
- ALTER TABLE api.chats OWNER TO authenticator;
36
- ALTER TABLE api.messages OWNER TO authenticator;
37
-
38
- GRANT SELECT, INSERT, UPDATE, DELETE ON api.chats TO web_anon;
39
- GRANT SELECT, INSERT, UPDATE, DELETE ON api.messages TO web_anon;
40
- GRANT USAGE, SELECT ON SEQUENCE api.chats_id_seq TO web_anon;
41
- GRANT USAGE, SELECT ON SEQUENCE api.messages_id_seq TO web_anon;
42
-
43
- INSERT INTO api.chats (title) VALUES
44
- ('Getting started'),
45
- ('PostgREST questions');
46
-
47
- INSERT INTO api.messages (chat_id, role, content) VALUES
48
- (1, 'user', 'How do I list all chats?'),
49
- (1, 'assistant', 'GET /chats — or GET /chats?select=*,messages(*) to embed messages.'),
50
- (2, 'user', 'How do I add a message to chat 1?'),
51
- (2, 'assistant', 'POST /messages with {"chat_id": 1, "role": "user", "content": "..."}');
52
-
53
- -- Row counts for Content-Range headers
54
- CREATE OR REPLACE FUNCTION api.chats_count()
55
- RETURNS bigint
56
- LANGUAGE sql
57
- AS $$
58
- SELECT count(*) FROM api.chats;
59
- $$;
60
-
61
- CREATE OR REPLACE FUNCTION api.messages_count()
62
- RETURNS bigint
63
- LANGUAGE sql
64
- AS $$
65
- SELECT count(*) FROM api.messages;
66
- $$;
67
-
68
- -- OpenAPI description endpoint
69
- CREATE OR REPLACE FUNCTION api.openapi_description()
70
- RETURNS text
71
- LANGUAGE sql
72
- AS $$
73
- SELECT 'A demo PostgREST chat-history API running on Hugging Face Spaces';
74
- $$;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
proxy.py DELETED
@@ -1,123 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Tiny reverse proxy in front of PostgREST.
4
-
5
- - /docs -> Swagger UI (static files from /opt/swagger-ui)
6
- - /docs/openapi.json -> OpenAPI spec fetched from PostgREST
7
- - everything else -> proxied verbatim to PostgREST on 127.0.0.1:$PGRST_PORT
8
-
9
- Uses only the Python standard library. Runs as the same non-root user.
10
- """
11
-
12
- import json
13
- import os
14
- import urllib.request
15
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
-
17
- SWAGGER_DIR = "/opt/swagger-ui"
18
- UPSTREAM = f"http://127.0.0.1:{os.environ.get('PGRST_PORT', '3000')}"
19
- LISTEN_PORT = int(os.environ.get("PORT", "7860"))
20
-
21
- # Swagger UI page that loads /docs/openapi.json
22
- INDEX_HTML = """<!DOCTYPE html>
23
- <html lang="en">
24
- <head>
25
- <meta charset="UTF-8">
26
- <title>PostgREST API Docs</title>
27
- <link rel="stylesheet" href="/docs/swagger-ui.css">
28
- </head>
29
- <body>
30
- <div id="swagger-ui"></div>
31
- <script src="/docs/swagger-ui-bundle.js" charset="UTF-8"></script>
32
- <script>
33
- window.onload = () => {
34
- SwaggerUIBundle({
35
- url: "/docs/openapi.json",
36
- dom_id: "#swagger-ui",
37
- deepLinking: true,
38
- });
39
- };
40
- </script>
41
- </body>
42
- </html>"""
43
-
44
-
45
- class Handler(SimpleHTTPRequestHandler):
46
- def __init__(self, *args, **kwargs):
47
- # Serve static Swagger UI files from SWAGGER_DIR
48
- super().__init__(*args, directory=SWAGGER_DIR, **kwargs)
49
-
50
- # --- /docs routes -----------------------------------------------------
51
- def _send(self, code, body, ctype):
52
- self.send_response(code)
53
- self.send_header("Content-Type", ctype)
54
- self.send_header("Content-Length", str(len(body)))
55
- self.send_header("Cache-Control", "no-store")
56
- self.end_headers()
57
- self.wfile.write(body)
58
-
59
- # --- proxying ---------------------------------------------------------
60
- def _proxy(self):
61
- url = UPSTREAM + self.path
62
- req = urllib.request.Request(url, method=self.command)
63
- for h in ("Content-Type", "Accept", "Prefer", "Range",
64
- "If-Match", "Authorization"):
65
- if v := self.headers.get(h):
66
- req.add_header(h, v)
67
- length = int(self.headers.get("Content-Length") or 0)
68
- body = self.rfile.read(length) if length else None
69
-
70
- try:
71
- with urllib.request.urlopen(req, body) as r:
72
- resp, code, rheaders = r.read(), r.status, r.headers
73
- except urllib.error.HTTPError as e:
74
- resp, code, rheaders = e.read(), e.code, e.headers
75
- except Exception as e: # URLError, timeout, upstream down, ...
76
- print(f"proxy error: {self.command} {self.path}: {e!r}", flush=True)
77
- resp = json.dumps({"message": str(e)}).encode()
78
- code, rheaders = 502, {"Content-Type": "application/json"}
79
-
80
- self.send_response(code)
81
- for h in ("Content-Type", "Content-Range", "Content-Location",
82
- "Location", "ETag", "Preference-Applied",
83
- "Accept-Profile", "Content-Profile"):
84
- if v := rheaders.get(h):
85
- self.send_header(h, v)
86
- self.send_header("Content-Length", str(len(resp)))
87
- self.end_headers()
88
- self.wfile.write(resp)
89
-
90
- def do_GET(self):
91
- if self.path == "/docs" or self.path.startswith("/docs?"):
92
- self._send(200, INDEX_HTML.encode(), "text/html; charset=utf-8")
93
- elif self.path == "/docs/openapi.json":
94
- # Fetch the spec and rewrite its host so "Try it out" requests
95
- # hit the public origin instead of PostgREST's internal port
96
- import json
97
- spec = json.loads(urllib.request.urlopen(f"{UPSTREAM}/").read())
98
- spec["host"] = self.headers.get("Host") or f"localhost:{LISTEN_PORT}"
99
- spec["schemes"] = ["https"] if self.headers.get(
100
- "X-Forwarded-Proto") == "https" else ["http"]
101
- body = json.dumps(spec).encode()
102
- self._send(200, body, "application/openapi+json")
103
- elif self.path.startswith("/docs/"):
104
- # static assets: strip the /docs prefix
105
- self.path = self.path[len("/docs"):]
106
- super().do_GET()
107
- else:
108
- self._proxy()
109
-
110
- def do_HEAD(self):
111
- self.do_GET()
112
-
113
- def _proxy_with_body(self):
114
- self._proxy()
115
-
116
- do_POST = do_PATCH = do_PUT = do_DELETE = do_OPTIONS = _proxy_with_body
117
-
118
- def log_message(self, fmt, *args):
119
- print("%s - %s" % (self.address_string(), fmt % args), flush=True)
120
-
121
-
122
- if __name__ == "__main__":
123
- ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
start.sh DELETED
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Start PostgreSQL, apply the schema, then launch PostgREST on $PORT.
3
- set -euo pipefail
4
-
5
- # Load .env if present (local runs; HF Spaces injects vars itself)
6
- if [ -f .env ]; then
7
- set -a
8
- . ./.env
9
- set +a
10
- fi
11
-
12
- PGDATA=/var/lib/postgresql/data
13
- SOCKET_DIR=/var/run/postgresql
14
- INIT_SQL=/docker-entrypoint-init.d/init.sql
15
-
16
- # Debian's postgresql package keeps binaries in a versioned dir
17
- # (e.g. /usr/lib/postgresql/15/bin) that is not on PATH.
18
- for PGDIR in /usr/lib/postgresql/*/bin; do
19
- [ -x "$PGDIR/pg_ctl" ] && export PATH="$PGDIR:$PATH" && break
20
- done
21
-
22
- command -v pg_ctl >/dev/null || { echo "postgres binaries not found" >&2; exit 1; }
23
-
24
- # --- Initialize and start PostgreSQL ---
25
- if [ ! -s "$PGDATA/PG_VERSION" ]; then
26
- initdb --username=appuser --pgdata="$PGDATA" --auth-local=trust --auth-host=trust
27
- fi
28
-
29
- pg_ctl -D "$PGDATA" -o "-c listen_addresses=127.0.0.1 -c unix_socket_directories=$SOCKET_DIR -c port=5432" -w start
30
-
31
- # Wait until the server accepts connections
32
- until pg_isready -h "$SOCKET_DIR" -p 5432 -q; do
33
- sleep 0.2
34
- done
35
-
36
- # --- Apply schema once ---
37
- if ! psql -h "$SOCKET_DIR" -p 5432 -d postgres -Atqc "SELECT 1 FROM pg_roles WHERE rolname='web_anon'" | grep -q 1; then
38
- psql -v ON_ERROR_STOP=1 --set=auth_pw="${AUTHENTICATOR_PASSWORD:-secret}" \
39
- -h "$SOCKET_DIR" -p 5432 -d postgres -f "$INIT_SQL"
40
- fi
41
-
42
- # --- Launch PostgREST ---
43
- export PGRST_DB_URI="postgresql://authenticator:${AUTHENTICATOR_PASSWORD:-secret}@/postgres?host=$SOCKET_DIR&port=5432"
44
- export PGRST_DB_SCHEMA="${PGRST_DB_SCHEMA:-api}"
45
- export PGRST_DB_ANON_ROLE="${PGRST_DB_ANON_ROLE:-web_anon}"
46
- export PGRST_SERVER_PORT="${PGRST_PORT:-3000}"
47
-
48
- # PGRST_JWT_ROLE_CLAIM_KEY must be a JSON path (".role", ".payload.role",
49
- # '."https://example.com/role"' or "[\"role\"]"). A bare value like "role"
50
- # is a parse error that crashes PostgREST at startup — normalize it.
51
- if [ -n "${PGRST_JWT_ROLE_CLAIM_KEY:-}" ] && \
52
- [[ ! "$PGRST_JWT_ROLE_CLAIM_KEY" =~ ^(\.|\[|\") ]]; then
53
- export PGRST_JWT_ROLE_CLAIM_KEY=".$PGRST_JWT_ROLE_CLAIM_KEY"
54
- fi
55
-
56
- # PostgREST listens on the internal port; the proxy serves it (plus
57
- # Swagger UI at /docs) on the public $PORT.
58
- postgrest &
59
- exec python3 /usr/local/bin/proxy.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
supervisord.conf ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [supervisord]
2
+ nodaemon=true
3
+ user=root
4
+ logfile=/var/log/supervisord.log
5
+
6
+ ; Runs the postgres image's own entrypoint script with "postgres" as the
7
+ ; argument -- this is what triggers its normal first-run init (creating
8
+ ; the data dir, user, db) before it execs postgres in the foreground.
9
+ [program:postgres]
10
+ command=docker-entrypoint.sh postgres
11
+ priority=1
12
+ autostart=true
13
+ autorestart=true
14
+ stdout_logfile=/var/log/postgres.log
15
+ stderr_logfile=/var/log/postgres.err.log
16
+
17
+ [program:tunnel]
18
+ command=/usr/local/bin/tunnel.sh
19
+ priority=10
20
+ autostart=true
21
+ autorestart=true
22
+ startretries=999
23
+ stdout_logfile=/var/log/tunnel.log
24
+ stderr_logfile=/var/log/tunnel.err.log
25
+
26
+ [program:fastapi]
27
+ command=python3 -m uvicorn app.main:app --host 0.0.0.0 --port 7860
28
+ directory=/app
29
+ priority=20
30
+ autostart=true
31
+ autorestart=true
32
+ stdout_logfile=/var/log/fastapi.log
33
+ stderr_logfile=/var/log/fastapi.err.log
tunnel.sh ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Keeps a pinggy TCP tunnel to local postgres alive, recycling it before
3
+ # the free-tier ~60 minute cap, and writing the current public host:port
4
+ # to a file that the FastAPI app reads from.
5
+
6
+ set -u
7
+
8
+ URL_FILE=/tmp/pinggy_url.txt
9
+ LOG_FILE=/tmp/pinggy_current.log
10
+ TTL_SECONDS=$((50 * 60)) # refresh every 50 min, inside the 60 min free cap
11
+
12
+ echo "[tunnel] waiting for postgres..."
13
+ until pg_isready -h 127.0.0.1 -p 5432 >/dev/null 2>&1; do
14
+ sleep 1
15
+ done
16
+ echo "[tunnel] postgres is up"
17
+
18
+ while true; do
19
+ SECONDS=0
20
+ : > "$LOG_FILE"
21
+ echo "[tunnel] opening new pinggy tunnel..."
22
+
23
+ ssh -p 443 -T \
24
+ -o StrictHostKeyChecking=no \
25
+ -o UserKnownHostsFile=/dev/null \
26
+ -o ServerAliveInterval=30 \
27
+ -o ExitOnForwardFailure=yes \
28
+ -R0:127.0.0.1:5432 qr+tcp@free.pinggy.io \
29
+ >> "$LOG_FILE" 2>&1 &
30
+ SSH_PID=$!
31
+
32
+ # poll the log for the public "tcp://host:port" pinggy prints on connect
33
+ URL=""
34
+ for i in $(seq 1 30); do
35
+ URL=$(grep -oE 'tcp://[a-zA-Z0-9.-]+:[0-9]+' "$LOG_FILE" | head -n1)
36
+ [ -n "$URL" ] && break
37
+ kill -0 "$SSH_PID" 2>/dev/null || break # ssh already died
38
+ sleep 1
39
+ done
40
+
41
+ if [ -n "$URL" ]; then
42
+ echo "$URL" > "$URL_FILE"
43
+ echo "[tunnel] live at $URL"
44
+ else
45
+ echo "[tunnel] never got a URL from pinggy, see $LOG_FILE" >&2
46
+ fi
47
+
48
+ # hold this tunnel open until TTL expires or ssh dies on its own
49
+ while [ "$SECONDS" -lt "$TTL_SECONDS" ] && kill -0 "$SSH_PID" 2>/dev/null; do
50
+ sleep 5
51
+ done
52
+
53
+ echo "[tunnel] recycling..."
54
+ kill "$SSH_PID" 2>/dev/null
55
+ wait "$SSH_PID" 2>/dev/null
56
+ done