Commit 8acd599f authored by Pierre Smeyers's avatar Pierre Smeyers
Browse files

Merge branch '1-kubeconfig-support' into 'main'

feat: add kubeconfig endpoint

Closes #1

See merge request to-be-continuous/tools/azure-auth-provider!2
parents 02deb339 c4a839d6
Loading
Loading
Loading
Loading
+24 −5
Original line number Diff line number Diff line
@@ -36,8 +36,9 @@ If you wish to use this authentication mode, please apply carefully the instruct

* `AZURE_JWT` for the JWT token (using GitLab [ID Tokens](https://docs.gitlab.com/ci/secrets/id_token_authentication/)),
* `AZURE_CLIENT_ID`: the configured client's ID.
* `AZURE_TENANT_ID`: the Azure tenant's ID.

You may specialize the last variable for the current `env_ctx`.
You may specialize the last two variables for the current `env_ctx` or pass then in the API call.

### `GET /acr/auth/password`

@@ -48,6 +49,24 @@ The endpoint will try to guess the `registry` parameter from several to-be-conti
#### Query parameters

| Name        | Description                                                   | Required                         |
| ---------- | ------------------------------------------------------------- | --------------------- |
| ----------- | ------------------------------------------------------------- | -------------------------------- |
| `env_ctx`   | the [environment context to consider](#the-notion-of-env_ctx) | no _(can be guessed)_            |
| `registry` | the domain name of the registry to authenticate to            | no _(can be guessed)_ |
| `tenant_id` | the identifiant of the Azure Tenant                           | no _(can be retrieved from env)_ |
| `client_id` | the identifiant of the managed identity to authenticate as    | no _(can be retrieved from env)_ |
| `registry`  | the domain name of the registry to authenticate to            | no _(can be retrieved from env)_ |


### `GET /kubeconfig`

This API generates a full kubeconfig file for an AKS cluster. The kubeconfig will however need to be used with a kubectl with the [kubelogin](https://github.com/Azure/kubelogin/) credentials exec plugin. A `kubelogin convert-kubeconfig` might be needed to convert the returned kubeconfig file to fit your needs.

#### Query parameters

| Name                  | Description                                                   | Required                         |
| --------------------- | ------------------------------------------------------------- | -------------------------------- |
| `env_ctx`             | the [environment context to consider](#the-notion-of-env_ctx) | no _(can be guessed)_            |
| `tenant_id`           | the identifiant of the Azure Tenant                           | no _(can be retrieved from env)_ |
| `client_id`           | the identifiant of the managed identity to authenticate as    | no _(can be retrieved from env)_ |
| `subscription_id`     | the identifiant of your subscription                          | no _(can be retrieved from env)_ |
| `resource_group_name` | the name of the resource group containing the AKS cluster     | no _(can be retrieved from env)_ |
| `resource_name`       | the name of the AKS cluster                                   | no _(can be retrieved from env)_ |
+121 −23
Original line number Diff line number Diff line
@@ -4,12 +4,13 @@ import os
import re
from http import HTTPStatus
from types import NoneType
from typing import Union
from typing import Optional, Union

import requests
from azure.core.exceptions import ClientAuthenticationError
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
from azure.identity import ClientAssertionCredential, CredentialUnavailableError
from fastapi import FastAPI, HTTPException, Query, Request, Response
from azure.mgmt.containerservice import ContainerServiceClient
from fastapi import FastAPI, Query, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse
@@ -55,26 +56,101 @@ def ping():
    return "ok"


@app.get("/kubeconfig", response_class=PlainTextResponse)
def get_kubeconfig(
    env_ctx: Optional[str] = Query(default=None, alias="env_ctx"),
    tenant_id: Optional[str] = Query(default=None, alias="tenant_id"),
    client_id: Optional[str] = Query(default=None, alias="client_id"),
    subscription_id: Optional[str] = Query(default=None, alias="subscription_id"),
    resource_group_name: Optional[str] = Query(
        default=None, alias="resource_group_name"
    ),
    resource_name: Optional[str] = Query(default=None, alias="resource_name"),
):
    if not (gitlab_oidc_token := os.getenv("AZURE_JWT", None)):
        raise StarletteHTTPException(status_code=400, detail="No OIDC token found")

    if not env_ctx:
        env_ctx = guess_env_ctx()

    if not (tenant_id or (tenant_id := get_env_var_with(env_ctx, "AZURE_TENANT_ID"))):
        raise StarletteHTTPException(status_code=400, detail="No Azure tenant id found")

    if not (client_id or (client_id := get_env_var_with(env_ctx, "AZURE_CLIENT_ID"))):
        raise StarletteHTTPException(status_code=400, detail="No Azure client id found")

    def get_token() -> str:
        return gitlab_oidc_token

    credentials = ClientAssertionCredential(
        tenant_id=tenant_id, client_id=client_id, func=get_token
    )

    if not (
        subscription_id
        or (subscription_id := get_env_var_with(env_ctx, "AZURE_AKS_SUBSCRIPTION_ID"))
    ):
        raise StarletteHTTPException(status_code=400, detail="No Azure tenant id found")

    if not (
        resource_group_name
        or (
            resource_group_name := get_env_var_with(env_ctx, "AZURE_AKS_RESOURCE_GROUP")
        )
    ):
        raise StarletteHTTPException(status_code=400, detail="No Azure tenant id found")

    if not (
        resource_name
        or (resource_name := get_env_var_with(env_ctx, "AZURE_AKS_RESOURCE_NAME"))
    ):
        raise StarletteHTTPException(status_code=400, detail="No Azure tenant id found")

    client = ContainerServiceClient(
        credential=credentials, subscription_id=subscription_id
    )

    try:
        cluster_creds = client.managed_clusters.list_cluster_user_credentials(
            resource_group_name=resource_group_name,
            resource_name=resource_name,
        )
    except HttpResponseError as e:
        raise StarletteHTTPException(
            status_code=500, detail=f"Failed to auth : {str(e.error)}"
        )

    if (
        cluster_creds
        and cluster_creds.kubeconfigs
        and cluster_creds.kubeconfigs[0].value
    ):
        return cluster_creds.kubeconfigs[0].value

    raise StarletteHTTPException(status_code=500, detail="Failed to auth")


@app.get("/acr/auth/password", response_class=PlainTextResponse)
def get_acr_auth_password(
    env_ctx: str = Query(default=None, alias="env_ctx"),
    registry: str = Query(default=None, alias="registry"),
    env_ctx: Optional[str] = Query(default=None, alias="env_ctx"),
    tenant_id: Optional[str] = Query(default=None, alias="tenant_id"),
    client_id: Optional[str] = Query(default=None, alias="client_id"),
    registry: Optional[str] = Query(default=None, alias="registry"),
):
    if not (gitlab_oidc_token := os.getenv("AZURE_JWT", None)):
        raise HTTPException(status_code=400, detail="No OIDC token found")
        raise StarletteHTTPException(status_code=400, detail="No OIDC token found")

    if not env_ctx:
        env_ctx = guess_env_ctx()

    if not (tenant_id := get_env_var_with(env_ctx, "AZURE_TENANT_ID")):
        raise HTTPException(status_code=400, detail="No Azure tenant id found")
    if not (tenant_id or (tenant_id := get_env_var_with(env_ctx, "AZURE_TENANT_ID"))):
        raise StarletteHTTPException(status_code=400, detail="No Azure tenant id found")

    if not (client_id := get_env_var_with(env_ctx, "AZURE_CLIENT_ID")):
        raise HTTPException(status_code=400, detail="No Azure client id found")
    if not (client_id or (client_id := get_env_var_with(env_ctx, "AZURE_CLIENT_ID"))):
        raise StarletteHTTPException(status_code=400, detail="No Azure client id found")

    if not registry:
        if not (registry := guess_registry(env_ctx)):
            raise HTTPException(
    if not (registry or (registry := guess_registry(env_ctx))):
        raise StarletteHTTPException(
            status_code=400, detail="Unable to determine target registry"
        )

@@ -88,15 +164,22 @@ def get_acr_auth_password(
    try:
        token = credentials.get_token("https://management.azure.com/.default").token
    except CredentialUnavailableError:
        raise HTTPException(status_code=400, detail="Missing information")
        raise StarletteHTTPException(status_code=400, detail="Missing information")
    except ClientAuthenticationError as e:
        raise HTTPException(status_code=500, detail=f"Failed to auth {e.message}")
        raise StarletteHTTPException(
            status_code=500, detail=f"Failed to auth {e.message}"
        )

    if not token:
        raise StarletteHTTPException(status_code=500, detail="Empty token")

    try:
        r = requests.get(f"https://{registry}/v2/")
    except requests.exceptions.RequestException as e:
        logger.error("failed to fetch auth configuration of ACR")
        raise HTTPException(
        logger.error(
            "failed to fetch auth configuration of ACR, make your registry is network available to the runner"
        )
        raise StarletteHTTPException(
            status_code=500, detail=f"Failed to contact azure : {e.strerror}"
        )
    header = r.headers["Www-Authenticate"]
@@ -109,7 +192,7 @@ def get_acr_auth_password(
        logger.error(
            f"Claims do not contains either service or realm key :{' '.join(claims.keys())}"
        )
        return StarletteHTTPException(500)
        raise StarletteHTTPException(status_code=500)

    r = requests.post(
        url=claims.get("realm", "").replace("oauth2/token", "oauth2/exchange"),
@@ -122,9 +205,24 @@ def get_acr_auth_password(
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    logger.info(f"Token exchange status code : {r.status_code}")
    for h in r.headers:
        print(f"{h} : {r.headers[h]}")
    return r.json()["refresh_token"]
    if r.status_code != 200:
        if r.status_code == 401:
            logger.info(
                "401 status code received : make sure your identity/application has correct roles on registry"
            )
            raise StarletteHTTPException(
                status_code=400,
                detail="401 status code received : make sure your identity/application has correct roles on registry",
            )
        logger.info(f"No token recieved : {r.status_code} code on oauth2 exchange")
        raise StarletteHTTPException(
            status_code=500,
            detail=f"No token recieved : {r.status_code} code on oauth2 exchange",
        )

    if token := r.json().get("refresh_token", None):
        return token
    raise StarletteHTTPException(status_code=500, detail="No token returned")


def guess_env_ctx() -> str:
+174 −131

File changed.

Preview size limit exceeded, changes collapsed.

+1 −1
Original line number Diff line number Diff line
@@ -10,7 +10,7 @@ authors = []
license = "LGPL"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["azure-identity (>=1.25.3,<2.0.0)", "uvicorn (>=0.42.0,<1.0.0)", "fastapi (>=0.135.3,<1.0.0)", "cryptography (>=46.0.7,<47.0.0)"]
dependencies = ["azure-identity (>=1.25.3,<2.0.0)", "uvicorn (>=0.42.0,<1.0.0)", "fastapi (>=0.135.3,<1.0.0)", "cryptography (>=46.0.7,<47.0.0)", "azure-mgmt-containerservice (>=41.0.0,<42.0.0)"]

[tool.poetry.dependencies]
python = "^3.12"