diff --git a/Dockerfile b/Dockerfile index fb4162b..2b5aac7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..de6dc0c --- /dev/null +++ b/air.toml @@ -0,0 +1,5 @@ +# air.toml +root = "." +tmp_dir = "tmp" +build_cmd = "go build -o ./tmp/main ." +run_cmd = "./tmp/main" diff --git a/env.go b/env.go index 0590d8b..1873988 100644 --- a/env.go +++ b/env.go @@ -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) { @@ -19,3 +27,34 @@ func addShellEnvVarsFromRef(envVarsRef v1alpha1.ShellEnvVarsRef, shellEnvVars ma } return shellEnvVars, nil } + +func fromValueRef(req *fnv1beta1.RunFunctionRequest, path string) (string, error) { + // 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 +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..acb7741 --- /dev/null +++ b/env_test.go @@ -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) + } + }) + } + +} diff --git a/example/aws/ProviderConfig.yaml b/example/aws/ProviderConfig.yaml new file mode 100644 index 0000000..aaa8ef2 --- /dev/null +++ b/example/aws/ProviderConfig.yaml @@ -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 diff --git a/example/aws/README.md b/example/aws/README.md new file mode 100644 index 0000000..eb0a5d3 --- /dev/null +++ b/example/aws/README.md @@ -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' +``` diff --git a/example/aws/composition.yaml b/example/aws/composition.yaml new file mode 100644 index 0000000..e32142b --- /dev/null +++ b/example/aws/composition.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: shell-example +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: pull-extra-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + - kind: ProviderConfig + into: ProviderConfig + apiVersion: aws.upbound.io/v1beta1 + type: Selector + selector: + maxMatch: 1 + minMatch: 1 + matchLabels: + - key: account + type: Value + value: demo + - step: shell + functionRef: + name: function-shell + input: + apiVersion: shell.fn.crossplane.io/v1alpha1 + kind: Parameters + shellEnvVars: + - key: AWS_ROLE_ARN + value: arn:aws:iam::000000000000:role/eks-test-role + - key: AWS_ASSUME_ROLE_ARN + valueRef: "context[apiextensions.crossplane.io/extra-resources].ProviderConfig[0].spec.assumeRoleChain[0].roleARN" + shellCommand: | + ASSUME_ROLE_OUTPUT=$(aws sts assume-role --role-arn $AWS_ASSUME_ROLE_ARN --role-session-name "function-shell") + 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) + aws iam list-roles | jq -r '.Roles[] .Arn' + stdoutField: status.atFunction.shell.stdout + stderrField: status.atFunction.shell.stderr diff --git a/example/aws/functions.yaml b/example/aws/functions.yaml new file mode 100644 index 0000000..86b46af --- /dev/null +++ b/example/aws/functions.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-shell + annotations: + # This tells crossplane beta render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + # This is ignored when using the Development runtime. + package: function-shell +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-extra-resources +spec: + package: xpkg.upbound.io/crossplane-contrib/function-extra-resources:v0.0.3 diff --git a/example/aws/xr.yaml b/example/aws/xr.yaml new file mode 100644 index 0000000..8d921d7 --- /dev/null +++ b/example/aws/xr.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: example.crossplane.io/v1 +kind: XR +metadata: + name: example-aws +spec: {} diff --git a/fn.go b/fn.go index 9369d18..9723bcc 100644 --- a/fn.go +++ b/fn.go @@ -11,7 +11,6 @@ import ( fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1" "github.com/crossplane/function-sdk-go/request" "github.com/crossplane/function-sdk-go/response" - "github.com/keegancsmith/shell" ) @@ -87,7 +86,16 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ var shellEnvVars = make(map[string]string) for _, envVar := range in.ShellEnvVars { - shellEnvVars[envVar.Key] = envVar.Value + if envVar.ValueRef != "" { + envValue, err := fromValueRef(req, envVar.ValueRef) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot process contents of valueRef %s", envVar.ValueRef)) + return rsp, nil + } + shellEnvVars[envVar.Key] = envValue + } else { + shellEnvVars[envVar.Key] = envVar.Value + } } if len(in.ShellEnvVarsRef.Keys) > 0 { diff --git a/input/v1alpha1/parameters.go b/input/v1alpha1/parameters.go index 3c6aaea..17484fc 100644 --- a/input/v1alpha1/parameters.go +++ b/input/v1alpha1/parameters.go @@ -45,8 +45,9 @@ type Parameters struct { } type ShellEnvVar struct { - Key string `json:"key,omitempty"` - Value string `json:"value,omitempty"` + Key string `json:"key,omitempty"` + Value string `json:"value,omitempty"` + ValueRef string `json:"valueRef,omitempty"` } type ShellEnvVarsRef struct { diff --git a/package/input/template.fn.crossplane.io_parameters.yaml b/package/input/template.fn.crossplane.io_parameters.yaml index 1d0fe1b..cea0c50 100644 --- a/package/input/template.fn.crossplane.io_parameters.yaml +++ b/package/input/template.fn.crossplane.io_parameters.yaml @@ -52,6 +52,8 @@ spec: type: string value: type: string + valueRef: + type: string type: object type: array shellEnvVarsRef: