File size: 10,241 Bytes
30d7d85
3c65377
30d7d85
3c65377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3449ec
827eb22
c3449ec
 
 
 
 
 
 
 
3c65377
 
 
 
 
 
 
 
 
c3449ec
 
 
 
 
 
 
 
3c65377
 
 
 
 
 
 
 
 
 
2f5718d
 
 
3c65377
 
 
30d7d85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6a9e6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30d7d85
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
import json
from typing import Tuple
from urllib.parse import urlsplit


def friendly_mcp_liveboard_error(raw_error: str) -> Tuple[str, str]:
    """Return a user-facing MCP liveboard error and a coarse failure category."""
    raw = (raw_error or "Unknown MCP error").strip()
    normalized = raw.lower()

    service_terms = [
        "timed out",
        "timeout",
        "unresponsive",
        "gateway",
        "504",
        "503",
        "502",
        "connection refused",
        "connection reset",
        "connection aborted",
        "connectionerror",
        "server disconnected",
        "service unavailable",
        "bad gateway",
        "network",
        "agent.thoughtspot.app",
    ]
    auth_terms = [
        "401",
        "403",
        "unauthorized",
        "forbidden",
        "trusted auth",
        "auth token",
        "authentication",
    ]
    answer_token_terms = [
        "failed to get answers for any questions",
        "no valid answers with thoughtspot session/tokens",
        "no liveboard-compatible answers",
        "without session/tokens",
        "empty-token",
        "empty token",
        "visualization_type': 'undefined",
        '"visualization_type": "undefined',
    ]

    if any(term in normalized for term in auth_terms):
        return (
            "Liveboard creation failed because ThoughtSpot MCP could not authenticate. "
            "Check the ThoughtSpot trusted auth key, username, and environment settings. "
            f"Raw MCP error: {raw}",
            "mcp_auth",
        )

    if any(term in normalized for term in answer_token_terms):
        return (
            "Liveboard creation could not retrieve MCP-compatible answer session/tokens. "
            "The model and data may still have deployed; creating a clearly marked backup liveboard is safe for this failure mode. "
            f"Raw MCP error: {raw}",
            "mcp_answer_tokens",
        )

    if any(term in normalized for term in service_terms):
        return (
            "Liveboard creation failed because the ThoughtSpot MCP service appears unavailable or timed out. "
            "The model and data may still have deployed; create the liveboard from the ThoughtSpot Spotter/Liveboard workflow once MCP is back. "
            f"Raw MCP error: {raw}",
            "mcp_service",
        )

    return (
        "Liveboard creation failed during the MCP liveboard generation step. "
        "The model and data are deployed, so the run finishes without a liveboard rather than "
        "falling back to another liveboard path β€” build a board from the model in ThoughtSpot, "
        "and fix the generation error (Prompt Log) before re-running. "
        f"Raw MCP error: {raw}",
        "mcp_generation",
    )


# --- Trusted-auth login responses (ISS-22) -----------------------------------
#
# What ThoughtSpot's answer to a trusted-auth token request
# (POST /api/rest/2.0/auth/token/full) says about a login, so a failure can name
# its real cause instead of "ThoughtSpot authentication failed". Shapes seen
# for real:
#   no account  HTTP 400, application/json, code 10002, debug
#               'Invalid parameter values: {"username":"Specified user does not exist."}'
#               (runs 2a1c7306af2a, b58980440961, 1a69f7ab4c7f; fake-user probes
#               of secloud and sebe 2026-10-14)
#   standby     HTTP 200, text/html "ThoughtSpot - Start Instance ... Your instance
#               entered standby mode ... Complete the verification below to wake
#               up your instance" (sebe 2026-10-06, runs b22397c2cfb3, c34abed7dddb)

# Outcomes that mean the build's own login WILL fail, so refusing up front loses
# nothing. Every other outcome lets the build go ahead.
LOGIN_BLOCKING = frozenset({"no_account", "rejected", "standby"})

_STANDBY_MARKERS = ("start instance", "standby mode")


def _json_or_none(text):
    try:
        return json.loads(text)
    except (TypeError, ValueError):
        return None


def _host_of_url(url: str) -> str:
    return urlsplit(url if "://" in url else f"https://{url}").hostname or url


def is_standby_page(text: str) -> bool:
    """True when a ThoughtSpot response is the cluster's standby / wake-up page."""
    low = (text or "").lower()
    return any(marker in low for marker in _STANDBY_MARKERS)


def ts_error_reason(text: str) -> str:
    """The reason inside a ThoughtSpot REST v2 error body; "" if `text` is not one.

    v2 nests it as {"error": {"message": {"debug": {"code": ..., "debug":
    "[\\"<reason>\\"]"}}}} β€” the innermost debug is a JSON-encoded list of
    strings. A body that is an error but not in that shape comes back whole.
    """
    body = _json_or_none(text)
    if not isinstance(body, dict) or not isinstance(body.get("error"), dict):
        return ""
    node = body["error"].get("message")
    while isinstance(node, dict):
        node = node.get("debug", node.get("message"))
    if isinstance(node, str):
        parsed = _json_or_none(node)
        if isinstance(parsed, list):
            return "; ".join(str(item) for item in parsed)
        return node
    return json.dumps(body["error"])[:300]


