Commit 1f8bc562 authored by Clement Bois's avatar Clement Bois
Browse files

Merge branch 'feat/ssm/add-instance-name-resolving' into 'main'

feat(ssm|kubernetes): add instance name resolving

See merge request to-be-continuous/tools/aws-auth-provider!99
parents d2c9ea2b ec37242e
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -12,9 +12,9 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
# Install dependencies and Session Manager plugin
RUN apk upgrade --no-cache \
    && apk add --no-cache \
        curl=8.14.1-r2 \
        curl=8.17.0-r1 \
        gcompat=1.1.0-r4 \
        rpm=4.19.1.1-r2 \
        rpm=4.19.1.1-r3 \
        cpio=2.15-r0 \
    # Install pip packages
    && pip install --no-cache-dir . \
+19 −13
Original line number Diff line number Diff line
@@ -127,8 +127,9 @@ This API starts an [AWS Systems Manager (SSM) port forwarding session](https://d
#### Query Parameters

| Name            | Description                                                   | Required              |
|---------------|---------------------------------------------------------------|-----------------------|
| `instance_id` | EC2 instance ID to connect to                                 | yes                   |
|-----------------|---------------------------------------------------------------|-----------------------|
| `instance_id`   | EC2 instance ID to connect to                                 | yes _(or `instance_name`)_ |
| `instance_name` | EC2 instance Name tag to connect to                           | yes _(or `instance_id`)_ |
| `remote_port`   | Port on the remote instance                                   | yes                   |
| `remote_host`   | Remote host for advanced port forwarding scenarios           | no                    |
| `local_port`    | Local port to bind (auto-allocated if not specified)          | no                    |
@@ -137,6 +138,8 @@ This API starts an [AWS Systems Manager (SSM) port forwarding session](https://d
| `env_ctx`       | the [environment context to consider](#the-notion-of-env_ctx) | no _(can be guessed)_ |
| `role_arn`      | AWS IAM role ARN to assume                                    | no _(can be retrieved from env)_ |

> **Note:** You must provide either `instance_id` or `instance_name`. If `instance_name` is provided, the API will look up the instance ID by the EC2 `Name` tag (only running instances are considered).

### `GET /kubeconfig`

This API generates a complete kubeconfig file for an AWS EKS cluster with a valid authentication token.
@@ -177,7 +180,10 @@ You can also **force SSM tunneling** for public clusters by providing the `insta
| `ttl_minutes`  | Time-to-live for the token in minutes (max: 15)               | no                    | `15`            |
| `namespace`      | Default namespace for kubectl context                         | no                    | `default`       |
| `user_name`      | Username in the kubeconfig                                    | no                    | `kubectl-user`  |
| `instance_id`  | EC2 instance ID for SSM port forwarding                       | **yes** _(for private clusters only)_ | -               |
| `instance_id`    | EC2 instance ID for SSM port forwarding                       | **yes** _(for private clusters only, or use `instance_name`)_ | -               |
| `instance_name`  | EC2 instance Name tag for SSM port forwarding                 | **yes** _(for private clusters only, or use `instance_id`)_ | -               |

> **Note:** For private clusters, you must provide either `instance_id` or `instance_name`. If `instance_name` is provided, the API will look up the instance ID by the EC2 `Name` tag.

#### Example Usage

+13 −7
Original line number Diff line number Diff line
@@ -192,6 +192,7 @@ def generate_kubeconfig(
    namespace: str = Query(default="default", alias="namespace"),
    user_name: str = Query(default="kubectl-user", alias="user_name"),
    instance_id: str = Query(default=None, alias="instance_id"),
    instance_name: str = Query(default=None, alias="instance_name"),
) -> str:
    """
    Generate a kubeconfig file by retrieving cluster information from AWS EKS.
@@ -206,6 +207,7 @@ def generate_kubeconfig(
        namespace: Default namespace (default: "default")
        user_name: Username for the kubeconfig (default: "kubectl-user")
        instance_id: EC2 instance ID for SSM port forwarding (required for private clusters, optional for public clusters)
        instance_name: EC2 instance Name tag for SSM port forwarding (alternative to instance_id)

    Returns:
        YAML-formatted kubeconfig as a string
@@ -260,24 +262,27 @@ def generate_kubeconfig(
    endpoint_private_access = resources_vpc_config.get("endpointPrivateAccess", False)

    is_private_cluster = not endpoint_public_access and endpoint_private_access
    use_ssm = is_private_cluster or instance_id is not None
    use_ssm = is_private_cluster or instance_id is not None or instance_name is not None

    # Save original endpoint for metadata and verification
    original_endpoint = cluster_endpoint

    if use_ssm:
        # For private clusters, instance_id is required
        if is_private_cluster and not instance_id:
        # For private clusters, instance_id or instance_name is required
        if is_private_cluster and not instance_id and not instance_name:
            raise HTTPException(
                status_code=400,
                detail=f"Private cluster '{cluster_name}' requires 'instance_id' parameter. "
                f"Please provide an EC2 instance ID with SSM agent in the cluster's VPC for port forwarding.",
                detail=f"Private cluster '{cluster_name}' requires 'instance_id' or 'instance_name' parameter. "
                f"Please provide an EC2 instance ID or Name tag with SSM agent in the cluster's VPC for port forwarding.",
            )

        logger.info(
            f"Instance ID provided, using SSM port forwarding for cluster {cluster_name}"
            f"Instance ID/Name provided, using SSM port forwarding for cluster {cluster_name}"
        )
        if instance_id:
            logger.info(f"Using provided instance ID: {instance_id}")
        else:
            logger.info(f"Using provided instance name: {instance_name}")

        # Parse the cluster endpoint to get host and port
        original_endpoint = cluster_endpoint  # Save original endpoint before proxying
@@ -296,6 +301,7 @@ def generate_kubeconfig(
                region=region,
                role_arn=role_arn,
                instance_id=instance_id,
                instance_name=instance_name,
                remote_port=remote_port,
                remote_host=remote_host,
                local_port=None,  # Let it find an available port
+5 −1
Original line number Diff line number Diff line
@@ -195,7 +195,8 @@ def start_ssm_port_forward_endpoint(
    env_ctx: str = Query(default=None, alias="env_ctx"),
    region: str = Query(default=None, alias="region"),
    role_arn: str = Query(default=None, alias="role_arn"),
    instance_id: str = Query(alias="instance_id"),
    instance_id: str = Query(default=None, alias="instance_id"),
    instance_name: str = Query(default=None, alias="instance_name"),
    remote_port: int = Query(alias="remote_port"),
    remote_host: str = Query(default=None, alias="remote_host"),
    local_port: int = Query(default=None, alias="local_port"),
@@ -207,6 +208,7 @@ def start_ssm_port_forward_endpoint(
        region=region,
        role_arn=role_arn,
        instance_id=instance_id,
        instance_name=instance_name,
        remote_port=remote_port,
        remote_host=remote_host,
        local_port=local_port,
@@ -225,6 +227,7 @@ def generate_kubeconfig(
    namespace: str = Query(default="default", alias="namespace"),
    user_name: str = Query(default="kubectl-user", alias="user_name"),
    instance_id: str = Query(default=None, alias="instance_id"),
    instance_name: str = Query(default=None, alias="instance_name"),
) -> str:
    """Generate a kubeconfig file by retrieving cluster information from AWS EKS."""
    return kubeconfig.generate_kubeconfig(
@@ -236,4 +239,5 @@ def generate_kubeconfig(
        namespace=namespace,
        user_name=user_name,
        instance_id=instance_id,
        instance_name=instance_name,
    )
+74 −3
Original line number Diff line number Diff line
@@ -40,6 +40,60 @@ class SsmPortForwardResponse:
    plugin_local_port: Optional[int] = None


def resolve_instance_name_to_id(instance_name: str) -> str:
    """
    Resolve an EC2 instance name (Name tag) to its instance ID.

    Args:
        instance_name: The Name tag value of the EC2 instance

    Returns:
        The instance ID

    Raises:
        HTTPException: If the instance is not found or multiple instances match
    """
    logger.debug(f"Resolving instance name '{instance_name}' to instance ID")
    ec2_client = boto3.client("ec2")
    
    try:
        response = ec2_client.describe_instances(
            Filters=[
                {"Name": "tag:Name", "Values": [instance_name]},
                {"Name": "instance-state-name", "Values": ["running"]},
            ]
        )
        
        instances = []
        for reservation in response.get("Reservations", []):
            for instance in reservation.get("Instances", []):
                instances.append(instance["InstanceId"])
        
        if not instances:
            logger.error(f"No running instance found with name '{instance_name}'")
            raise HTTPException(
                status_code=404,
                detail=f"No running instance found with name '{instance_name}'",
            )
        
        if len(instances) > 1:
            logger.warning(
                f"Multiple instances found with name '{instance_name}': {instances}. Using first one: {instances[0]}"
            )
        
        instance_id = instances[0]
        logger.info(f"Resolved instance name '{instance_name}' to ID '{instance_id}'")
        return instance_id
        
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Error resolving instance name: {str(e)}")
        raise HTTPException(
            status_code=500, detail=f"Error resolving instance name: {str(e)}"
        )


def is_port_available(port: int) -> bool:
    """Check if a port is available for binding."""
    try:
@@ -99,6 +153,7 @@ def start_ssm_port_forward(
    region: str = None,
    role_arn: str = None,
    instance_id: str = None,
    instance_name: str = None,
    remote_port: int = None,
    remote_host: str = None,
    local_port: int = None,
@@ -112,7 +167,8 @@ def start_ssm_port_forward(
        env_ctx: Environment context (PROD, STAGING, INTEG, REVIEW)
        region: AWS region
        role_arn: AWS role ARN to assume
        instance_id: EC2 instance ID to connect to
        instance_id: EC2 instance ID to connect to (either instance_id or instance_name required)
        instance_name: EC2 instance Name tag to connect to (either instance_id or instance_name required)
        remote_port: Remote port on the instance
        remote_host: Remote host (optional)
        local_port: Local port to forward to (optional, will use remote_port if not specified)
@@ -120,7 +176,24 @@ def start_ssm_port_forward(

    Returns:
        SsmPortForwardResponse object with session details

    Raises:
        HTTPException: If neither instance_id nor instance_name is provided
    """
    # Validate that either instance_id or instance_name is provided
    if not instance_id and not instance_name:
        raise HTTPException(
            status_code=400,
            detail="Either instance_id or instance_name must be provided",
        )

    # Configure boto before resolving instance name (need credentials)
    configure_boto(env_ctx, region, role_arn)

    # Resolve instance_name to instance_id if needed
    if not instance_id and instance_name:
        instance_id = resolve_instance_name_to_id(instance_name)

    logger.info(
        f"Starting SSM port forward: instance={instance_id}, remote_port={remote_port}, local_port={local_port}, protocol={protocol}"
    )
@@ -155,8 +228,6 @@ def start_ssm_port_forward(
                        plugin_local_port=session_data.get("plugin_local_port"),
                    )

    configure_boto(env_ctx, region, role_arn)

    # Determine local port: try requested port, then remote port, then find available
    if local_port is None:
        # No local port specified, try to use the same port as remote
Loading