feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user