Commit 55c3ef5f authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(r2): grant partners an agreement before federating

A partner only sees our zones if an agreement grants them, and it learns
them from the CreateFederation answer, so the agreement has to exist first.
Each oop_platform_partners entry takes an optional agreement (zone names,
GSMA terms, allowed requests); fm_partners.py resolves the names through SRM
and creates it before federating. A changed agreement replaces the active one,
which is suspended; an unchanged one is left alone. The federate scenario
grants oop_platform_partner_agreement to every partner it finds.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent b419a802
Loading
Loading
Loading
Loading
Loading
+3 −1
Changes for ansible/playbooks/scenarios/r2_existing_cluster/federate.yml: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,7 +2,8 @@
# Playbook: federate the operators of an r2_existing_cluster inventory
# Description: Each operator registers every other operator in the inventory that it
#   issued a federation client to (and that issued one back), then establishes the
#   federation. The client secrets are read from the partner's Keycloak Secret, so this
#   federation. An operator with oop_platform_partner_agreement grants those terms to
#   each of them first, so they see its zones once federated. The client secrets are read from the partner's Keycloak Secret, so this
#   needs access to every operator's cluster. Partners outside the inventory go in
#   oop_platform_partners, with their secret from the vault.
# Usage: ansible-playbook -i inventory/r2.local.yml playbooks/scenarios/r2_existing_cluster/federate.yml
@@ -55,6 +56,7 @@
          our_client_id: "{{ oop_platform_federation_id }}"
          our_client_secret: "{{ them.issued_client_secrets[oop_platform_federation_id] }}"
          federate: "{{ federate_partners | bool }}"
          agreement: "{{ oop_platform_partner_agreement | default({}) }}"
      loop: "{{ groups['oop_platform_operators'] | difference([inventory_hostname]) }}"
      when:
        - them.oop_platform_federation_id in oop_platform_federation_clients
+9 −0
Changes for ansible/roles/oop-platform/defaults/main.yml: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -103,7 +103,16 @@ oop_platform_extra_values: {}
#   our_client_secret      its secret (vault); sent when FM has none, or with rotate_secret
#   status                 active (default) or pending
#   federate               true = establish the federation if there is none yet
#   agreement              what the partner may use here; set before federating, since the
#                          partner learns our zones when the federation is created:
#     zones                our zone names, as SRM lists them
#     terms                GSMA terms offered in each zone: reservedComputeResources,
#                          computeResourceQuotaLimits, flavoursSupported
#     api_types            requests allowed (default install-app, remove-app)
#                          A changed agreement replaces the active one, which is suspended.
oop_platform_partners: []
# Same agreement for every partner the r2_existing_cluster federate playbook finds.
oop_platform_partner_agreement: {}
oop_platform_fm_deployment: federation-manager
# Federation Manager's internal API as seen from inside its own pod.
oop_platform_fm_internal_url: http://127.0.0.1:8083
+67 −3
Changes for ansible/roles/oop-platform/files/fm_partners.py: 67 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -6,19 +6,21 @@ command line; the summary printed on stdout never contains them.
"""

import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone

FM = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8082"
SRM = os.environ.get("FM_SRM_INTERNAL_URL", "http://localhost:8081")
UPDATABLE = ("name", "base_url", "our_client_id", "token_endpoint", "status")


def call(method, path, body=None):
def call(method, path, body=None, base=FM):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(
        FM + path, data=data, method=method, headers={"content-type": "application/json"}
        base + path, data=data, method=method, headers={"content-type": "application/json"}
    )
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
@@ -61,6 +63,59 @@ def sync(wanted, existing):
    return partner, "updated " + ", ".join(sorted(changes))


def covers(wanted, stored):
    """True when everything in wanted is in stored; FM may add fields of its own."""
    if isinstance(wanted, dict):
        return isinstance(stored, dict) and all(
            k in stored and covers(v, stored[k]) for k, v in wanted.items()
        )
    if isinstance(wanted, list):
        return (
            isinstance(stored, list)
            and len(wanted) == len(stored)
            and all(covers(w, s) for w, s in zip(wanted, stored))
        )
    return wanted == stored


def agree(partner, wanted, zone_ids):
    """Make the partner's active agreement match wanted; FM keeps the old ones for audit."""
    missing = [z for z in wanted["zones"] if z not in zone_ids]
    if missing:
        raise SystemExit(
            f"{partner['name']}: no zone named {', '.join(missing)} in SRM "
            f"(known: {', '.join(sorted(zone_ids)) or 'none'})"
        )
    ids = [zone_ids[z] for z in wanted["zones"]]
    body = {
        "partner_op_id": partner["id"],
        "permitted_api_types": sorted(wanted.get("api_types", ["install-app", "remove-app"])),
        "permitted_zone_ids": ids,
        "zone_resources": {i: wanted["terms"] for i in ids},
        "valid_from": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "status": "active",
    }
    detail = expect(*call("GET", f"/internal/partners/{partner['id']}"), 200)
    active = [a for a in detail.get("agreements", []) if a["status"] == "active"]
    for current in active:
        if (
            sorted(current["permitted_api_types"]) == body["permitted_api_types"]
            and sorted(current["permitted_zone_ids"]) == sorted(ids)
            and covers(body["zone_resources"], current["zone_resources"])
            and current["valid_until"] is None
        ):
            return current["id"], "unchanged"
    created = expect(*call("POST", "/internal/agreements", body), 201)
    for current in active:
        expect(*call("PATCH", f"/internal/agreements/{current['id']}", {"status": "suspended"}), 200)
    return created["id"], "created" + (f", suspended {len(active)} older" if active else "")


