Files
fn_registry/python/functions/infra/hoppscotch_set_environment.py
T
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00

176 lines
6.2 KiB
Python

"""Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch.
Define las variables de un workspace Hoppscotch self-hosted via GraphQL,
resolviendo secretos desde `pass`: cualquier variable cuyo `value` empiece por
``pass:`` se resuelve con pass_get_secret y se marca como `secret=True`.
Idempotencia por nombre: lista los environments de la team y, si ya existe uno
con el `name` dado, lo actualiza; si no, lo crea. Las mutations estan protegidas
por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie
`access_token`.
Los valores secretos NUNCA se logean ni aparecen en el output: `resolved_secrets`
lista solo los KEYS resueltos desde pass, jamas sus valores.
"""
import json
import requests
from infra.pass_get_secret import pass_get_secret
_LIST_QUERY = "query($t:ID!){ team(teamID:$t){ teamEnvironments{ id name } } }"
_CREATE_MUTATION = (
"mutation($n:String!,$t:ID!,$v:String!){"
" createTeamEnvironment(name:$n,teamID:$t,variables:$v){ id name } }"
)
_UPDATE_MUTATION = (
"mutation($id:ID!,$n:String!,$v:String!){"
" updateTeamEnvironment(id:$id,name:$n,variables:$v){ id name } }"
)
_PASS_PREFIX = "pass:"
def hoppscotch_set_environment(
team_id: str,
name: str,
variables: list[dict],
*,
access_token: str,
backend_url: str = "http://localhost:3170",
) -> dict:
"""Crea o actualiza un Team Environment de Hoppscotch (idempotente por nombre).
Args:
team_id: ID de la team duena del environment.
name: nombre del environment. La idempotencia es por este nombre dentro
de la team: si ya existe uno con este name se actualiza, si no se crea.
variables: lista de dicts ``{"key": str, "value": str, "secret": bool}``.
Si un `value` empieza por ``pass:`` el resto se resuelve como ruta de
pass con pass_get_secret y el secreto resuelto se usa como value real,
forzando `secret=True` en esa variable. Campos `secret` ausentes se
tratan como False.
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch sin barra final. El endpoint
GraphQL es ``{backend_url}/graphql``.
Returns:
Dict. En exito: ``{"status": "ok", "id": str, "name": str,
"action": "created"|"updated", "resolved_secrets": list[str]}`` donde
`resolved_secrets` son SOLO los keys resueltos desde pass (nunca valores).
En error: ``{"status": "error", "error": str}`` (resolucion pass fallida,
GraphQL errors, HTTP no 200, o fallo de transporte). Si una variable
`pass:` no se puede resolver, NO se crea/actualiza el environment.
"""
resolved: list[dict] = []
resolved_secrets: list[str] = []
for var in variables:
key = var.get("key")
value = var.get("value", "")
secret = bool(var.get("secret", False))
if isinstance(value, str) and value.startswith(_PASS_PREFIX):
pass_path = value[len(_PASS_PREFIX):]
secret_res = pass_get_secret(pass_path)
if secret_res.get("status") != "ok":
# NO crear el env a medias: aborta con el key afectado.
return {
"status": "error",
"error": (
f"pass resolution failed for key {key!r} "
f"(path {pass_path!r}): {secret_res.get('error')}"
),
}
value = secret_res["value"]
secret = True
resolved_secrets.append(key)
resolved.append({"key": key, "value": value, "secret": secret})
variables_json = json.dumps(resolved)
# 1) Localiza un environment existente con este nombre (idempotencia).
try:
list_resp = requests.post(
f"{backend_url}/graphql",
json={"query": _LIST_QUERY, "variables": {"t": team_id}},
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
list_data = _parse_json(list_resp)
if list_data is None:
return {
"status": "error",
"error": f"non-JSON list response (HTTP {list_resp.status_code})",
}
if list_data.get("errors"):
return {"status": "error", "error": "graphql errors", "data": list_data}
team = (list_data.get("data") or {}).get("team") or {}
existing_id = None
for env in team.get("teamEnvironments") or []:
if env.get("name") == name:
existing_id = env.get("id")
break
# 2) Update si existe, create si no.
if existing_id:
query = _UPDATE_MUTATION
gql_vars = {"id": existing_id, "n": name, "v": variables_json}
result_field = "updateTeamEnvironment"
action = "updated"
else:
query = _CREATE_MUTATION
gql_vars = {"n": name, "t": team_id, "v": variables_json}
result_field = "createTeamEnvironment"
action = "created"
try:
resp = requests.post(
f"{backend_url}/graphql",
json={"query": query, "variables": gql_vars},
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
data = _parse_json(resp)
if data is None:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {"status": "error", "error": "graphql errors", "data": data}
env = (data.get("data") or {}).get(result_field)
if not env or not env.get("id"):
return {
"status": "error",
"error": f"{result_field} returned no id",
"data": data,
}
return {
"status": "ok",
"id": env["id"],
"name": env.get("name", name),
"action": action,
"resolved_secrets": resolved_secrets,
}
def _parse_json(resp):
"""Devuelve el JSON de la respuesta o None si no es JSON valido."""
try:
return resp.json()
except ValueError:
return None