Hoping for some guidance or direction. We have a working project that deploys EKS clusters. We use c...
l
Hoping for some guidance or direction. We have a working project that deploys EKS clusters. We use custom AMI's for our EKS worker nodes and dynamically generate a userdata file for our node group launch template. This works fairly well for Amazon Linux 2 AMI's where we could simply call the bootstrap.sh script and only needed to pass in the cluster name which was a pre-defined value the workflow read in from the Pulumi stack file. With Amazon Linux 2023 they have moved to the new nodeadm method which requires providing the EKS cluster *endpoint*(Kube API URL) and EKS certificate authority. From what I can see from the docs, currently no providers have a resource that dump both of the key items as outputs(one or the other). I did see that the aws-native provider has a get_cluster() function which provides both of these endpoints. This returns a string value which can be passed to our user data template function. However when running Pulumi up for the first time the get_cluster() function fails to find the cluster and pull the required info. Running it a second time after failure works just fine. I'm guessing this is due to the async nature of Pulumi. In an attempt to work around this issue I tried using the Pulumi command provider to pull the required values via the AWS CLI. This works, but because it delivers a value of Output I cannot get the two values into the data type of string to be used with our userdata template function. I just get the usual message below.
Copy code
To get the value of an Output[T] as an Output[str] consider:
1. o.apply(lambda v: f"prefix{v}suffix")
Our project is developed in Python and we are running the most recent versions of the pulumi-aws, pulumi-eks, pulumi-aws-native packages.
s
Haven’t seen this exact problem yet. Just for fun, pasted this into ChatGPT-4o and got this answer which seems plausible after a quick skim. It’s based on doing async callback to get the cert authority data. Do feed back if it works or not! Formatting is poor because I’m on phone ——— answer ——— To address the challenge you're facing with fetching EKS cluster details asynchronously in Pulumi and then using these details in your user data template, here's a structured approach to handle it: ### Step-by-Step Guide 1. *Fetching EKS Cluster Details: 2. You are correct that the
aws-native
provider has a
get_cluster
function that can fetch both the API endpoint and the certificate authority. However, Pulumi's asynchronous nature can sometimes lead to issues if resources are not created in the expected order. To ensure the cluster details are fetched correctly after the cluster is created, use Pulumi's
.apply
method effectively. 1. *Using Pulumi's Command Provider: 2. When using the command provider, ensure the output is processed correctly to convert the
Output
type to a string. 1. *User Data Template: 2. Properly structure the user data template to include the fetched EKS cluster details. 1. ### Example Implementation 1. Below is a Python-based example of how you can structure your Pulumi code to achieve this: 1.
Copy code
python
2. import pulumi
3. from pulumi_aws import ec2, eks
4. from pulumi_aws_native import eks as aws_native_eks
5. from pulumi_command import local
1. # Define your EKS cluster
2. cluster = eks.Cluster('my-cluster',
3.                       # Cluster configuration
4.                       )
1. # Fetch the cluster details using aws-native provider
2. cluster_details = aws_native_eks.get_cluster_output(
3.     name=cluster.eks_cluster.name
4. )
1. # Function to process the Output[T] type and convert it to a string
2. def get_value_as_string(output):
3.     return output.apply(lambda v: v)
1. cluster_endpoint = get_value_as_string(cluster_details.endpoint)
2. cluster_certificate = get_value_as_string(cluster_details.certificate_authority['data'])
1. # Define the user data template function
2. def create_user_data(cluster_name, endpoint, certificate):
3.     user_data = f"""#!/bin/bash
4.     /etc/eks/bootstrap.sh {cluster_name} --apiserver-endpoint '{endpoint}' --b64-cluster-ca '{certificate}'
5.     """
6.     return user_data
1. # Using the Pulumi apply method to create the user data string
2. user_data = pulumi.Output.all(cluster.eks_cluster.name, cluster_endpoint, cluster_certificate).apply(
3.     lambda args: create_user_data(args[0], args[1], args[2])
4. )
1. # Define your launch template with the generated user data
2. launch_template = ec2.LaunchTemplate('my-launch-template',
3.                                      name='my-launch-template',
4.                                      user_data=user_data,
5.                                      # Other configurations
6.                                      )
1. # Define the node group using the launch template
2. node_group = eks.NodeGroup('my-node-group',
3.                            cluster=cluster.eks_cluster.name,
4.                            launch_template={
5.                                'id': <http://launch_template.id|launch_template.id>,
6.                                'version': '$Latest'
7.                            },
8.                            # Other configurations
9.                            )
10.
1. ### Explanation 1. *Cluster Definition: 2. The
eks.Cluster
resource creates the EKS cluster. 1. *Fetching Cluster Details: 2. The
aws_native_eks.get_cluster_output
function fetches the EKS cluster details and returns them as
Output
types. 1. *Processing Outputs: 2. The
get_value_as_string
function ensures the
Output
values are converted to strings using the
.apply
method. 1. *User Data Template: 2. The
create_user_data
function creates the user data string by embedding the EKS cluster details. 1. *Launch Template: 2. The
ec2.LaunchTemplate
resource defines the launch template for the EKS worker nodes using the generated user data. 1. *Node Group: 2. The
eks.NodeGroup
resource uses the launch template to create the worker nodes. 1. ### Important Considerations 1. Ensure that the cluster creation and fetching details are synchronized correctly. The
.apply
method ensures the values are only processed after they are available. 2. Handle exceptions and errors appropriately, especially when dealing with asynchronous operations. 1. This approach ensures that your EKS cluster details are correctly fetched and embedded into the user data template, resolving the issues you've faced with the asynchronous nature of Pulumi.*********
l
Hi Shawn, assuming I understand correctly, you've got some
Output[str]
which you want to process and use to build a userdata attribute. If so I think you should be able to use
Output.apply
for this; something like:
Copy code
userdata=command.stdout.apply(lambda o: f"userdata stuff {o}")
If you need to split the output etc. first you can do that too, e.g.:
Copy code
def parse_output(o: str) -> tuple[str, str]:
  # split o on newlines, extract cluster urls, etc.
  return endpoint, ca

