Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
- name: Run repository contract
run: pdm run check

- name: Verify ChatGPT deployment lifecycle
run: pdm run python -m pytest -q deploy/integrations/chatgpt/tests

- name: Initialize disposable business-test database
env:
INKCRE_ENV_FILE: ""
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/self-host-heroku-neon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ jobs:
--tag "inkcre-production-web:$HEAD_SHA" .
bash scripts/automation/runtime_artifact.sh build-production-transports

- name: Compose optional ChatGPT web image
if: vars.CHATGPT_TUNNEL_ENABLED == 'true'
run: |
bash deploy/integrations/chatgpt/build.sh "inkcre-production-web:$HEAD_SHA" "inkcre-chatgpt:$HEAD_SHA"
docker tag "inkcre-chatgpt:$HEAD_SHA" "inkcre-production-web:$HEAD_SHA"

- name: Install pinned Heroku CLI
run: npm install --global heroku@11.8.1

Expand All @@ -86,6 +92,12 @@ jobs:
POSTGREST_APP_NAME: ${{ steps.settings.outputs.postgrest_app_name }}
run: bash scripts/automation/production_delivery.sh resolve-apps

- name: Check optional ChatGPT runtime configuration
if: vars.CHATGPT_TUNNEL_ENABLED == 'true'
env:
APP_NAME: ${{ steps.heroku.outputs.app_name }}
run: python -m deploy.integrations.chatgpt.heroku preflight --app "$APP_NAME"

- name: Verify self-hosted apps are stopped before database changes
env:
APP_NAME: ${{ steps.heroku.outputs.app_name }}
Expand Down Expand Up @@ -140,3 +152,9 @@ jobs:
WEB_RELEASE: ${{ steps.release.outputs.web_release }}
WEB_URL: ${{ steps.heroku.outputs.web_url }}
run: bash scripts/automation/production_delivery.sh probe

- name: Verify optional ChatGPT integration
if: vars.CHATGPT_TUNNEL_ENABLED == 'true'
env:
APP_NAME: ${{ steps.heroku.outputs.app_name }}
run: python -m deploy.integrations.chatgpt.heroku verify --app "$APP_NAME"
13 changes: 13 additions & 0 deletions deploy/integrations/chatgpt/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# A deployment composition: the standard Core image is built independently.
ARG CORE_IMAGE
FROM python:3.12-slim AS tunnel
WORKDIR /download
ARG TUNNEL_VERSION=0.0.19
ARG TUNNEL_SHA256=9ee9448592345a9dd1517fb79791ab54d24f051142eb92bcbe8cd355effc9142
RUN python -c 'import hashlib,io,os,urllib.request,zipfile; v=os.environ["TUNNEL_VERSION"]; data=urllib.request.urlopen(f"https://github.com/openai/tunnel-client/releases/download/v{v}/tunnel-client-v{v}-linux-amd64.zip").read(); assert hashlib.sha256(data).hexdigest()==os.environ["TUNNEL_SHA256"], "Tunnel checksum mismatch"; zipfile.ZipFile(io.BytesIO(data)).extractall("/tunnel")' \
&& chmod +x /tunnel/tunnel-client /tunnel/cloudflared

FROM ${CORE_IMAGE}
COPY --from=tunnel --chown=inkcre:inkcre /tunnel/ /opt/inkcre-chatgpt/
COPY --chown=inkcre:inkcre launcher.py /opt/inkcre-chatgpt/launcher.py
CMD ["python", "/opt/inkcre-chatgpt/launcher.py"]
64 changes: 64 additions & 0 deletions deploy/integrations/chatgpt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ChatGPT 部署集成

本目录把正式 Core 镜像与 OpenAI Secure MCP Tunnel 客户端组合到同一容器中。
它是一个特定部署配方:Core 提供普通 MCP HTTP + PAT,集成负责 ChatGPT 的连接进程。
标准 Core 镜像不包含此目录或 Tunnel;无需修改 Core、Sink、数据库或 Extension。

