eb8dbf66a1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
6.2 KiB
Python
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
|