mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(ia-egress): estabiliza a rota ate o deepseek com proxy na tailnet + probe no deploy
This commit is contained in:
parent
4678117b08
commit
53bea7c455
5 changed files with 174 additions and 5 deletions
|
|
@ -498,6 +498,35 @@ anota no run (`::warning::`) qualquer chave de feature ausente — não falha o
|
|||
deploy (quais chaves existem é decisão humana), mas a perda de capacidade
|
||||
aparece no próprio run.
|
||||
|
||||
### Egress até o provedor — proxy na tailnet
|
||||
|
||||
A rota do homeserver (Vivo) até `api.deepseek.com` (CloudFront) é
|
||||
intermitente — medido em 2026-09-25: 1/3 de connect, enquanto a estação de
|
||||
trabalho (que sai pelo exit node `mail-relay` da tailnet) dava 3/3. Para não
|
||||
depender dessa rota, o egress do app passa por um **proxy HTTP na estação**
|
||||
(`100.94.122.26:3128`), que usa o exit node:
|
||||
|
||||
- **Onde roda:** container Docker `teqo-egress-proxy` na estação
|
||||
(`--network host`, `--restart unless-stopped`, imagem `node:24-alpine`),
|
||||
com o script `scripts/egress-proxy.mjs` deste repo (cópia operacional em
|
||||
`~/teqo-egress-proxy/proxy.mjs`). Bind só na IP da tailnet e allowlist de
|
||||
cliente = o homeserver (`100.119.220.31`); só túneis `CONNECT` para 443.
|
||||
- **Como o app usa:** `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY=http://100.94.122.26:3128`
|
||||
- `NO_PROXY` (internos) nos env files de produção e staging. O Node 24 honra
|
||||
o proxy no `fetch()` — zero mudança de código.
|
||||
- **Subir/atualizar:**
|
||||
`docker run -d --name teqo-egress-proxy --restart unless-stopped --network host -e PROXY_HOST=100.94.122.26 -e PROXY_PORT=3128 -e PROXY_ALLOWED_CLIENTS=100.119.220.31,127.0.0.1,::1 -v ~/teqo-egress-proxy:/app:ro node:24-alpine node /app/proxy.mjs`
|
||||
- **Limite conhecido:** a estação é ponto único — desligada, as capacidades
|
||||
de IA degradam (fail-safe) e o probe de egress do deploy avisa. Se um dia o
|
||||
`mail-relay` (sempre ligado) tiver acesso SSH, mover o proxy para lá é a
|
||||
evolução natural.
|
||||
- **Diagnóstico:** `docker logs teqo-egress-proxy`; do container,
|
||||
`docker exec teqo-1313 node -e "fetch('https://api.deepseek.com/').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code))"`.
|
||||
|
||||
Desde 2026-09-25 o `deploy-homeserver.sh` também **probe o egress de IA de
|
||||
dentro do container** (após o healthcheck, antes do smoke) e emite
|
||||
`::warning::` se `api.deepseek.com` não responder — não falha o deploy.
|
||||
|
||||
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
|
||||
|
||||
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
| Diagnóstico | 2026-09-24 | Read-only em prod: containers `teqo-1313`/`teqo-staging` **sem** `DEEPSEEK_API_KEY`/`DEEPINFRA_API_KEY`/`RESEND_API_KEY`/`VAPID_*` no env (nomes via `docker inspect`); `~/stack/.env` **tem** `DEEPSEEK_API_KEY` + `DEEPINFRA_API_KEY`; os env files do app não. Smoke real do módulo com a chave da estação: 5/10 falhas com `AI_NoObjectGeneratedError` (JSON truncado / resposta vazia) no teto 200; `usage` medido: 93–354 tokens de raciocínio contra 28–34 de texto. Com `thinking: disabled`: 6/6. |
|
||||
| Correção mergeada | 2026-09-25 02:09Z (`3cdd1cd8`) | PR #1333 mergeado com o required check `CI (PR) / checks` verde (auto-merge rebase; o fix é o commit `4e7b8444`). |
|
||||
| Deploy | 2026-09-25 02:39–03:32Z | Run `36087193017` (requeue do merge): verify full verde → `deploy-staging` success → `deploy-production` aprovado pelo humano; `teqo-1313` recriado em `3cdd1cd8` às 03:32:43Z. Staging e produção rodam o fix **com** as duas chaves no runtime (verificado por `docker inspect`, só nomes). |
|
||||
| Verificado em prod | pendente | **Bloqueado por rede:** a rota do homeserver até `api.deepseek.com` está intermitente (abaixo) — o fix e a chave estão no ar, mas a feature ainda pode degradar. |
|
||||
| Egress estabilizado | 2026-09-25 04:1xZ | Proxy HTTP de egress na estação de trabalho (tailnet, via exit node `mail-relay`) + `NODE_USE_ENV_PROXY`/`HTTPS_PROXY`/`NO_PROXY` nos env files do app; containers recriados (prod `3cdd1cd8`, staging `0f81e369`). Do container de produção: `GET /models` 5/5 200 e completion real 3/3 200. |
|
||||
| Verificado em prod | parcial | Fix no ar (`3cdd1cd8`) com chave e egress de IA provados de dentro do container (fetch e completion reais); a confirmação da **UI** do acervo em produção segue humana. |
|
||||
|
||||
## O bug
|
||||
|
||||
|
|
@ -55,11 +56,11 @@ Nenhuma migration, nenhum Consent/access, nenhum contrato de URL/shape público.
|
|||
- Guard de ops: `bash -n` verde; `deployScript.unit.spec.ts` verde (22) pinando o audit e a ordem (healthcheck → audit → smoke); audit exercitado com `docker` stubado (env com buraco → `::warning::` com a lista exata; tudo presente → silêncio).
|
||||
- CI: o required check `CI (PR) / checks` do PR #1333 (o auto-merge nativo só mergeia com ele verde).
|
||||
- Prod: pendente — deploy começa no merge; produção só após approve humano; a validação do humano depende de a chave entrar no `~/stack/teqo-1313.env` (copia-e-cola do runbook) e o container ser recriado.
|
||||
- Prod (2026-09-25): o deploy saiu — produção roda `3cdd1cd8` (o fix) com as duas chaves no runtime, mas a verificação funcional esbarrou numa **terceira camada, de rede** (abaixo). O aviso de indisponibilidade segue possível em parte das tentativas.
|
||||
- Prod (2026-09-25): o deploy saiu — produção roda `3cdd1cd8` (o fix) com as duas chaves no runtime. A terceira camada (rede, abaixo) foi resolvida com o proxy de egress e verificada do próprio container: `GET /models` 5/5 e completion real 3/3. Falta só a confirmação da UI do acervo pelo humano.
|
||||
|
||||
### Rota intermitente até o provedor (2026-09-25)
|
||||
### Rota até o provedor — resolvida com egress pela tailnet (2026-09-25)
|
||||
|
||||
A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfront.net` → `3.173.21.63:443`) está degradada: 3 `curl` do **host** deram 2 timeouts de 8s e 1 `401` em 0,55s; de dentro do **container de produção**, o `GET /models` deu 2 `UND_ERR_CONNECT_TIMEOUT` e 1 `200`. Não é bloqueio geral de rede: `example.com` responde 200 no container (3/3), `aws.amazon.com`/`d1.awsstatic.com` respondem no host, `api.deepinfra.com` responde 200 em 0,5s e a estação (egress AWS) alcança `api.deepseek.com` em 1,1s. Também não é o egress do Docker: o primeiro teste de `example.com` deu timeout numa janela ruim e foi refutado em 3/3 na repetição. A falha de rede cai no mesmo `catch` → `null` → aviso; **sem estabilizar a rota (ou trocar/rotear o provedor), a busca por tema continuará intermitente**.
|
||||
A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfront.net` → `3.173.21.63:443`) estava degradada (host e container: 1/3 de connect; o resto timeout de 8s), enquanto a estação de trabalho — que sai pelo exit node `mail-relay` da tailnet — dava 3/3. Não era bloqueio geral nem egress do Docker: `example.com` responde 200 no container, `aws.amazon.com`/`d1.awsstatic.com` no host e `api.deepinfra.com` em 0,5s. A camada de rede foi corrigida com um **proxy HTTP de egress na estação** (`scripts/egress-proxy.mjs` versionado no repo; container `teqo-egress-proxy`, bind na IP da tailnet, allowlist do homeserver, só `CONNECT` 443) mais `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY` + `NO_PROXY` nos env files de produção e staging — o Node 24 do container honra o proxy no `fetch()`, sem mudança de código. Verificado do container de produção, com o env próprio: `GET /models` 5/5 200 e completion real (`/chat/completions` com `thinking: disabled`) 3/3 200. O `deploy-homeserver.sh` passou a **probar o egress de dentro do container** (`warn_ai_egress`, não-fatal, após o healthcheck). Limite aceito e documentado: a estação é ponto único — desligada, as capacidades de IA degradam (fail-safe) e o probe do deploy avisa; mover o proxy para o `mail-relay` (sempre ligado) quando houver acesso SSH é a evolução natural.
|
||||
|
||||
## Prevenção
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfr
|
|||
| Falhar o deploy quando uma feature env esperada está ausente | cara | documentada — muda contrato de ops (pode travar hotfix/rollback) e é decisão humana |
|
||||
| Observabilidade de degradação (contador/alerta por `catch`→`null` de cada feature, em vez de só o aviso na UI) | cara | documentada — instrumentação nova |
|
||||
| Passar as chaves de `~/stack/.env` ao runtime via `environment:` no compose (infra-solla), em vez de copiar linhas | cara | documentada — toca repo de infra fora deste repo |
|
||||
| Estabilizar a rota até o provedor LLM (egress alternativo/proxy pela rede saudável, retry+timeout no cliente, ou trocar para Deep Infra — alcançável em 0,5s) | cara | documentada — aguarda decisão humana (produto/custo/infra) |
|
||||
| Egress de IA via proxy na tailnet (estação → exit node) + probe do egress no deploy + script versionado (`scripts/egress-proxy.mjs`) | barata | implementada agora (este PR) |
|
||||
|
||||
**Estratégia implementada:** os cinco pins de regressão travam a configuração do provedor em cada call site (o defeito não volta sem um teste vermelho), o dono único impede que um irmão fique para trás num próximo ajuste, e o audit de deploy transforma "capacidade degradada em silêncio" em anotação visível no run — exatamente o que faltou para o incidente aparecer em 2026-08-17.
|
||||
|
||||
|
|
@ -85,4 +86,5 @@ A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfr
|
|||
- **Teto de saída é orçamento compartilhado com o raciocínio.** Em modelo com thinking default, `maxOutputTokens` pequeno não limita o tamanho da resposta — trunca o pensamento; a resposta correta é desligar o thinking em tarefas de transformação determinística e/ou dar margem medida, não apertar o teto.
|
||||
- **Rede é parte do caminho crítico de um LLM externo.** O mesmo request de connect ao provedor teve 1/3 de sucesso em duas amostras (host e container de produção), enquanto a estação passava em 1,1s — degradação por rede é indistinguível de queda do provedor no log da aplicação e cai no mesmo fallback silencioso.
|
||||
- **Smoke local não valida o alvo.** O caminho do provedor funcionava da estação de trabalho e falhava no homeserver; validar "na minha máquina" não é validar o ambiente que serve a produção.
|
||||
- **Correção de rede também é engenharia versionada.** O proxy que estabiliza o egress vive no repo (`scripts/egress-proxy.mjs`) e o deploy prova o caminho de dentro do container — infra que sustenta feature precisa de fonte e guard, não de um host configurado à mão.
|
||||
- **Tarefas iguais pedem dono.** A mesma config repetida em cinco call sites garantiu que o incidente atingisse cinco features de uma vez; a defaults compartilhada fecha o vetor de drift até o helper completo chegar.
|
||||
|
|
|
|||
|
|
@ -310,6 +310,27 @@ warn_missing_feature_envs() {
|
|||
|
||||
warn_missing_feature_envs
|
||||
|
||||
# --- AI egress probe (non-fatal) ----------------------------------------
|
||||
# The AI keys can be present and the capability still dead: the homeserver's
|
||||
# route to api.deepseek.com (CloudFront) is intermittently unreachable (incident
|
||||
# 2026-09-25), and the app's egress is a tailnet proxy that can be down. Probe
|
||||
# the ACTUAL container path (it honors NODE_USE_ENV_PROXY/HTTPS_PROXY) and warn
|
||||
# (never fail: the app degrades to its fallbacks).
|
||||
|
||||
warn_ai_egress() {
|
||||
docker exec "$TEQO_CONTAINER" node -e "
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 8000)
|
||||
fetch('https://api.deepseek.com/', { signal: controller.signal })
|
||||
.then((response) => { clearTimeout(timer); process.exit(0) })
|
||||
.catch(() => process.exit(1))
|
||||
" >/dev/null 2>&1 && return 0
|
||||
say "WARNING: $TEQO_CONTAINER cannot reach api.deepseek.com — AI features degrade to their fallbacks"
|
||||
printf '::warning::%s cannot reach api.deepseek.com (AI capabilities degrade silently)\n' "$TEQO_CONTAINER"
|
||||
}
|
||||
|
||||
warn_ai_egress
|
||||
|
||||
# --- smoke --------------------------------------------------------------
|
||||
|
||||
base="$TEQO_SMOKE_BASE"
|
||||
|
|
|
|||
95
scripts/egress-proxy.mjs
Normal file
95
scripts/egress-proxy.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
|
||||
const HOST = process.env.PROXY_HOST ?? '100.94.122.26'
|
||||
const PORT = Number(process.env.PROXY_PORT ?? 3128)
|
||||
const ALLOWED_CLIENTS = new Set(
|
||||
(process.env.PROXY_ALLOWED_CLIENTS ?? '100.119.220.31,127.0.0.1,::1')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
const ALLOWED_PORTS = new Set(
|
||||
(process.env.PROXY_ALLOWED_PORTS ?? '443')
|
||||
.split(',')
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isInteger(value) && value > 0),
|
||||
)
|
||||
|
||||
const log = (...args) => console.log(new Date().toISOString(), ...args)
|
||||
|
||||
const clientAllowed = (socket) => {
|
||||
const ip = (socket.remoteAddress ?? '').replace(/^::ffff:/, '')
|
||||
return ALLOWED_CLIENTS.has(ip)
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let target
|
||||
try {
|
||||
target = new URL(req.url ?? '')
|
||||
} catch {
|
||||
res.writeHead(400)
|
||||
res.end('bad request')
|
||||
return
|
||||
}
|
||||
const port = Number(target.port || 80)
|
||||
if (target.protocol !== 'http:' || !ALLOWED_PORTS.has(port)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
const upstream = http.request(
|
||||
{
|
||||
host: target.hostname,
|
||||
port,
|
||||
path: `${target.pathname}${target.search}`,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
},
|
||||
(upstreamRes) => {
|
||||
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers)
|
||||
upstreamRes.pipe(res)
|
||||
},
|
||||
)
|
||||
upstream.on('error', () => {
|
||||
if (!res.headersSent) res.writeHead(502)
|
||||
res.end('bad gateway')
|
||||
})
|
||||
req.pipe(upstream)
|
||||
})
|
||||
|
||||
server.on('connect', (req, clientSocket, head) => {
|
||||
const [rawHost, rawPort] = (req.url ?? '').split(':')
|
||||
const host = rawHost?.trim()
|
||||
const port = Number(rawPort ?? 443)
|
||||
if (!host || !ALLOWED_PORTS.has(port)) {
|
||||
log('denied connect', req.url)
|
||||
clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n')
|
||||
return
|
||||
}
|
||||
const upstream = net.connect(port, host, () => {
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
if (head.length > 0) upstream.write(head)
|
||||
upstream.pipe(clientSocket)
|
||||
clientSocket.pipe(upstream)
|
||||
})
|
||||
upstream.setTimeout(30000, () => upstream.destroy())
|
||||
upstream.on('error', (error) => {
|
||||
log('upstream error', host, error.code)
|
||||
clientSocket.destroy()
|
||||
})
|
||||
clientSocket.on('error', () => upstream.destroy())
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
if (!clientAllowed(socket)) {
|
||||
log('denied client', socket.remoteAddress)
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
log(
|
||||
`egress proxy on ${HOST}:${PORT} — clients: ${[...ALLOWED_CLIENTS].join(',')} — ports: ${[...ALLOWED_PORTS].join(',')}`,
|
||||
)
|
||||
})
|
||||
|
|
@ -125,6 +125,28 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
|
|||
expect(callIndex).toBeLessThan(smokeIndex)
|
||||
})
|
||||
|
||||
it('probes the container AI egress non-fatally (route/proxy breakage incident 2026-09-25)', () => {
|
||||
// The keys can be present and the capability still dead: the homeserver's
|
||||
// route to api.deepseek.com is intermittent and the app egresses through a
|
||||
// tailnet proxy. The probe runs INSIDE the container (honoring
|
||||
// NODE_USE_ENV_PROXY/HTTPS_PROXY) and only warns — a broken provider path
|
||||
// must never fail an otherwise good deploy.
|
||||
for (const fragment of [
|
||||
'warn_ai_egress',
|
||||
'docker exec "$TEQO_CONTAINER" node -e',
|
||||
'api.deepseek.com',
|
||||
]) {
|
||||
expect(script, fragment).toContain(fragment)
|
||||
}
|
||||
const probeIndex = script.indexOf('warn_ai_egress() {')
|
||||
const callIndex = script.indexOf('warn_ai_egress\n')
|
||||
const healthIndex = script.indexOf('waiting for the healthcheck')
|
||||
const smokeIndex = script.indexOf('# --- smoke')
|
||||
expect(probeIndex).toBeGreaterThan(healthIndex)
|
||||
expect(callIndex).toBeGreaterThan(probeIndex)
|
||||
expect(callIndex).toBeLessThan(smokeIndex)
|
||||
})
|
||||
|
||||
it('never echoes secret values (no set -x, passwords only via stdin/secrets)', () => {
|
||||
expect(script).not.toMatch(/^set -x\b/m)
|
||||
expect(script).not.toContain('echo "$DATABASE_URL"')
|
||||
|
|
|
|||
Loading…
Reference in a new issue