目前支持 Linux amd64、单个 web 实例、单条 Tunnel 与显式指定的本地 MCP Sink。
同一信息库的其他 Peer 不自动启动客户端。不创建 OpenAI Tunnel、Sink、插件或额外服务。

## Heroku 自部署

1. 按正常流程创建 MCP Sink、个人 OpenAI Tunnel 和 ChatGPT 插件,取得运行时 Key。
2. 在 Core app 的 Heroku Config Vars 中配置下列值;不要放进镜像或构建参数。
3. 在 fork 的 Actions Variables 设置 `CHATGPT_TUNNEL_ENABLED=true`。
4. 继续运行原 `Deploy self-hosted InKCre to Heroku` 工作流。每次部署都会组合当前选定的
Core 镜像并分别检查 Core 与 ChatGPT 接入,不需要维护个人 Dockerfile。

| Heroku Config Var | 内容 |
| --- | --- |
| `INKCRE_TUNNEL_ID` | 已有的 OpenAI Tunnel ID |
| `INKCRE_TUNNEL_API_KEY` | 该 Tunnel 可用的运行时 API Key |
| `INKCRE_MCP_SINK_ID` | 本地已启用的 MCP Sink 正整数 ID,没有默认值 |
| `INKCRE_MCP_PAT` | 该 Sink 的 PAT 原值,不带 `Bearer ` |

运行配置以 Heroku 为准;CD 只读取、检查,正常更新不会覆盖它们。首次配置可先在普通
Core 上建立 Sink 后再启用集成。PAT 的真值由 Sink 配置拥有:轮换时需同步更新这里的
凭据,否则检查会报告认证失败。日志及检查输出不打印凭据。

移除 fork 变量或设为 `false` 后,下次自部署恢复标准 Core 镜像,停止运行 Tunnel;不会
删除 Sink、OpenAI Tunnel、信息或凭据。镜像回退不会改变数据库,仍须核对目标 release
是否会回退其他 Heroku 配置。不要同时运行本机同一 Tunnel 客户端。

## 构建与独立验收

标准 Core 先按自己的流程构建。此目录仅接收一个准确镜像引用(或本机已构建的 tag):

```bash
bash deploy/integrations/chatgpt/build.sh CORE_IMAGE CHATGPT_IMAGE
pdm run python -m pytest -q deploy/integrations/chatgpt/tests
pdm run python -m deploy.integrations.chatgpt.heroku preflight --app APP_NAME
pdm run python -m deploy.integrations.chatgpt.heroku verify --app APP_NAME
```

Heroku 检查读取 `HEROKU_API_KEY`,本机未设置时使用已登录 Heroku CLI 的 token。
构建固定下载官方 Tunnel Client 0.0.19,校验固定 SHA256,保留许可证和配套文件。
发布组合镜像时记录 Core 引用、集成源码提交和镜像摘要;不将候选镜像冒充标准 Core。
标准 Core 发布本身不发布此组合;Heroku 自部署工作流是其交付入口。

## 生命周期与可用性

启动器先执行原 Core 命令,等待 localhost `/readyz` 成功,再连接
`http://127.0.0.1:$PORT/sinks/$INKCRE_MCP_SINK_ID/mcp`。Core 自身 exec 重启流程不变。
Tunnel 退出仅重试自身(10–60秒退避);Core 退出则结束组合;SIGTERM/SIGINT 回收两个
进程组。客户端本地 `/readyz` 状态改变时输出 `Tunnel ready` / `Tunnel not ready`。

CD 验证运行命令、当前 dyno 启动后的客户端就绪记录,并用 PAT 对目标 Sink 做 MCP
initialize。无本轮就绪记录会失败,不会用旧 dyno 的记录判定成功。这证明客户端就绪与
下游认证正常,不能代替真正的 ChatGPT 授权与调用验收;首次接入仍需通过个人插件读回
一条已知数据。后续诊断可查看 Heroku web 日志;Core `/readyz` 不包含 Tunnel 状态。