def make_userdata(o: str) -> str:
  endpoint, ca = parse_output(o)
  return f"userdata stuff {endpoint} more stuff {ca}"
...

userdata=command.stdout.apply(make_userdata)
l
@salmon-gold-74709 I will look into it further, but that's quite similar to the approach I've already taken. @lively-crayon-44649 Ouptut Apply literally just dumps the typical Pulumi message:
Copy code
To get the value of an Output[T] as an Output[str] consider:
1. o.apply(lambda v: f"prefix{v}suffix")
Into the contents of the userdata string. The issue is passing in the Outout<T>.apply(lamda v: str(v)) as one of the transformation dicts in the below function. Python's native replace still sees it as an Ouput or if wrapped as a Python str results in the error message being injected into the userdata .
Copy code
def encoded_user_data(
    cluster_name: str, enable_edr: bool, user_data: str, transformations: list[dict]
):
    # Reads user data script and encodes it as base 64
    with open(f"data/{user_data}") as f:
        data = f.read()

    # Set enable EDR value in cloud init script
    if enable_edr:
        data = data.replace("<ENABLE_EDR>", "1")
    else:
        data = data.replace("<ENABLE_EDR>", "0")

    # Replace use case specific fields or values
    for t in transformations:
        data = data.replace(t.get("key"), t.get("value"))

    return base64.b64encode(data.encode()).decode("utf-8")
