Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AWS CLI and environment patch #20

Merged
merged 9 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ ARG GO_VERSION=1
# architecture that we're building the function for.
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS build

RUN apt-get update && apt-get install -y coreutils jq unzip zsh
RUN mkdir /scripts && chown 2000:2000 /scripts
RUN apt-get update && apt-get install -y coreutils jq unzip zsh less
RUN mkdir /scripts /.aws && chown 2000:2000 /scripts /.aws

# TODO: Install awscli, gcloud
# RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "/tmp/awscliv2.zip" && \
# unzip "/tmp/awscliv2.zip" && \
# ./aws/install
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "/tmp/awscliv2.zip" && \
unzip "/tmp/awscliv2.zip" && \
./aws/install

WORKDIR /fn

Expand Down Expand Up @@ -52,6 +51,7 @@ FROM gcr.io/distroless/python3-debian12 AS image

WORKDIR /
COPY --from=build --chown=2000:2000 /scripts /scripts
COPY --from=build --chown=2000:2000 /.aws /.aws

COPY --from=build /bin /bin
COPY --from=build /etc /etc
Expand Down
5 changes: 5 additions & 0 deletions air.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# air.toml
root = "."
tmp_dir = "tmp"
build_cmd = "go build -o ./tmp/main ."
run_cmd = "./tmp/main"
39 changes: 39 additions & 0 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ package main

import (
"encoding/json"
"fmt"
"os"
"regexp"

"github.com/crossplane-contrib/function-shell/input/v1alpha1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/fieldpath"
fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1"
"github.com/crossplane/function-sdk-go/request"
"github.com/crossplane/function-sdk-go/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func addShellEnvVarsFromRef(envVarsRef v1alpha1.ShellEnvVarsRef, shellEnvVars map[string]string) (map[string]string, error) {
Expand All @@ -19,3 +27,34 @@ func addShellEnvVarsFromRef(envVarsRef v1alpha1.ShellEnvVarsRef, shellEnvVars ma
}
return shellEnvVars, nil
}

func fromValueRef(req *fnv1beta1.RunFunctionRequest, path string) (string, error) {
humoflife marked this conversation as resolved.
Show resolved Hide resolved
// Check for context key presence and capture context key and path
contextRegex := regexp.MustCompile(`^context\[(.+?)].(.+)$`)
if match := contextRegex.FindStringSubmatch(path); match != nil {
if v, ok := request.GetContextKey(req, match[1]); ok {
context := &unstructured.Unstructured{}
if err := resource.AsObject(v.GetStructValue(), context); err != nil {
return "", errors.Wrapf(err, "cannot convert context to %s", v)
}
value, err := fieldpath.Pave(context.Object).GetValue(match[2])
if err != nil {
return "", errors.Wrapf(err, "cannot get context value at %s", match[2])
}
return fmt.Sprintf("%v", value), nil
}

} else {
oxr, err := request.GetObservedCompositeResource(req)
if err != nil {
return "", errors.Wrapf(err, "cannot get observed composite resource from %T", req)
}
value, err := oxr.Resource.GetValue(path)
if err != nil {
return "", errors.Wrapf(err, "cannot get observed composite value at %s", path)
}
return fmt.Sprintf("%v", value), nil

}
return "", nil
}
86 changes: 86 additions & 0 deletions env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"testing"

fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1"
"github.com/crossplane/function-sdk-go/resource"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"google.golang.org/protobuf/testing/protocmp"
)

func TestFromValueRef(t *testing.T) {

type args struct {
req *fnv1beta1.RunFunctionRequest
path string
}

type want struct {
result string
err error
}

cases := map[string]struct {
reason string
args args
want want
}{
"FromCompositeValid": {
reason: "If composite path is valid, it should be returned.",
args: args{
req: &fnv1beta1.RunFunctionRequest{
Observed: &fnv1beta1.State{
Composite: &fnv1beta1.Resource{
Resource: resource.MustStructJSON(`{
"apiVersion": "",
"kind": "",
"spec": {
"foo": "bar"
}
}`),
},
},
},
path: "spec.foo",
},
want: want{
result: "bar",
err: nil,
},
},
"FromContextValid": {
reason: "If composite path is valid, it should be returned.",
args: args{
req: &fnv1beta1.RunFunctionRequest{
Context: resource.MustStructJSON(`{
"apiextensions.crossplane.io/foo": {
"bar": "baz"
}
}`),
},
path: "context[apiextensions.crossplane.io/foo].bar",
},
want: want{
result: "baz",
err: nil,
},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
result, err := fromValueRef(tc.args.req, tc.args.path)

if diff := cmp.Diff(tc.want.result, result, protocmp.Transform()); diff != "" {
t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff)
}

if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff)
}
})
}

}
11 changes: 11 additions & 0 deletions example/aws/ProviderConfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
labels:
account: demo
name: demo
spec:
assumeRoleChain:
- roleARN: arn:aws:iam::000000000001:role/eks-test-assume-role
credentials:
source: IRSA
174 changes: 174 additions & 0 deletions example/aws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Example - AWS