同一 Eco dyno 共享 CPU 和内存,不新增 dyno、Cron 或保活。Tunnel 的普通进程故障不会
主动重启健康 Core,但内存耗尽等资源故障仍可能影响两者。Eco 休眠会同时停止连接;
需要时先从任意设备访问 Core `/readyz` 唤醒,再等待 Tunnel 连接后使用 ChatGPT。
不承诺休眠时 ChatGPT 请求能唤醒 dyno,也不承诺用尽 Eco 小时额度后可用。
11 changes: 11 additions & 0 deletions deploy/integrations/chatgpt/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Accept the exact locally built Core image too; never build Core in this directory.
base="${1:?usage: build.sh CORE_IMAGE OUTPUT_IMAGE}"
output="${2:?usage: build.sh CORE_IMAGE OUTPUT_IMAGE}"
if [ "$base" = "$output" ]; then
echo 'Core input and composition output must have distinct tags' >&2
exit 2
fi
docker build --platform linux/amd64 --provenance=false \
--build-arg "CORE_IMAGE=$base" --tag "$output" "$(dirname "$0")"
122 changes: 122 additions & 0 deletions deploy/integrations/chatgpt/heroku.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Read-only preflight and post-release checks for this Heroku integration."""

import argparse
import datetime
import json
import os
import subprocess
import time
import urllib.error
import urllib.request

from .launcher import validate_config


def api(app, path, *, body=None):
key = os.environ.get("HEROKU_API_KEY")
if not key:
key = subprocess.run(
["heroku", "auth:token"], capture_output=True, text=True, check=True
).stdout.strip()
request = urllib.request.Request(
f"https://api.heroku.com/apps/{app}{path}",
data=None if body is None else json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + key,
"Accept": "application/vnd.heroku+json; version=3",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)


def sink_ready(base, config):
request = urllib.request.Request(
base.rstrip("/") + f"/sinks/{config['INKCRE_MCP_SINK_ID']}/mcp",
data=json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "deployment-check", "version": "1"},
},
}
).encode(),
headers={
"Authorization": "Bearer " + config["INKCRE_MCP_PAT"],
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
)
with urllib.request.urlopen(request, timeout=15) as response:
value = json.load(response)
return "tools" in value.get("result", {}).get("capabilities", {})


def tunnel_ready(app, dyno):
session = api(app, "/log-sessions", body={"lines": 1500, "source": "app", "dyno": "web"})
with urllib.request.urlopen(session["logplex_url"], timeout=20) as response:
lines = response.read().decode().splitlines()
started = datetime.datetime.fromisoformat(dyno["created_at"].replace("Z", "+00:00"))
state = False
for line in lines:
if "[chatgpt-tunnel] Tunnel " not in line:
continue
try:
timestamp = datetime.datetime.fromisoformat(line.split()[0].replace("Z", "+00:00"))
except ValueError:
continue
if timestamp < started:
continue
if "[chatgpt-tunnel] Tunnel ready" in line:
state = True
elif any(
value in line
for value in ("Tunnel not ready", "Tunnel exited", "Tunnel client started")
):
state = False
return state


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("preflight", "verify"))
parser.add_argument("--app", required=True)
args = parser.parse_args()
config = api(args.app, "/config-vars")
validate_config(config)
if args.action == "preflight":
print("ChatGPT runtime configuration present; no credentials printed or changed")
return
base = api(args.app, "")["web_url"]
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
try:
dynos = [d for d in api(args.app, "/dynos") if d["type"] == "web"]
if len(dynos) != 1:
raise SystemExit("This integration expects one web dyno")
dyno = dynos[0]
if "inkcre-chatgpt/launcher.py" not in dyno["command"]:
raise SystemExit("The web dyno is not running the ChatGPT composition")
if (
dyno["state"] == "up" and sink_ready(base, config) and tunnel_ready(args.app, dyno)
):
print(
f"ChatGPT integration ready: {args.app}, release v{dyno['release']['version']}, "
f"Sink {config['INKCRE_MCP_SINK_ID']}, {dyno['size']}"
)
return
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
pass
time.sleep(3)
raise SystemExit(
"Core deployment may be healthy, but ChatGPT integration was not verified"
)


if __name__ == "__main__":
main()
Loading
Loading