"""Seed Jira credentials into the integration row created by migration 076. Migration 076 seeds a *disabled* Jira integration row with empty secrets. The actual credentials need to be encrypted with the environment's ``CREDENTIAL_ENCRYPTION_KEY``, which the migration can't know about, so this script handles that half. 1. Read ``HELIX_JIRA_SITE_URL`` (required, e.g. ``tidb.atlassian.net``), ``HELIX_JIRA_ADMIN_EMAIL`` (required), ``HELIX_JIRA_API_TOKEN`` (required, **user-level token** from ``id.atlassian.com/manage-profile/security/api-tokens``), ``HELIX_JIRA_ORG_ID`` (optional), and ``HELIX_JIRA_ORG_ADMIN_API_KEY`` (optional, **org-scoped key** from ``admin.atlassian.com/o/{orgId}/api-keys`` — only needed if binding ``action_on_offboard=delete``) from the environment. 2. Encrypt them with :mod:`app.domains.cmdb.credential_crypto`. 3. Update the ``integrations`` row where ``slug='jira-primary'``. 4. Optionally flip ``enabled=True`` (``--enable``) so the scheduler can start running ``jira.seat_sync`` immediately. 5. Optionally test the credentials via the driver (``--probe``) — calls ``/rest/api/3/myself`` so a typo lands as an error here rather than as a silent failure on the next sync. Idempotent: safe to re-run. Each invocation rewrites the encrypted blob in full, so rotating a token = update env + run again. Usage:: # local dev — .env already has the values uv run python scripts/seed_jira_integration.py --probe --enable # production — export first, then run once export HELIX_JIRA_SITE_URL=tidb.atlassian.net export HELIX_JIRA_ADMIN_EMAIL=shuai.feng@pingcap.com export HELIX_JIRA_API_TOKEN=ATATT... # user token from id.atlassian.com export HELIX_JIRA_ORG_ID=7b9308d0-... # optional export HELIX_JIRA_ORG_ADMIN_API_KEY=ATCTT... # optional uv run python scripts/seed_jira_integration.py --probe --enable """ from __future__ import annotations import argparse import asyncio import os import sys from typing import Any from sqlalchemy import select # Register mapper classes — same pattern as seed_zoom_integration.py / # seed_slack_integration.py. import app.approval.models # noqa: F401 import app.audit.models # noqa: F401 import app.auth.models # noqa: F401 import app.domains.apikey.models # noqa: F401 import app.domains.asset.models # noqa: F401 import app.domains.cmdb.models # noqa: F401 import app.domains.email_group.models # noqa: F401 import app.domains.hr.models # noqa: F401 import app.domains.offboarding.models # noqa: F401 import app.domains.permission.models # noqa: F401 import app.domains.provisioning.models # noqa: F401 import app.identity.person_models # noqa: F401 from app.core.database import async_session_factory from app.domains.cmdb.credential_crypto import encrypt_fields from app.integrations.drivers.jira_software import JiraSoftwareDriver from app.integrations.models import Integration _SLUG = "jira-primary" def _required_env(key: str) -> str: v = os.environ.get(key, "").strip() if not v: print( f"✗ {key} is not set. Required: HELIX_JIRA_SITE_URL, " "HELIX_JIRA_ADMIN_EMAIL, HELIX_JIRA_API_TOKEN (user-level " "token from id.atlassian.com — NOT the org admin key). " "Optional: HELIX_JIRA_ORG_ID, HELIX_JIRA_ORG_ADMIN_API_KEY.", file=sys.stderr, ) sys.exit(2) return v def _optional_env(key: str) -> str | None: v = os.environ.get(key, "").strip() return v or None async def _run(*, probe: bool, enable: bool) -> None: config: dict[str, Any] = { "site_url": _required_env("HELIX_JIRA_SITE_URL"), "admin_email": _required_env("HELIX_JIRA_ADMIN_EMAIL"), "api_token": _required_env("HELIX_JIRA_API_TOKEN"), } org_id = _optional_env("HELIX_JIRA_ORG_ID") if org_id: config["org_id"] = org_id org_admin_api_key = _optional_env("HELIX_JIRA_ORG_ADMIN_API_KEY") if org_admin_api_key: config["org_admin_api_key"] = org_admin_api_key if probe: print("→ Probing Jira /rest/api/3/myself with the supplied credentials...") driver = JiraSoftwareDriver(integration_id="probe", config=config) res = await driver.test_connection() if not res.ok: print(f"✗ Jira test_connection FAILED: {res.message}", file=sys.stderr) sys.exit(3) print(f"✓ Jira test_connection OK — {res.message}") ciphertext, nonce = encrypt_fields(config) async with async_session_factory() as db: row = (await db.execute( select(Integration).where(Integration.slug == _SLUG) )).scalar_one_or_none() if row is None: print( f"✗ No integrations row with slug={_SLUG!r}. " "Run `alembic upgrade head` first (migration 076 seeds " "the empty row).", file=sys.stderr, ) sys.exit(4) row.secrets_encrypted = ciphertext row.secrets_nonce = nonce # config_public stays `{}`; site_url, admin_email, org_id all # live in the encrypted blob alongside the token so a single # decrypt suffices for the driver. if enable: row.enabled = True row.is_primary = True await db.commit() print( "✓ Jira integration row updated " f"({'enabled' if enable else 'secrets only'}). " "Next: enable the `jira.seat_sync` scheduled job at " "/admin/scheduled-jobs if you haven't already." ) def main() -> None: parser = argparse.ArgumentParser( description="Seed Jira credentials into the integration row.", ) parser.add_argument( "--probe", action="store_true", help="Call Jira /rest/api/3/myself before writing.", ) parser.add_argument( "--enable", action="store_true", help="Flip the integration row to enabled=True after writing.", ) args = parser.parse_args() asyncio.run(_run(probe=args.probe, enable=args.enable)) if __name__ == "__main__": main()