This example demonstrates how to reuse an existing `ProviderConfig` to authenticate with AWS using IAM Role for
ServiceAccount (IRSA) and execute a command on a different account using AssumeRole. This architecture is known as Hub
and Spoke.

## Prerequisites

### Authenticate using IAM Roles for Service Accounts

The Amazon Elastic Kubernetes Service (EKS) running the function will need a ServiceAccount authorized to authenticate
with AWS. The steps are details in the configuration of
[provider-upjet-aws](https://github.com/crossplane-contrib/provider-upjet-aws/blob/main/docs/family/Configuration.md#authenticate-using-iam-roles-for-service-accounts).

### Authorize Hub account on the Spoke Account

The previous step has created the AWS IAM Role`arn:aws:iam::000000000000:role/eks-test-role`. It now needs to be allowed
to make requests on the target account.

#### Create an IAM policy

Define the actions allowed on the target account. For this example, it will be restricted to listing IAM Roles. Adapt
the policy based on your needs.

Apply the policy using the AWS command-line command:
```bash
aws iam create-policy \
--policy-name IAMRoleLister \
--policy-document \
'{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"iam:ListRoles"
],
"Resource": "*",
"Effect": "Allow"
}
]
}'
```

Example output:
```json
{
"Policy": {
"PolicyName": "IAMRoleLister",
"PolicyId": "ANPAS43KCNAZNTZIWNOCU",
"Arn": "arn:aws:iam::000000000001:policy/IAMRoleLister",
"Path": "/",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"PermissionsBoundaryUsageCount": 0,
"IsAttachable": true,
"CreateDate": "2024-09-17T08:35:55+00:00",
"UpdateDate": "2024-09-17T08:35:55+00:00"
}
}
```

#### Create an IAM role

Define the trust policy to allow the Hub account (000000000000) to AssumeRole on the target account (000000000001).

```bash
aws iam create-role \
--role-name eks-test-assume-role \
--assume-role-policy-document \
'{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/eks-test-role"
},
"Action": "sts:AssumeRole",
"Condition": {}
}
]
}'
```

Example output:
```json
{
"Role": {
"Path": "/",
"RoleName": "eks-test-assume-role",
"RoleId": "AROAS43KCNAZGWLU4RRHM",
"Arn": "arn:aws:iam::000000000001:role/eks-test-assume-role",
"CreateDate": "2024-09-17T08:48:30+00:00",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/eks-test-role"
},
"Action": "sts:AssumeRole",
"Condition": {}
}
]
}
}
}
```

Attach the IAMRoleLister policy on the role

```bash
aws iam attach-role-policy \
--policy-arn arn:aws:iam::000000000001:policy/IAMRoleLister \
--role-name eks-test-assume-role
```

## Usage

### Loading ProviderConfig with function-extra-resources

The composition pipeline use `function-extra-resources` to load ProviderConfig defined in the EKS cluster. For this example
the following has been applied:

```yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
labels:
account: demo
name: demo
spec:
assumeRoleChain:
- roleARN: arn:aws:iam::000000000001:role/eks-test-assume-role
credentials:
source: IRSA
```

The IAM role of the target account is placed at `spec.assumeRoleChain[0].roleARN`

The `function-extra-resources` use a selector to discover `ProviderConfig`, `maxMatch: 1` and `minMatch: 1`have been set
so that exactly one `ProviderConfig` is return. In case none is found the composition will error out.

### Configuring function-shell

AWS CLI is configured with the following environment variable
- **AWS_ROLE_ARN** : is the role created on the hub account
- **AWS_ASSUME_ROLE_ARN**: is the role on the targeted account

The **AWS_ASSUME_ROLE_ARN** is configured dynamically with `valueRef` with the following expression:
`context[apiextensions.crossplane.io/extra-resources].ProviderConfig[0].spec.assumeRoleChain[0].roleARN`

The reference is retrieving from the context at the `apiextensions.crossplane.io/extra-resources`key. In the previous step
the `ProviderConfig` are saved into the `ProviderConfig` key (set with `function-extra-resources` input `spec.extraResources[0].into`)

For the function to be authenticated it first request a temporary token using:
```bash
ASSUME_ROLE_OUTPUT=$(aws sts assume-role --role-arn $AWS_ASSUME_ROLE_ARN --role-session-name "function-shell")
```

The session name can be set to an arbitrary value; it enables keeping track of the service making the call.

The environment variable `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` are set from the output with:
```bash
export AWS_ACCESS_KEY_ID=$(echo $ASSUME_ROLE_OUTPUT | grep -o '"AccessKeyId": "[^"]*"' | cut -d'"' -f4)
export AWS_SECRET_ACCESS_KEY=$(echo $ASSUME_ROLE_OUTPUT | grep -o '"SecretAccessKey": "[^"]*"' | cut -d'"' -f4)
export AWS_SESSION_TOKEN=$(echo $ASSUME_ROLE_OUTPUT | grep -o '"SessionToken": "[^"]*"' | cut -d'"' -f4)
```

Finally, the AWS command is executed and filtered with `jq` to retrieve the ARN of all the roles in the target account with:
```bash
aws iam list-roles | jq -r '.Roles[] .Arn'
```
Loading