{
  "version": 1,
  "id": "multicloud-lock-in-case-study",
  "title": "Case Study: A SaaS Leaves Single-Cloud Lock-In",
  "summary": "Plan identity federation, data portability and a staged cutover away from a single provider.",
  "estimatedMinutes": 40,
  "prerequisites": [],
  "source": "courses-notebook CLD302_multi_cloud_architecture 08_case_study",
  "lesson": "Full walk-through of the architecture, sequencing, and instrumentation needed for a mid-stage B2B SaaS to deliberately leave single-cloud lock-in. Provider APIs are mocked as plain Python objects so every cell runs on stdlib alone; the same logic ports to Terraform / boto3 / google-cloud-* / azure-sdk by swapping the mocks for real clients.\n\n**Scenario.** `acme-platform` runs entirely on AWS us-east-1: Django on EKS, Postgres on RDS, ML on SageMaker, identity scattered across IAM. Leadership signs off on a 12-month plan: keep the app and the database on AWS, move ML to GCP for cost and tooling, consolidate identity behind Azure AD. The job: a migration plan that holds availability, controls egress, and proves the case with numbers a CFO will read.\n\n## Architecture under test\n\n```\n         users\n           |\n  [Azure AD: OIDC IdP]\n           |    \\\n           v     \\___ WIF ___> [GCP: Vertex AI, GCS Parquet]\n   [AWS: EKS app + RDS Postgres]            ^\n           |                                 |\n           +------- nightly S3 -> GCS -------+\n           |                                 |\n           +------- cross-cloud inference ---+ (50 GB/mo egress)\n```\n\nThree boundaries, each with explicit costs:\n1. **Identity boundary** \u2014 Azure AD as the single source of truth; AWS and GCP federate, no long-lived keys.\n2. **Data boundary** \u2014 Parquet on object storage as the interchange; CSV-on-S3 never crosses a cloud.\n3. **Compute boundary** \u2014 AWS for the transactional plane, GCP for the ML plane; the seam is the inference call.\n\nEvery boundary is a place failures compound. If a boundary's value does not exceed the operational tax it adds, delete it from the design.\n\n## Part 1 \u2014 Mock the provider primitives\n\n```python\nimport random, statistics, time\nfrom dataclasses import dataclass, field\nfrom typing import Callable, Optional\n\nrandom.seed(13)\n\n@dataclass(frozen=True)\nclass Workload:\n    name: str\n    provider: str            # 'aws' | 'gcp' | 'azure'\n    compute_usd: float\n    storage_usd: float\n    egress_usd: float\n    managed_usd: float\n\n    @property\n    def total(self) -> float:\n        return self.compute_usd + self.storage_usd + self.egress_usd + self.managed_usd\n\nCURRENT = [\n    Workload('EC2 app servers (4 x c5.4xlarge)',          'aws', 2240,    0,    0,    0),\n    Workload('RDS PostgreSQL Multi-AZ (r5.2xlarge)',      'aws',  980,  115,    0,    0),\n    Workload('SageMaker training (ml.p3.2xlarge, 4h/d)',  'aws', 3600,    0,    0,  450),\n    Workload('SageMaker inference endpoint',              'aws', 1200,    0,    0,    0),\n    Workload('S3 training data + egress',                 'aws',    0, 11.5, 45.0,    0),\n    Workload('Glue data prep',                            'aws',  320,    0,    0,    0),\n]\nTARGET = [\n    Workload('EKS app servers (4 x m5.2xlarge)',          'aws', 1840,    0,    0,  120),\n    Workload('RDS PostgreSQL Multi-AZ (r5.2xlarge)',      'aws',  980,  115,    0,    0),\n    Workload('Vertex AI training (A100, 4h/day)',         'gcp', 1800,    0,    0,    0),\n    Workload('Vertex AI online prediction endpoint',      'gcp',  600,    0,    0,    0),\n    Workload('GCS feature store (Parquet, 500 GB)',       'gcp',    0, 10.0,    0,    0),\n    Workload('S3 -> GCS nightly sync + egress',           'aws',    0, 11.5, 45.0,    0),\n    Workload('App -> GCP inference egress (50 GB/mo)',    'aws',    0,    0,  4.5,    0),\n    Workload('Azure AD (P1, 100 users)',                  'azure',  0,    0,    0,   60),\n]\n```\n\n**Why typed workloads.** A `Workload` is a contract \u2014 every new line item is a code change a peer can review. Spreadsheets drift; typed lists do not.\n\n## Part 2 \u2014 Cost diff with by-provider breakdown\n\n```python\ndef cost_summary(items, label):\n    by = {}\n    for w in items:\n        by[w.provider] = by.get(w.provider, 0) + w.total\n    total = sum(w.total for w in items)\n    print(f'=== {label} ===')\n    for p in sorted(by):\n        print(f'  {p:6s} ${by[p]:>9,.2f}/mo')\n    print(f'  {\"total\":6s} ${total:>9,.2f}/mo\\n')\n    return total\n\ncur = cost_summary(CURRENT, 'CURRENT (AWS-only)')\ntgt = cost_summary(TARGET,  'TARGET (AWS + GCP + Azure ID)')\nprint(f'monthly delta: ${cur - tgt:,.2f}  ({(cur - tgt)/cur*100:.1f}% of current)')\nprint(f'annual delta:  ${(cur - tgt)*12:,.2f}')\nprint('note: operational complexity also rises; savings must justify it.')\n```\n\n**Read the breakdown, not the bottom line.** The headline savings are real, but the new `app -> GCP inference egress` line is the seam you will be paged about. Every multi-cloud design has at least one of these; they grow non-linearly with traffic and must be modelled before commitment, not after.\n\n## Part 3 \u2014 Identity federation (Azure AD -> AWS + GCP, no long-lived keys)\n\n```python\n@dataclass(frozen=True)\nclass OIDCToken:\n    subject: str\n    issuer: str\n    audience: str\n    expires_in_s: int\n\n@dataclass\nclass CloudCreds:\n    provider: str\n    access_token: str\n    expires_in_s: int\n\ndef issue_oidc(subject: str, audience: str) -> OIDCToken:\n    return OIDCToken(subject=subject, issuer='https://login.microsoftonline.com/acme', audience=audience, expires_in_s=900)\n\ndef aws_assume_role_with_web_identity(token: OIDCToken, role_arn: str) -> CloudCreds:\n    assert token.audience == 'sts.amazonaws.com', 'audience mismatch'\n    assert token.expires_in_s > 0,                'token expired'\n    return CloudCreds(provider='aws', access_token=f'aws-sts:{role_arn}:{token.subject}', expires_in_s=3600)\n\ndef gcp_workload_identity_federation(token: OIDCToken, sa_email: str) -> CloudCreds:\n    assert token.audience.startswith('//iam.googleapis.com/projects/'), 'audience mismatch'\n    return CloudCreds(provider='gcp', access_token=f'gcp-wif:{sa_email}:{token.subject}', expires_in_s=3600)\n\ntok_aws = issue_oidc('user:alice@acme.io', 'sts.amazonaws.com')\ntok_gcp = issue_oidc('user:alice@acme.io', '//iam.googleapis.com/projects/acme-ml/locations/global/workloadIdentityPools/aad/providers/aad')\nprint(aws_assume_role_with_web_identity(tok_aws, 'arn:aws:iam::123:role/eks-app'))\nprint(gcp_workload_identity_federation(tok_gcp, 'vertex-runner@acme-ml.iam.gserviceaccount.com'))\n```\n\n**No long-lived keys, anywhere.** The federation flow above means there is exactly *one* trust root (Azure AD), exactly *one* place to disable a leaver, and zero stored secrets in either cloud. If you cannot draw the trust chain on a whiteboard, your federation design is wrong and you will discover it during an incident.\n\n## Part 4 \u2014 Data portability: CSV-on-S3 -> Parquet on object storage\n\n```python\n@dataclass\nclass ParquetSync:\n    src_uri: str           # s3://acme-train/2026/05/data.csv\n    dst_uri: str           # gs://acme-train-parquet/2026/05/data.parquet\n    bytes_transferred: int\n    schema_checked: bool\n\ndef sync_csv_to_parquet(src_uri: str, dst_uri: str, gb: float, schema_ok: bool) -> ParquetSync:\n    # Mock: validate the schema before write; refuse on drift.\n    if not schema_ok:\n        raise RuntimeError(f'schema drift detected at {src_uri}; refusing to sync')\n    return ParquetSync(src_uri, dst_uri, int(gb * 1e9), True)\n\nsyncs = [\n    sync_csv_to_parquet(f's3://acme-train/day-{d:02d}.csv', f'gs://acme-train-parquet/day-{d:02d}.parquet', 16.5, schema_ok=True)\n    for d in range(1, 4)\n]\nfor s in syncs:\n    print(f'  {s.src_uri:40s} -> {s.dst_uri:50s}  {s.bytes_transferred/1e9:.1f} GB')\n\ntry:\n    sync_csv_to_parquet('s3://acme-train/bad.csv', 'gs://acme-train-parquet/bad.parquet', 1.0, schema_ok=False)\nexcept RuntimeError as e:\n    print(f'\\nrefused: {e}')\n```\n\n**Parquet is the interchange, not the storage.** Both clouds can read Parquet at full throughput; CSV is the format that costs you twice (size, schema drift). Sync jobs that do not validate schema *will* eventually propagate a corruption across the boundary.\n\n## Part 5 \u2014 Strangler-fig cutover with a disagreement gate\n\n```python\n@dataclass\nclass InferenceReq:\n    rid: str\n    features: list[float]\n    expected: int\n\ndef sagemaker_infer(req):\n    pred = 1 if sum(req.features) > 2.5 else 0\n    return pred, max(80, random.gauss(180, 30))\n\ndef vertex_infer(req):\n    pred = 1 if sum(req.features) + random.gauss(0, 0.05) > 2.5 else 0\n    return pred, max(20, random.gauss(45, 10))\n\ndef parallel_run(reqs, vertex_pct):\n    sm_lat, v_lat = [], []\n    sm_ok = v_ok = disagree = n_v = 0\n    for r in reqs:\n        sm_p, sm_t = sagemaker_infer(r); sm_lat.append(sm_t)\n        if sm_p == r.expected: sm_ok += 1\n        if random.random() < vertex_pct:\n            n_v += 1\n            v_p, v_t = vertex_infer(r); v_lat.append(v_t)\n            if v_p == r.expected: v_ok += 1\n            if v_p != sm_p:       disagree += 1\n    return {\n        'n': len(reqs), 'vertex_pct': vertex_pct, 'n_v': n_v,\n        'sm_acc': sm_ok / len(reqs),\n        'v_acc':  v_ok / n_v if n_v else None,\n        'disagreement': disagree / n_v if n_v else None,\n        'sm_p50_ms': sorted(sm_lat)[len(sm_lat)//2],\n        'v_p50_ms':  sorted(v_lat)[len(v_lat)//2] if v_lat else None,\n    }\n\ndef cutover_gate(m, max_disagree=0.005):\n    if m['disagreement'] is None:                  return False, 'no vertex traffic yet'\n    if m['disagreement'] > max_disagree:           return False, f'disagreement {m[\"disagreement\"]:.3%} > {max_disagree:.3%}'\n    return True, f'disagreement {m[\"disagreement\"]:.3%}: ready'\n\nREQS = [InferenceReq(f'r{i:04d}', [random.uniform(0,1) for _ in range(5)], random.choice([0,1])) for i in range(500)]\nfor label, pct in [('shadow 5%', 0.05), ('canary 20%', 0.20), ('ramp 50%', 0.50), ('cutover 100%', 1.00)]:\n    random.seed(42)\n    m = parallel_run(REQS, pct)\n    ok, reason = cutover_gate(m)\n    print(f'{label:14s} n_v={m[\"n_v\"]:>3d}  sm_acc={m[\"sm_acc\"]:.3f}  v_acc={m[\"v_acc\"]:.3f}  '\n          f'p50: sm={m[\"sm_p50_ms\"]:.0f}ms v={m[\"v_p50_ms\"]:.0f}ms  [{\"GO\" if ok else \"HOLD\"}] {reason}')\n```\n\n**Strangler-fig with a gate beats a flag-day, every time.** Shadow, canary, ramp, cutover \u2014 each phase has a numeric exit condition. The gate is not a vibe call; it is a Python function that returns a bool. Anything else and the cutover stalls on opinions in a meeting room.\n\n## Part 6 \u2014 Resilience composition (active-passive across two clouds)\n\n```python\n@dataclass\nclass Endpoint:\n    provider: str\n    healthy: bool\n    region: str\n\ndef dns_resolve(endpoints, prefer):\n    primary = next((e for e in endpoints if e.provider == prefer and e.healthy), None)\n    if primary: return primary\n    return next((e for e in endpoints if e.healthy), None)\n\ndef availability_serial(a, b):\n    return a * b  # both must be up: lower availability\n\ndef availability_parallel(a, b):\n    return 1 - (1 - a) * (1 - b)  # either suffices: higher availability\n\nendpoints = [Endpoint('aws', True, 'us-east-1'), Endpoint('gcp', True, 'us-central1')]\nprint(f'normal:                {dns_resolve(endpoints, \"aws\")}')\nendpoints[0] = Endpoint('aws', False, 'us-east-1')\nprint(f'aws region down:       {dns_resolve(endpoints, \"aws\")}')\n\naws_slo = 0.9995; gcp_slo = 0.9990\nprint(f'\\nserial composition (need both clouds):   {availability_serial(aws_slo, gcp_slo):.5f}')\nprint(f'parallel composition (either suffices):  {availability_parallel(aws_slo, gcp_slo):.5f}')\n```\n\n**Composition rule.** Adding a second cloud *in serial* (you need both up) makes you less available, not more. It only buys availability when the dependency is *parallel* \u2014 either cloud satisfies the request. Most multi-cloud designs start as parallel on paper and become serial in production (shared identity, shared data sync, shared CI). Re-audit yearly.\n\n## Part 7 \u2014 Decision table: when each multi-cloud move is right\n\n| Driver | When it is real | When it is a bad reason |\n|---|---|---|\n| Cost arbitrage | One workload class is materially cheaper on the other cloud (e.g. ML compute) | A spot-price gap that closes quarterly |\n| Vendor leverage | Negotiating a new commit; you need a credible BATNA | You already signed the commit yesterday |\n| Data residency | A jurisdiction your primary cloud does not serve | A regulation you assumed without reading |\n| Resilience | Truly parallel failure domains (different orgs, different ops) | A serial composition you label \"parallel\" on the diagram |\n| Team skill | A team that is already shipping on the other cloud | A hiring slide that says \"polyglot\" |\n| Identity consolidation | Centralising on a corporate IdP you already pay for | Adding a third IdP just to be neutral |\n\nPrint this; tape it to the next architecture review.\n\n## Part 8 \u2014 Regression gate and migration runbook\n\n```python\nBASELINE = {\n    'monthly_usd':         8_700,  # post-migration target\n    'egress_usd':            120,  # cross-cloud egress headroom\n    'inference_p50_ms':       60,  # vertex p50 must beat sagemaker by 3x\n    'identity_long_lived':     0,  # zero long-lived keys in either cloud\n}\nTOL = 0.10\n\ncurrent = {\n    'monthly_usd':         sum(w.total for w in TARGET),\n    'egress_usd':          sum(w.egress_usd for w in TARGET),\n    'inference_p50_ms':    50,\n    'identity_long_lived':  0,\n}\nfailed = []\nfor k, base in BASELINE.items():\n    cur = current[k]\n    if k == 'identity_long_lived':\n        ok = cur == 0\n    elif k == 'inference_p50_ms' or k == 'egress_usd' or k == 'monthly_usd':\n        ok = cur <= base * (1 + TOL)\n    print(f'  {k:22s} baseline={base:>8}  current={cur:>8}  [{\"OK\" if ok else \"FAIL\"}]')\n    if not ok: failed.append(k)\nprint(f'\\nmigration verdict: {\"BLOCK\" if failed else \"PROCEED\"}')\n```\n\n**Migration runbook (one page).**\n\n| Step | Owner | Exit condition |\n|---|---|---|\n| 1. Stand up Azure AD as IdP; configure AWS + GCP federation | Platform | OIDC + WIF working; zero long-lived keys |\n| 2. Build the Parquet sync; validate schema on every run | Data | 7 nights without a refusal |\n| 3. Stand up Vertex AI endpoint behind a feature flag | ML | Shadow traffic at 5% for 7 days |\n| 4. Run the parallel-run gate at 5 / 20 / 50 / 100% | ML + Platform | Disagreement < 0.5% at each phase |\n| 5. Decommission SageMaker | ML | Endpoint deleted, IAM role revoked |\n| 6. Re-run the cost model on 30 days of live data | Finance + Platform | Within 10% of target |\n| 7. Quarterly: re-audit the serial-vs-parallel resilience map | Architecture | Map matches reality |\n\nAnything not in the runbook is not part of the migration. New asks go to the next quarter, or they re-open the runbook with an owner and an exit condition.\n\n## Reflection\n\n**What this case study should leave you with**\n\n- Multi-cloud is *deliberate boundaries*, not a default posture. Each boundary has an operational tax that must be paid by a specific value.\n- Egress is the seam that bites; model it before commitment, instrument it after.\n- Federation gives you one trust root and zero long-lived keys; everything else is a footgun waiting for an incident.\n- Parquet (or any open columnar format) is what makes the next migration cheap; CSV makes it expensive twice.\n- A strangler-fig cutover with a numeric disagreement gate beats a flag-day every time.\n- Adding a second cloud in *serial* lowers availability; only *parallel* composition raises it. Audit yearly.\n\n\n\n**Where to go next.** `CLD303 Cloud Cost Engineering` deepens the egress and commitment modelling; `SYS401 Reliability Engineering` formalises the serial-vs-parallel availability calculus; `AI704 Open Source LLMs` is the *next* candidate workload to lift off the proprietary inference vendor \u2014 same playbook, different boundary.\n",
  "quiz": []
}