l
Hi Shawn, thanks for sharing this. How are you calling
encoded_user_data
?
l
From the aws.ec2.LaunchTemplate provider, more specifically the user_data parameter.
Copy code
node_template = aws.ec2.LaunchTemplate(
    f"{cluster_name}-node-lt",
    image_id=launch_template_ami,
    instance_type=instance_type,
    block_device_mappings=[
        aws.ec2.LaunchTemplateBlockDeviceMappingArgs(
            device_name="/dev/xvda",
            ebs=aws.ec2.LaunchTemplateBlockDeviceMappingEbsArgs(
                volume_size=20, **ebs_args
            ),
        ),
        aws.ec2.LaunchTemplateBlockDeviceMappingArgs(
            device_name="/dev/sda1",
            ebs=aws.ec2.LaunchTemplateBlockDeviceMappingEbsArgs(
                volume_size=25, **ebs_args
            ),
        ),
        aws.ec2.LaunchTemplateBlockDeviceMappingArgs(
            device_name="/dev/sdb",
            ebs=aws.ec2.LaunchTemplateBlockDeviceMappingEbsArgs(
                volume_size=250, **ebs_args
            ),
        ),
    ],
    key_name="packer_access",
    user_data=encoded_user_data(
        cluster_name,
        enable_edr,
        "user-data-eks",
        [
            {"key": "<CLUSTER>", "value": cluster_name},
            {"key": "<CIDR>", "value": eks_cidr_block},
            {"key": "<KUBE_API>", "value": kube_api},
            {"key": "<CERTIFICATE_AUTHORITY>", "value": certificate_authority}
        ],
    ),
    vpc_security_group_ids=[sg_id],
    tags={"<http://karpenter.sh/discovery|karpenter.sh/discovery>": cluster_name},
)
l
OK, gotcha. And the output values -- are they
certificate_authority
,
kupe_api
, etc.?
l
Yes, that is correct.
l
If so then you want to call
apply
on those, which you can supply a callback that will be allowed access to the values "inside" the outputs
So if you have an
Output[str]
, you can provide a function
f(s: str) -> A
and get back an
Output[A]
In this case, you could do something like:
Copy code
user_data=Output.all(
  cluster_name=cluster_name, 
  eks_cidr_block=eks_cidr_block, 
  kube_api=kube_api, 
  certificate_authority=certificate_authority,
).apply(lambda args: encoded_user_data(args["cluster_name"], enable_edr, "user-data-eks", [{"key": "<CLUSTER">, "value": args["cluster_name"]}, ...]))
(See https://www.pulumi.com/docs/concepts/inputs-outputs/all/ for more on
all
)
l
Here is where I'm pulling the values, and using apply:
Copy code
# Required to bootstrap Amazon Linux 2023 nodes
cmd_refresh_config = cmd.local.Command(
    f"{cluster_config.name}-cmd-refresh",
    create=f"aws eks update-kubeconfig --region us-east-1 --name {cluster_config.name}",
    opts=pulumi.ResourceOptions(depends_on=[cluster])
)

cmd_get_cluster_cert_authority = cmd.local.Command(
    f"{cluster_config.name}-cmd-get-cluster-cert-authority",
    create=f"aws eks describe-cluster --name {cluster_config.name} --query cluster.certificateAuthority.data --output text --no-cli-pager",
    opts=pulumi.ResourceOptions(depends_on=[cmd_refresh_config])
)

cmd_get_cluster_endpoint = cmd.local.Command(
    f"{cluster_config.name}-cmd-get-cluster-endpoint",
    create=f"aws eks describe-cluster --name {cluster_config.name} --query cluster.endpoint --output text --no-cli-pager",
    opts=pulumi.ResourceOptions(depends_on=[cmd_refresh_config])
)

cluster_config.certificate_authority = cmd_get_cluster_cert_authority.stdout.apply(lambda v: str(v))
cluster_config.endpoint = cmd_get_cluster_endpoint.stdout.apply(lambda v: str(v))
l
So generally, if you have: • Some outputs
oa: Output[A]
,
ob: Output[B]
,
oc: Output[C]
, ... • A function
f(a: A, b: B, c: C, ...) -> Result
You can use
Output.all(oa, ob, oc, ...).apply(f)
to get an
Output[Result]
If you have just the one
Output
,
o: Output[X]
and a function
g(x: X) -> Y
then
o.apply(g)
gives you
Output[Y]
, and you can see this as the "degenerate" case of
all
where you only have one argument
l
Sounds like the trick here is have user_data= in the LaunchTemplate provider receive an awaitable response by wrapping the encode_user_data function via the Pulumi Ouput functions.
l
Yes, I think so -- most (all?) Pulumi resource inputs will accept outputs, so if you need to transform previous outputs, you are often writing "normal" functions and inserting them in the right places using
apply
(or
all().apply
)
If that makes sense
l
I believe that does, Thanks for the input/feedback. Ill take a stab at it and see where that leads.
l
👍 Great!
l
@lively-crayon-44649 Thank your for your input and suggestion, It appears to be working.