Spaces:
Running
Running
chore(scripts): add scripts/ for operational tooling
Browse filesMoves whats_deployed.py and setup_test_mcp_secrets.py out of scratch/ (which
is gitignored and defined as disposable) into a tracked scripts/ dir. Both are
standing operational tools, not throwaways β whats_deployed.py is meant to run
at the start of every session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- scripts/setup_test_mcp_secrets.py +154 -0
- scripts/whats_deployed.py +128 -0
scripts/setup_test_mcp_secrets.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
One-off: copy the MCP Space secrets from the local .env into the TEST MCP Space.
|
| 4 |
+
|
| 5 |
+
source ./demoprep/bin/activate && python scratch/setup_test_mcp_secrets.py
|
| 6 |
+
|
| 7 |
+
Reads values straight from .env and pushes them to
|
| 8 |
+
`thoughtspot-demoprep/test-mcp` as Repository Secrets. Secret VALUES are never
|
| 9 |
+
printed β only the key names and whether each was set. HF's API is write-only
|
| 10 |
+
for secrets, so values cannot be read back out of the prod Space; .env is the
|
| 11 |
+
source of truth.
|
| 12 |
+
|
| 13 |
+
Why a separate MCP test Space exists: on 2026-08-25 a logging change went
|
| 14 |
+
straight to the prod MCP Space and failed every build (PermissionError on
|
| 15 |
+
/app/logs under the Space's non-root user). Deploy to test first.
|
| 16 |
+
|
| 17 |
+
Flags:
|
| 18 |
+
--dry-run show what would be set, touch nothing
|
| 19 |
+
--new-token generate a FRESH MCP_ACCESS_TOKEN for test instead of
|
| 20 |
+
reusing prod's (recommended β a leaked test token should
|
| 21 |
+
not unlock the prod MCP Space). Prints it ONCE so you can
|
| 22 |
+
hand it to the calling agent; it is not stored locally.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import secrets
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
REPO_ID = "thoughtspot-demoprep/test-mcp"
|
| 31 |
+
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
| 32 |
+
|
| 33 |
+
# Required by the MCP Space (see README.md "Required secrets").
|
| 34 |
+
REQUIRED = [
|
| 35 |
+
"SUPABASE_URL",
|
| 36 |
+
"SUPABASE_ANON_KEY",
|
| 37 |
+
"OPENAI_API_KEY",
|
| 38 |
+
"MCP_OWNER_EMAIL",
|
| 39 |
+
"MCP_TS_ENV_LABEL",
|
| 40 |
+
"MCP_ACCESS_TOKEN",
|
| 41 |
+
]
|
| 42 |
+
# Optional but commonly set.
|
| 43 |
+
OPTIONAL = ["GOOGLE_API_KEY", "ANTHROPIC_API_KEY", "MCP_SHARE_WITH",
|
| 44 |
+
"MCP_MAX_CONCURRENT_BUILDS"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_env(path: Path) -> dict:
|
| 48 |
+
if not path.exists():
|
| 49 |
+
sys.exit(f"ERROR: {path} not found β run from the repo root.")
|
| 50 |
+
env = {}
|
| 51 |
+
for raw in path.read_text().splitlines():
|
| 52 |
+
line = raw.strip()
|
| 53 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 54 |
+
continue
|
| 55 |
+
k, v = line.split("=", 1)
|
| 56 |
+
env[k.strip()] = v.strip().strip('"').strip("'")
|
| 57 |
+
return env
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> None:
|
| 61 |
+
ap = argparse.ArgumentParser()
|
| 62 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 63 |
+
ap.add_argument("--new-token", action="store_true")
|
| 64 |
+
args = ap.parse_args()
|
| 65 |
+
|
| 66 |
+
env = load_env(ENV_PATH)
|
| 67 |
+
|
| 68 |
+
# Collect every TS_ENV_<n>_* triple present, plus resolve the KEY_VAR
|
| 69 |
+
# indirection: TS_ENV_<n>_KEY_VAR holds the NAME of the var carrying the
|
| 70 |
+
# actual trusted-auth key, so that var has to be copied too.
|
| 71 |
+
ts_keys = sorted(k for k in env if k.startswith("TS_ENV_"))
|
| 72 |
+
indirect = []
|
| 73 |
+
for k in ts_keys:
|
| 74 |
+
if k.endswith("_KEY_VAR"):
|
| 75 |
+
target = env.get(k, "").strip()
|
| 76 |
+
if target and target in env:
|
| 77 |
+
indirect.append(target)
|
| 78 |
+
elif target:
|
| 79 |
+
print(f" !! {k} points at '{target}' which is NOT in .env "
|
| 80 |
+
f"β trusted auth will fail on test until you set it.")
|
| 81 |
+
|
| 82 |
+
to_set = REQUIRED + [k for k in OPTIONAL if k in env] + ts_keys + indirect
|
| 83 |
+
# de-dupe, preserve order
|
| 84 |
+
seen, ordered = set(), []
|
| 85 |
+
for k in to_set:
|
| 86 |
+
if k not in seen:
|
| 87 |
+
seen.add(k)
|
| 88 |
+
ordered.append(k)
|
| 89 |
+
|
| 90 |
+
fresh_token = None
|
| 91 |
+
if args.new_token:
|
| 92 |
+
fresh_token = secrets.token_urlsafe(32)
|
| 93 |
+
|
| 94 |
+
missing = [k for k in ordered
|
| 95 |
+
if not env.get(k) and not (k == "MCP_ACCESS_TOKEN" and fresh_token)]
|
| 96 |
+
|
| 97 |
+
print(f"Target Space: {REPO_ID}")
|
| 98 |
+
print(f"Reading: {ENV_PATH}")
|
| 99 |
+
print(f"\n{len(ordered)} secret(s) to set:")
|
| 100 |
+
for k in ordered:
|
| 101 |
+
if k == "MCP_ACCESS_TOKEN" and fresh_token:
|
| 102 |
+
state = "NEWLY GENERATED"
|
| 103 |
+
elif env.get(k):
|
| 104 |
+
state = "ok"
|
| 105 |
+
else:
|
| 106 |
+
state = "MISSING from .env"
|
| 107 |
+
print(f" {k:<28} {state}")
|
| 108 |
+
|
| 109 |
+
if missing:
|
| 110 |
+
print(f"\nWARNING: {len(missing)} required value(s) missing from .env: "
|
| 111 |
+
f"{', '.join(missing)}")
|
| 112 |
+
print("The Space will boot but builds will fail until these are set.")
|
| 113 |
+
|
| 114 |
+
if args.dry_run:
|
| 115 |
+
print("\n--dry-run: nothing was changed.")
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
if input("\nPush these to the test Space? [y/N] ").strip().lower() != "y":
|
| 119 |
+
sys.exit("Aborted.")
|
| 120 |
+
|
| 121 |
+
from huggingface_hub import HfApi
|
| 122 |
+
|
| 123 |
+
hf_token = env.get("HF_TOKEN")
|
| 124 |
+
if not hf_token:
|
| 125 |
+
sys.exit("ERROR: HF_TOKEN not in .env")
|
| 126 |
+
api = HfApi(token=hf_token)
|
| 127 |
+
|
| 128 |
+
ok, failed = 0, []
|
| 129 |
+
for k in ordered:
|
| 130 |
+
value = fresh_token if (k == "MCP_ACCESS_TOKEN" and fresh_token) else env.get(k)
|
| 131 |
+
if not value:
|
| 132 |
+
continue
|
| 133 |
+
try:
|
| 134 |
+
api.add_space_secret(repo_id=REPO_ID, key=k, value=value)
|
| 135 |
+
print(f" set {k}")
|
| 136 |
+
ok += 1
|
| 137 |
+
except Exception as exc:
|
| 138 |
+
print(f" FAILED {k}: {type(exc).__name__}: {exc}")
|
| 139 |
+
failed.append(k)
|
| 140 |
+
|
| 141 |
+
print(f"\nDone: {ok} set, {len(failed)} failed.")
|
| 142 |
+
if fresh_token:
|
| 143 |
+
print("\n" + "=" * 62)
|
| 144 |
+
print("FRESH TEST MCP_ACCESS_TOKEN (shown once β copy it now):")
|
| 145 |
+
print(f" {fresh_token}")
|
| 146 |
+
print("Give this to the calling agent for TEST. Prod keeps its own token.")
|
| 147 |
+
print("=" * 62)
|
| 148 |
+
print("\nNEXT: confirm the TS target. If MCP_TS_ENV_LABEL / MCP_OWNER_EMAIL")
|
| 149 |
+
print("match prod, test builds will create objects in the PROD ThoughtSpot")
|
| 150 |
+
print("instance. Point test at a non-prod TS env or a throwaway owner.")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
main()
|
scripts/whats_deployed.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
What is actually deployed, right now, on every Space.
|
| 4 |
+
|
| 5 |
+
.venv/bin/python scratch/whats_deployed.py
|
| 6 |
+
|
| 7 |
+
Answers the question that was unanswerable during the 2026-08-25 MCP outage:
|
| 8 |
+
which commit is each Space really running, and does any local branch have it?
|
| 9 |
+
|
| 10 |
+
For each Space it prints the live commit, the runtime stage, and β the part that
|
| 11 |
+
matters β a DRIFT verdict:
|
| 12 |
+
|
| 13 |
+
in sync the deployed sha is the tip of its source branch
|
| 14 |
+
behind source branch has newer commits not deployed yet
|
| 15 |
+
DRIFT the deployed sha is on NO local branch. Something was pushed
|
| 16 |
+
straight to the Space. The next normal deploy will revert it.
|
| 17 |
+
|
| 18 |
+
DRIFT is the condition that caused the outage's second-order problem: hotfix
|
| 19 |
+
4b87c36 lived only on the prod Space for ~10 hours while every local branch
|
| 20 |
+
still carried the bug.
|
| 21 |
+
|
| 22 |
+
Exit code is 1 if any Space is in DRIFT or not RUNNING, so this can gate a
|
| 23 |
+
deploy script later.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import subprocess
|
| 27 |
+
import time
|
| 28 |
+
import sys
|
| 29 |
+
|
| 30 |
+
# Space -> the branch it is supposed to be deployed from.
|
| 31 |
+
SPACES = [
|
| 32 |
+
("thoughtspot-demoprep/mcp", "deploy/mcp", "MCP PROD"),
|
| 33 |
+
("thoughtspot-demoprep/test-mcp", "deploy/mcp", "MCP test"),
|
| 34 |
+
("thoughtspot-dp/demoprep", "origin/main", "app PROD"),
|
| 35 |
+
("thoughtspot-dp/test-demoprep", "spike/portal-template", "app test"),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def git(*args, _tries: int = 5) -> str:
|
| 40 |
+
"""Run git, retrying if the host is temporarily out of process slots.
|
| 41 |
+
|
| 42 |
+
macOS fork() raises BlockingIOError when the per-user process table is
|
| 43 |
+
full (e.g. thousands of zombie browser helpers). That is a host condition,
|
| 44 |
+
not a git failure β back off and retry rather than crashing the report.
|
| 45 |
+
"""
|
| 46 |
+
for attempt in range(_tries):
|
| 47 |
+
try:
|
| 48 |
+
return subprocess.run(("git",) + args, capture_output=True,
|
| 49 |
+
text=True).stdout.strip()
|
| 50 |
+
except BlockingIOError:
|
| 51 |
+
if attempt == _tries - 1:
|
| 52 |
+
raise
|
| 53 |
+
time.sleep(0.5 * (attempt + 1))
|
| 54 |
+
return ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def hf_token() -> str:
|
| 58 |
+
for line in open(".env"):
|
| 59 |
+
line = line.strip()
|
| 60 |
+
if line.startswith("HF_TOKEN="):
|
| 61 |
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
| 62 |
+
sys.exit("ERROR: HF_TOKEN not found in .env")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def main() -> None:
|
| 66 |
+
from huggingface_hub import HfApi
|
| 67 |
+
|
| 68 |
+
api = HfApi(token=hf_token())
|
| 69 |
+
git("fetch", "--all", "--quiet")
|
| 70 |
+
|
| 71 |
+
print(f"{'SPACE':<34} {'TIER':<10} {'DEPLOYED':<9} {'STAGE':<14} SOURCE / DRIFT")
|
| 72 |
+
print("-" * 104)
|
| 73 |
+
|
| 74 |
+
problems = 0
|
| 75 |
+
for repo_id, source, tier in SPACES:
|
| 76 |
+
try:
|
| 77 |
+
info = api.space_info(repo_id)
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
print(f"{repo_id:<34} {tier:<10} {'?':<9} {'UNREACHABLE':<14} {type(exc).__name__}")
|
| 80 |
+
problems += 1
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
sha = (info.sha or "")[:7]
|
| 84 |
+
stage = getattr(info.runtime, "stage", "?") or "?"
|
| 85 |
+
|
| 86 |
+
# Does any local branch contain the deployed commit?
|
| 87 |
+
if sha and git("cat-file", "-t", sha) == "commit":
|
| 88 |
+
holders = [b.strip().lstrip("* +").strip()
|
| 89 |
+
for b in git("branch", "--contains", sha).splitlines() if b.strip()]
|
| 90 |
+
if not holders:
|
| 91 |
+
verdict = "DRIFT β on no local branch! pushed direct to Space"
|
| 92 |
+
problems += 1
|
| 93 |
+
else:
|
| 94 |
+
tip = git("rev-parse", "--short=7", source)
|
| 95 |
+
if tip == sha:
|
| 96 |
+
verdict = f"in sync with {source}"
|
| 97 |
+
else:
|
| 98 |
+
ahead = git("rev-list", "--count", f"{sha}..{source}")
|
| 99 |
+
behind = git("rev-list", "--count", f"{source}..{sha}")
|
| 100 |
+
if behind != "0":
|
| 101 |
+
verdict = (f"DRIFT β {behind} commit(s) deployed that {source} "
|
| 102 |
+
f"lacks (+{ahead} undeployed)")
|
| 103 |
+
problems += 1
|
| 104 |
+
else:
|
| 105 |
+
verdict = f"behind {source} by {ahead} commit(s)"
|
| 106 |
+
else:
|
| 107 |
+
verdict = f"deployed sha {sha} UNKNOWN locally β run git fetch --all"
|
| 108 |
+
problems += 1
|
| 109 |
+
|
| 110 |
+
if stage != "RUNNING":
|
| 111 |
+
problems += 1
|
| 112 |
+
|
| 113 |
+
print(f"{repo_id:<34} {tier:<10} {sha:<9} {stage:<14} {verdict}")
|
| 114 |
+
|
| 115 |
+
print()
|
| 116 |
+
if problems:
|
| 117 |
+
print(f"{problems} issue(s). A DRIFT verdict means: merge the Space's commit back "
|
| 118 |
+
f"into its source branch BEFORE the next deploy, or it gets reverted.")
|
| 119 |
+
else:
|
| 120 |
+
print("All Spaces running and in sync with their source branches.")
|
| 121 |
+
|
| 122 |
+
print("\nNote: 'RUNNING' only means the container booted. It does NOT mean builds "
|
| 123 |
+
"work β GET / returned ok throughout the 2026-08-25 total build outage.")
|
| 124 |
+
sys.exit(1 if problems else 0)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|