def classify_trusted_login(status_code: int, content_type: str, text: str) -> Tuple[str, str]:
    """(outcome, reason) for a trusted-auth token response.

    outcome:
      ok            200 with a token, or 204 (session cookie) β€” the login works
      no_account    ThoughtSpot says the user does not exist
      rejected      400/401/403 with a ThoughtSpot error body, any other reason
      standby       the cluster served its standby / wake-up page
      transient     429 or 5xx β€” says nothing about the user; worth retrying
      inconclusive  anything else (a 4xx that is not ThoughtSpot's, a 200 with
                    no token). Not proof the login fails, so not a refusal.
    """
    body = _json_or_none(text)
    shown_type = content_type or "none"
    if body is None and is_standby_page(text):
        return "standby", "the cluster served its 'Start Instance' standby page"
    if status_code == 429 or status_code >= 500:
        return "transient", f"HTTP {status_code}"
    if status_code == 204:
        return "ok", ""
    if status_code == 200:
        if isinstance(body, dict) and "token" in body:
            return "ok", ""
        return "inconclusive", f"HTTP 200 without a token (content-type {shown_type})"
    if status_code in (400, 401, 403) and isinstance(body, dict) and "error" in body:
        reason = ts_error_reason(text)
        if "user does not exist" in (text or "").lower():
            return "no_account", reason
        return "rejected", reason
    return "inconclusive", f"HTTP {status_code} (content-type {shown_type})"


def no_account_message(owner: str, ts_url: str) -> str:
    return (
        f"{owner} has no ThoughtSpot account on {_host_of_url(ts_url)}. Ask a ThoughtSpot "
        f"admin to add them, or pass ts_url for a cluster where they have one."
    )


def standby_message(ts_url: str) -> str:
    return (
        f"{_host_of_url(ts_url)} is in standby. Open {ts_url} in a browser and complete "
        f"the wake-up check, then retry."
    )


def rejected_login_message(owner: str, ts_url: str, status_code: int, reason: str) -> str:
    return (
        f"ThoughtSpot on {_host_of_url(ts_url)} refused a trusted-auth login for {owner} "
        f"(HTTP {status_code}): {reason}"
    )


def trusted_auth_failure_message(username: str, ts_url: str, status_code, body: str) -> str:
    """One sentence for a failed trusted-auth login that a person can act on.

    Names the user, the cluster and the cause β€” a missing account as the plain
    instruction (no_account_message), a standby cluster as its wake-up
    instruction, anything else as ThoughtSpot's own reason with the HTTP
    status. deploy_all raises this, so the run's errors, the MCP result and
    the Slack "Failed" post all say the same thing instead of the bare
    "ThoughtSpot authentication failed" four SEs retried against for two days
    (findings triage 2026-10-15, E2).
    """
    body = body or ""
    status = int(status_code or 0)
    outcome, reason = classify_trusted_login(status, "", body)
    # The body alone settles the two cases that have a plain instruction, so a
    # caller that lost the HTTP status (or recorded the deployer's own standby
    # sentence instead of the page) still gets the right answer.
    shown = f" (HTTP {status})" if status else ""
    if outcome == "no_account" or "user does not exist" in body.lower():
        # The instruction first β€” who, which cluster, what to do β€” then
        # ThoughtSpot's own words, so the run's errors still grep as an auth
        # failure and still quote the response.
        said = ts_error_reason(body) or " ".join(body.split())[:300]
        http = f", HTTP {status}" if status else ""
        return (
            f"{no_account_message(username, ts_url)} "
            f"(ThoughtSpot authentication failed{http}: {said})"
        )
    if outcome == "standby" or is_standby_page(body) or body == standby_message(ts_url):
        return f"ThoughtSpot authentication failed for {username} on {ts_url}: {standby_message(ts_url)}"
    if outcome in ("rejected", "transient"):
        cause = reason or ts_error_reason(body) or " ".join(body.split())[:300]
    else:
        cause = ts_error_reason(body) or " ".join(body.split())[:300]
    return (
        f"ThoughtSpot authentication failed for {username} on {ts_url}{shown}: "
        f"{cause or 'no detail recorded'}"
    )


def non_json_auth_error(ts_url: str, status_code: int, content_type: str, text: str) -> str:
    """The real cause behind a trusted-auth response that should have been JSON.

    Replaces the bare parse error ("Expecting value: line 1 column 1 (char 0)"),
    which is all the 2026-10-06 sebe standby runs recorded.
    """
    if is_standby_page(text):
        return standby_message(ts_url)
    snippet = " ".join((text or "").split())[:200]
    return (
        f"ThoughtSpot returned a non-JSON response to the trusted-auth token request "
        f"(HTTP {status_code}, content-type {content_type or 'none'}): {snippet!r}"
    )