def local_zones():
    zones = expect(*call("GET", "/internal/zones", base=SRM), 200)
    return {zone["name"]: zone["id"] for zone in zones}


def federate(partner, status_link):
    path = f"/internal/partners/{partner['id']}/federations"
    status, body = call("GET", path)
@@ -79,14 +134,23 @@ def main():
    wanted = [{"status": "active", **partner} for partner in json.load(sys.stdin)]
    existing = expect(*call("GET", "/internal/partners"), 200)
    status_link = expect(*call("GET", "/internal/identity"), 200)["partner_status_link"]
    zone_ids = local_zones() if any(item.get("agreement") for item in wanted) else {}
    report = []
    for item in wanted:
        partner, partner_change = sync(item, existing)
        entry = {"name": item["name"], "id": partner["id"], "partner": partner_change}
        # before federating: the partner learns our zones from the CreateFederation answer
        if item.get("agreement"):
            entry["agreement_id"], entry["agreement"] = agree(partner, item["agreement"], zone_ids)
        if item.get("federate"):
            entry["federation_context_id"], entry["federation"] = federate(partner, status_link)
        report.append(entry)
    changed = any(e["partner"] != "unchanged" or e.get("federation") == "created" for e in report)
    changed = any(
        e["partner"] != "unchanged"
        or e.get("federation") == "created"
        or e.get("agreement", "unchanged") != "unchanged"
        for e in report
    )
    print(json.dumps({"changed": changed, "partners": report}))


+17 −3
Changes for ansible/roles/oop-platform/tasks/partners.yml: 17 added lines, 3 removed lines.
Original line number Diff line number Diff line
---
# Register oop_platform_partners in Federation Manager and, for those with `federate`,
# establish the federation. Goes through the FM pod, so FM's internal API never needs to
# be reachable from outside the cluster.
# Register oop_platform_partners in Federation Manager, grant those with an `agreement`
# its terms and, for those with `federate`, establish the federation. Goes through the FM
# pod, so FM's internal API never needs to be reachable from outside the cluster.

- name: Check the partner list
  ansible.builtin.assert:
@@ -21,6 +21,20 @@
  loop_control:
    label: "{{ item.name | default('?') }}"

- name: Check the agreements
  ansible.builtin.assert:
    that:
      - item.agreement.zones | default([]) | length > 0
      - item.agreement.terms | default({}) is mapping
      - item.agreement.terms.flavoursSupported | default([]) | length > 0
    fail_msg: >-
      {{ item.name }}: an agreement needs zones (our zone names) and terms with at least
      reservedComputeResources, computeResourceQuotaLimits and flavoursSupported.
    quiet: true
  loop: "{{ oop_platform_partners | selectattr('agreement', 'defined') | selectattr('agreement') | list }}"
  loop_control:
    label: "{{ item.name }}"

- name: Learn the target user's home, to resolve ~ in the kubeconfig path
  ansible.builtin.setup:
    gather_subset: [min]