diff --git a/apis/v1alpha1/resource_share.go b/apis/v1alpha1/resource_share.go index 8a14a34..b008886 100644 --- a/apis/v1alpha1/resource_share.go +++ b/apis/v1alpha1/resource_share.go @@ -23,22 +23,48 @@ import ( // ResourceShareSpec defines the desired state of ResourceShare. // // Describes a resource share in RAM. +// +// This resource can operate in two modes: +// 1. Create Mode (default): Creates a new resource share and shares resources with principals +// 2. Accept Mode: Accepts an incoming resource share invitation from another account +// +// To use Accept Mode, set the acceptInvitation field with the ShareARN of the incoming share. +// In Accept Mode, most other fields (name, principals, resourceARNs, etc.) are ignored. type ResourceShareSpec struct { + // AcceptInvitation configures this resource to accept an incoming share invitation + // instead of creating a new share. When set, the controller will: + // 1. Find the pending invitation for the specified ShareARN + // 2. Accept the invitation + // 3. Populate status with invitation details + // + // This is mutually exclusive with creating a new share. When acceptInvitation + // is set, fields like name, principals, and resourceARNs are ignored. + // + // Use this when you want to accept a share from another AWS account. + // +kubebuilder:validation:Optional + AcceptInvitation *AcceptInvitationSpec `json:"acceptInvitation,omitempty"` + // Specifies whether principals outside your organization in Organizations can // be associated with a resource share. A value of true lets you share with // individual Amazon Web Services accounts that are not in your organization. // A value of false only has meaning if your account is a member of an Amazon // Web Services Organization. The default value is true. + // + // This field is ignored when acceptInvitation is set. AllowExternalPrincipals *bool `json:"allowExternalPrincipals,omitempty"` // Specifies the name of the resource share. - // +kubebuilder:validation:Required - Name *string `json:"name"` + // + // This field is ignored when acceptInvitation is set. + // +kubebuilder:validation:Optional + Name *string `json:"name,omitempty"` // Specifies the Amazon Resource Names (ARNs) (https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html) // of the RAM permission to associate with the resource share. If you do not // specify an ARN for the permission, RAM automatically attaches the default // version of the permission for each resource type. You can associate only // one permission with each resource type included in the resource share. + // + // This field is ignored when acceptInvitation is set. PermissionARNs []*string `json:"permissionARNs,omitempty"` PermissionRefs []*ackv1alpha1.AWSResourceReferenceWrapper `json:"permissionRefs,omitempty"` // Specifies a list of one or more principals to associate with the resource @@ -61,19 +87,35 @@ type ResourceShareSpec struct { // Not all resource types can be shared with IAM roles and users. For more information, // see Sharing with IAM roles and users (https://docs.aws.amazon.com/ram/latest/userguide/permissions.html#permissions-rbp-supported-resource-types) // in the Resource Access Manager User Guide. + // + // This field is ignored when acceptInvitation is set. Principals []*string `json:"principals,omitempty"` // Specifies a list of one or more ARNs of the resources to associate with the // resource share. + // + // This field is ignored when acceptInvitation is set. ResourceARNs []*string `json:"resourceARNs,omitempty"` // Specifies from which source accounts the service principal has access to // the resources in this resource share. + // + // This field is ignored when acceptInvitation is set. Sources []*string `json:"sources,omitempty"` // A list of one or more tag key and value pairs. The tag key must be present // and not be an empty string. The tag value must be present but can be an empty // string. + // + // This field is ignored when acceptInvitation is set. Tags []*Tag `json:"tags,omitempty"` } +// AcceptInvitationSpec defines the configuration for accepting an incoming share invitation +type AcceptInvitationSpec struct { + // The Amazon Resource Name (ARN) of the resource share to accept. + // This should be the ARN of the share that was created by another account. + // +kubebuilder:validation:Required + ShareARN *string `json:"shareARN"` +} + // ResourceShareStatus defines the observed state of ResourceShare type ResourceShareStatus struct { // All CRs managed by ACK have a common `Status.ACKResourceMetadata` member @@ -124,6 +166,43 @@ type ResourceShareStatus struct { // A message about the status of the resource share. // +kubebuilder:validation:Optional StatusMessage *string `json:"statusMessage,omitempty"` + + // The following fields are populated only when spec.acceptInvitation is set + // (Accept Mode). They provide details about the accepted invitation. + + // The ARN of the invitation that was accepted. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + InvitationARN *string `json:"invitationARN,omitempty"` + // The current status of the invitation. + // Possible values: PENDING, ACCEPTED, REJECTED, EXPIRED + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + InvitationStatus *string `json:"invitationStatus,omitempty"` + // The ID of the AWS account that sent the invitation. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + SenderAccountID *string `json:"senderAccountID,omitempty"` + // The ID of the AWS account that received the invitation. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + ReceiverAccountID *string `json:"receiverAccountID,omitempty"` + // The name of the resource share. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + ShareName *string `json:"shareName,omitempty"` + // The date and time when the invitation was sent. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + InvitationTime *metav1.Time `json:"invitationTime,omitempty"` + // The resources included in the resource share. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + Resources []*string `json:"resources,omitempty"` + // The current status of the resource share. + // Only populated in Accept Mode. + // +kubebuilder:validation:Optional + ShareStatus *string `json:"shareStatus,omitempty"` } // ResourceShare is the Schema for the ResourceShares API diff --git a/config/crd/bases/ram.services.k8s.aws_resourceshares.yaml b/config/crd/bases/ram.services.k8s.aws_resourceshares.yaml index 8dcad65..58572cf 100644 --- a/config/crd/bases/ram.services.k8s.aws_resourceshares.yaml +++ b/config/crd/bases/ram.services.k8s.aws_resourceshares.yaml @@ -41,7 +41,35 @@ spec: ResourceShareSpec defines the desired state of ResourceShare. Describes a resource share in RAM. + + This resource can operate in two modes: + 1. Create Mode (default): Creates a new resource share and shares resources with principals + 2. Accept Mode: Accepts an incoming resource share invitation from another account + + To use Accept Mode, set the acceptInvitation field with the ShareARN of the incoming share. + In Accept Mode, most other fields (name, principals, resourceARNs, etc.) are ignored. properties: + acceptInvitation: + description: |- + AcceptInvitation configures this resource to accept an incoming share invitation + instead of creating a new share. When set, the controller will: + 1. Find the pending invitation for the specified ShareARN + 2. Accept the invitation + 3. Populate status with invitation details + + This is mutually exclusive with creating a new share. When acceptInvitation + is set, fields like name, principals, and resourceARNs are ignored. + + Use this when you want to accept a share from another AWS account. + properties: + shareARN: + description: |- + The Amazon Resource Name (ARN) of the resource share to accept. + This should be the ARN of the share that was created by another account. + type: string + required: + - shareARN + type: object allowExternalPrincipals: description: |- Specifies whether principals outside your organization in Organizations can @@ -49,9 +77,14 @@ spec: individual Amazon Web Services accounts that are not in your organization. A value of false only has meaning if your account is a member of an Amazon Web Services Organization. The default value is true. + + This field is ignored when acceptInvitation is set. type: boolean name: - description: Specifies the name of the resource share. + description: |- + Specifies the name of the resource share. + + This field is ignored when acceptInvitation is set. type: string permissionARNs: description: |- @@ -60,6 +93,8 @@ spec: specify an ARN for the permission, RAM automatically attaches the default version of the permission for each resource type. You can associate only one permission with each resource type included in the resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -104,6 +139,8 @@ spec: Not all resource types can be shared with IAM roles and users. For more information, see Sharing with IAM roles and users (https://docs.aws.amazon.com/ram/latest/userguide/permissions.html#permissions-rbp-supported-resource-types) in the Resource Access Manager User Guide. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -111,6 +148,8 @@ spec: description: |- Specifies a list of one or more ARNs of the resources to associate with the resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -118,6 +157,8 @@ spec: description: |- Specifies from which source accounts the service principal has access to the resources in this resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -126,6 +167,8 @@ spec: A list of one or more tag key and value pairs. The tag key must be present and not be an empty string. The tag value must be present but can be an empty string. + + This field is ignored when acceptInvitation is set. items: description: |- A structure containing a tag. A tag is metadata that you can attach to your @@ -143,8 +186,6 @@ spec: type: string type: object type: array - required: - - name type: object status: description: ResourceShareStatus defines the observed state of ResourceShare @@ -240,6 +281,23 @@ spec: but the customer ran the PromoteResourceShareCreatedFromPolicy and that operation is still in progress. This value changes to STANDARD when complete. type: string + invitationARN: + description: |- + The ARN of the invitation that was accepted. + Only populated in Accept Mode. + type: string + invitationStatus: + description: |- + The current status of the invitation. + Possible values: PENDING, ACCEPTED, REJECTED, EXPIRED + Only populated in Accept Mode. + type: string + invitationTime: + description: |- + The date and time when the invitation was sent. + Only populated in Accept Mode. + format: date-time + type: string lastUpdatedTime: description: The date and time when the resource share was last updated. format: date-time @@ -248,6 +306,33 @@ spec: description: The ID of the Amazon Web Services account that owns the resource share. type: string + receiverAccountID: + description: |- + The ID of the AWS account that received the invitation. + Only populated in Accept Mode. + type: string + resources: + description: |- + The resources included in the resource share. + Only populated in Accept Mode. + items: + type: string + type: array + senderAccountID: + description: |- + The ID of the AWS account that sent the invitation. + Only populated in Accept Mode. + type: string + shareName: + description: |- + The name of the resource share. + Only populated in Accept Mode. + type: string + shareStatus: + description: |- + The current status of the resource share. + Only populated in Accept Mode. + type: string status: description: The current status of the resource share. type: string diff --git a/examples/resource-share-integrated-modes.yaml b/examples/resource-share-integrated-modes.yaml new file mode 100644 index 0000000..28071c3 --- /dev/null +++ b/examples/resource-share-integrated-modes.yaml @@ -0,0 +1,93 @@ +--- +# Example 1: Create Mode (Default) +# This creates a new resource share in Account A and shares resources with Account B +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: my-resource-share + namespace: default +spec: + # Create Mode: Specify name and resources to share + name: my-shared-resources + allowExternalPrincipals: true + principals: + - "123456789012" # Account B's AWS account ID + resourceARNs: + - "arn:aws:ec2:us-west-2:111111111111:subnet/subnet-12345678" + - "arn:aws:ec2:us-west-2:111111111111:transit-gateway/tgw-12345678" + tags: + - key: Environment + value: Production + - key: Owner + value: TeamA + +--- +# Example 2: Accept Mode +# This accepts an incoming resource share invitation in Account B +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: accepted-share-from-account-a + namespace: default +spec: + # Accept Mode: Specify acceptInvitation with the ShareARN + acceptInvitation: + shareARN: "arn:aws:ram:us-west-2:111111111111:resource-share/12345678-1234-1234-1234-123456789012" + + # Note: In Accept Mode, the following fields are ignored: + # - name + # - allowExternalPrincipals + # - principals + # - resourceARNs + # - permissionARNs + # - sources + # - tags + +--- +# Example 3: Cross-Account Workflow +# +# Step 1: Account A (Sharer) creates a ResourceShare +# Deploy this in Account A's cluster: +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: saas-provider-share + namespace: default +spec: + name: saas-provider-resources + allowExternalPrincipals: true + principals: + - "999999999999" # Customer's AWS account ID + resourceARNs: + - "arn:aws:ec2:us-west-2:111111111111:subnet/subnet-abcdef12" + +--- +# Step 2: Account B (Customer) accepts the invitation +# Deploy this in Account B's cluster: +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: accepted-saas-resources + namespace: default +spec: + acceptInvitation: + shareARN: "arn:aws:ram:us-west-2:111111111111:resource-share/abcdef12-abcd-abcd-abcd-abcdef123456" + +--- +# Example 4: Checking Status in Accept Mode +# After accepting, the status will include invitation details: +# +# status: +# ackResourceMetadata: +# arn: arn:aws:ram:us-west-2:111111111111:resource-share/abcdef12-abcd-abcd-abcd-abcdef123456 +# conditions: [...] +# invitationARN: arn:aws:ram:us-west-2:111111111111:resource-share-invitation/12345678-1234-1234-1234-123456789012 +# invitationStatus: ACCEPTED +# senderAccountID: "111111111111" +# receiverAccountID: "999999999999" +# shareName: saas-provider-resources +# invitationTime: "2024-01-15T10:30:00Z" +# resources: +# - "arn:aws:ec2:us-west-2:111111111111:subnet/subnet-abcdef12" +# shareStatus: ACCEPTED + diff --git a/helm/crds/ram.services.k8s.aws_resourceshares.yaml b/helm/crds/ram.services.k8s.aws_resourceshares.yaml index d9517e3..6abe813 100644 --- a/helm/crds/ram.services.k8s.aws_resourceshares.yaml +++ b/helm/crds/ram.services.k8s.aws_resourceshares.yaml @@ -41,7 +41,35 @@ spec: ResourceShareSpec defines the desired state of ResourceShare. Describes a resource share in RAM. + + This resource can operate in two modes: + 1. Create Mode (default): Creates a new resource share and shares resources with principals + 2. Accept Mode: Accepts an incoming resource share invitation from another account + + To use Accept Mode, set the acceptInvitation field with the ShareARN of the incoming share. + In Accept Mode, most other fields (name, principals, resourceARNs, etc.) are ignored. properties: + acceptInvitation: + description: |- + AcceptInvitation configures this resource to accept an incoming share invitation + instead of creating a new share. When set, the controller will: + 1. Find the pending invitation for the specified ShareARN + 2. Accept the invitation + 3. Populate status with invitation details + + This is mutually exclusive with creating a new share. When acceptInvitation + is set, fields like name, principals, and resourceARNs are ignored. + + Use this when you want to accept a share from another AWS account. + properties: + shareARN: + description: |- + The Amazon Resource Name (ARN) of the resource share to accept. + This should be the ARN of the share that was created by another account. + type: string + required: + - shareARN + type: object allowExternalPrincipals: description: |- Specifies whether principals outside your organization in Organizations can @@ -49,9 +77,14 @@ spec: individual Amazon Web Services accounts that are not in your organization. A value of false only has meaning if your account is a member of an Amazon Web Services Organization. The default value is true. + + This field is ignored when acceptInvitation is set. type: boolean name: - description: Specifies the name of the resource share. + description: |- + Specifies the name of the resource share. + + This field is ignored when acceptInvitation is set. type: string permissionARNs: description: |- @@ -60,6 +93,8 @@ spec: specify an ARN for the permission, RAM automatically attaches the default version of the permission for each resource type. You can associate only one permission with each resource type included in the resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -104,6 +139,8 @@ spec: Not all resource types can be shared with IAM roles and users. For more information, see Sharing with IAM roles and users (https://docs.aws.amazon.com/ram/latest/userguide/permissions.html#permissions-rbp-supported-resource-types) in the Resource Access Manager User Guide. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -111,6 +148,8 @@ spec: description: |- Specifies a list of one or more ARNs of the resources to associate with the resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -118,6 +157,8 @@ spec: description: |- Specifies from which source accounts the service principal has access to the resources in this resource share. + + This field is ignored when acceptInvitation is set. items: type: string type: array @@ -126,6 +167,8 @@ spec: A list of one or more tag key and value pairs. The tag key must be present and not be an empty string. The tag value must be present but can be an empty string. + + This field is ignored when acceptInvitation is set. items: description: |- A structure containing a tag. A tag is metadata that you can attach to your @@ -143,8 +186,6 @@ spec: type: string type: object type: array - required: - - name type: object status: description: ResourceShareStatus defines the observed state of ResourceShare @@ -240,6 +281,23 @@ spec: but the customer ran the PromoteResourceShareCreatedFromPolicy and that operation is still in progress. This value changes to STANDARD when complete. type: string + invitationARN: + description: |- + The ARN of the invitation that was accepted. + Only populated in Accept Mode. + type: string + invitationStatus: + description: |- + The current status of the invitation. + Possible values: PENDING, ACCEPTED, REJECTED, EXPIRED + Only populated in Accept Mode. + type: string + invitationTime: + description: |- + The date and time when the invitation was sent. + Only populated in Accept Mode. + format: date-time + type: string lastUpdatedTime: description: The date and time when the resource share was last updated. format: date-time @@ -248,6 +306,33 @@ spec: description: The ID of the Amazon Web Services account that owns the resource share. type: string + receiverAccountID: + description: |- + The ID of the AWS account that received the invitation. + Only populated in Accept Mode. + type: string + resources: + description: |- + The resources included in the resource share. + Only populated in Accept Mode. + items: + type: string + type: array + senderAccountID: + description: |- + The ID of the AWS account that sent the invitation. + Only populated in Accept Mode. + type: string + shareName: + description: |- + The name of the resource share. + Only populated in Accept Mode. + type: string + shareStatus: + description: |- + The current status of the resource share. + Only populated in Accept Mode. + type: string status: description: The current status of the resource share. type: string diff --git a/pkg/resource/resource_share/sdk.go b/pkg/resource/resource_share/sdk.go index bddc1f0..363e912 100644 --- a/pkg/resource/resource_share/sdk.go +++ b/pkg/resource/resource_share/sdk.go @@ -63,6 +63,13 @@ func (rm *resourceManager) sdkFind( defer func() { exit(err) }() + + // Check if we're in Accept Mode (acceptInvitation is set) + if r.ko.Spec.AcceptInvitation != nil { + return rm.sdkFindAcceptedInvitation(ctx, r) + } + + // Original Create Mode logic // If any required fields in the input shape are missing, AWS resource is // not created yet. Return NotFound here to indicate to callers that the // resource isn't yet created. @@ -184,6 +191,11 @@ func (rm *resourceManager) sdkFind( func (rm *resourceManager) requiredFieldsMissingFromReadManyInput( r *resource, ) bool { + // In Accept Mode, Name is not required (we use ShareARN instead) + if r.ko.Spec.AcceptInvitation != nil { + return false + } + // In Create Mode, Name is required return r.ko.Spec.Name == nil } @@ -214,6 +226,19 @@ func (rm *resourceManager) sdkCreate( defer func() { exit(err) }() + + // Check if we're in Accept Mode (acceptInvitation is set) + if desired.ko.Spec.AcceptInvitation != nil { + return rm.sdkAcceptInvitation(ctx, desired) + } + + // Create Mode: Validate that name is provided + if desired.ko.Spec.Name == nil || *desired.ko.Spec.Name == "" { + return nil, ackerr.NewTerminalError(fmt.Errorf( + "spec.name is required when not in Accept Mode (acceptInvitation is not set)")) + } + + // Original Create Mode logic input, err := rm.newCreateRequestPayload(ctx, desired) if err != nil { return nil, err @@ -429,6 +454,15 @@ func (rm *resourceManager) sdkDelete( defer func() { exit(err) }() + + // Check if we're in Accept Mode + if r.ko.Spec.AcceptInvitation != nil { + // In Accept Mode, we need to reject the invitation or leave the share + // We don't own the share, so we can't delete it + return rm.sdkRejectOrLeaveShare(ctx, r) + } + + // Create Mode: Delete the resource share we own input, err := rm.newDeleteRequestPayload(r) if err != nil { return nil, err @@ -440,6 +474,142 @@ func (rm *resourceManager) sdkDelete( return nil, err } +// sdkRejectOrLeaveShare handles deletion for Accept Mode resources +// In Accept Mode, we don't own the share, so we need to handle cleanup: +// 1. If invitation is PENDING: Reject it using RejectResourceShareInvitation +// 2. If invitation is ACCEPTED: Leave the share using DisassociateResourceShare +// +// IMPORTANT: AWS Resource Type Limitations +// ----------------------------------------- +// Some AWS resource types do not support self-disassociation from resource shares. +// When a share contains these resource types, attempting to leave the share will +// result in an OperationNotPermittedException error. +// +// Known resource types with this limitation: +// - Network Firewall (network-firewall:StatefulRulegroup, network-firewall:StatelessRulegroup, etc.) +// +// For these resource types: +// - Only the share OWNER can remove principals from the share +// - The receiver CANNOT leave the share themselves +// - AWS error message: "You cannot leave resource share [...]. The share contains +// resources of the following resource types, which don't support this action: [...]" +// +// Handling Strategy: +// - When OperationNotPermittedException is encountered during deletion, we treat it +// as a successful deletion to allow the Kubernetes resource to be cleaned up +// - The AWS invitation will remain in ACCEPTED state until the share owner removes +// the principal or deletes the share +// - This prevents the Kubernetes resource from getting stuck with a finalizer +// - A warning is logged to inform operators about the limitation +// +// This is expected AWS behavior and not a bug in the controller. +func (rm *resourceManager) sdkRejectOrLeaveShare( + ctx context.Context, + r *resource, +) (latest *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + rlog.Info("Cleaning up resource share in Accept Mode") + + // Get the invitation ARN, status, and share ARN from the resource + invitationARN := r.ko.Status.InvitationARN + invitationStatus := r.ko.Status.InvitationStatus + shareARN := r.ko.Spec.AcceptInvitation.ShareARN + + if shareARN == nil { + rlog.Info("No ShareARN in spec, nothing to clean up") + return nil, nil + } + + if invitationARN == nil || *invitationARN == "" { + // No invitation ARN in status, try to find it + listInput := &svcsdk.GetResourceShareInvitationsInput{ + ResourceShareArns: []string{*shareARN}, + } + + listResp, err := rm.sdkapi.GetResourceShareInvitations(ctx, listInput) + rm.metrics.RecordAPICall("READ_MANY", "GetResourceShareInvitations", err) + if err != nil { + rlog.Info("Failed to find invitation, may already be cleaned up", "error", err) + return nil, nil + } + + // Find the invitation (pending or accepted) + for _, inv := range listResp.ResourceShareInvitations { + if inv.Status == svcsdktypes.ResourceShareInvitationStatusPending || + inv.Status == svcsdktypes.ResourceShareInvitationStatusAccepted { + invitationARN = inv.ResourceShareInvitationArn + invitationStatus = aws.String(string(inv.Status)) + break + } + } + + if invitationARN == nil { + rlog.Info("No invitation found, nothing to clean up") + return nil, nil + } + } + + // Check the invitation status + if invitationStatus != nil && *invitationStatus == "ACCEPTED" { + // For accepted invitations, we need to disassociate ourselves as a principal + // This is what the AWS GUI "Leave" button does + rlog.Info("Leaving accepted resource share by disassociating principal", + "shareARN", *shareARN) + + // Get our account ID + accountID := r.ko.Status.ReceiverAccountID + if accountID == nil || *accountID == "" { + rlog.Info("No receiver account ID in status, cannot disassociate") + return nil, ackerr.NewTerminalError(fmt.Errorf("receiver account ID not found in status")) + } + + // Disassociate ourselves as a principal from the share + disassociateInput := &svcsdk.DisassociateResourceShareInput{ + ResourceShareArn: shareARN, + Principals: []string{*accountID}, + } + + _, err = rm.sdkapi.DisassociateResourceShare(ctx, disassociateInput) + rm.metrics.RecordAPICall("DELETE", "DisassociateResourceShare", err) + if err != nil { + // Check if this is an OperationNotPermittedException + // Some resource types (e.g., Network Firewall) don't support self-disassociation + // In this case, only the share owner can remove the principal + if strings.Contains(err.Error(), "OperationNotPermittedException") { + rlog.Info("Cannot leave resource share - resource type does not support self-disassociation. " + + "Only the share owner can remove you from this share. " + + "Treating as successful deletion to avoid resource getting stuck.", + "error", err) + // Return success to allow the Kubernetes resource to be deleted + // The AWS invitation will remain ACCEPTED until the owner removes the principal + return nil, nil + } + + rlog.Info("Failed to disassociate from resource share", "error", err) + return nil, err + } + + rlog.Info("Successfully left resource share") + return nil, nil + } + + // Invitation is PENDING - we can reject it + rlog.Info("Rejecting pending resource share invitation", "invitationARN", *invitationARN) + rejectInput := &svcsdk.RejectResourceShareInvitationInput{ + ResourceShareInvitationArn: invitationARN, + } + + _, err = rm.sdkapi.RejectResourceShareInvitation(ctx, rejectInput) + rm.metrics.RecordAPICall("DELETE", "RejectResourceShareInvitation", err) + if err != nil { + rlog.Info("Failed to reject resource share invitation", "error", err) + return nil, err + } + + rlog.Info("Successfully rejected resource share invitation") + return nil, nil +} + // newDeleteRequestPayload returns an SDK-specific struct for the HTTP request // payload of the Delete API call for the resource func (rm *resourceManager) newDeleteRequestPayload( @@ -568,3 +738,247 @@ func (rm *resourceManager) terminalAWSError(err error) bool { return false } } + +// sdkFindAcceptedInvitation finds an accepted invitation for the specified ShareARN +// This is used when spec.acceptInvitation is set (Accept Mode) +func (rm *resourceManager) sdkFindAcceptedInvitation( + ctx context.Context, + r *resource, +) (latest *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.sdkFindAcceptedInvitation") + defer func() { + exit(err) + }() + + if r.ko.Spec.AcceptInvitation == nil || r.ko.Spec.AcceptInvitation.ShareARN == nil { + return nil, ackerr.NotFound + } + + // Get the invitation for this share ARN + input := &svcsdk.GetResourceShareInvitationsInput{ + ResourceShareArns: []string{*r.ko.Spec.AcceptInvitation.ShareARN}, + } + + var resp *svcsdk.GetResourceShareInvitationsOutput + resp, err = rm.sdkapi.GetResourceShareInvitations(ctx, input) + rm.metrics.RecordAPICall("READ_ONE", "GetResourceShareInvitations", err) + if err != nil { + var awsErr smithy.APIError + if errors.As(err, &awsErr) { + if awsErr.ErrorCode() == "ResourceShareInvitationArnNotFoundException" || + awsErr.ErrorCode() == "UnknownResourceException" { + return nil, ackerr.NotFound + } + } + return nil, err + } + + // Find the accepted invitation for this share + ko := r.ko.DeepCopy() + rm.setStatusDefaults(ko) + found := false + var acceptedInvitationARN *string + + for _, invitation := range resp.ResourceShareInvitations { + // We're looking for an ACCEPTED invitation for this share + if invitation.Status == svcsdktypes.ResourceShareInvitationStatusAccepted { + found = true + acceptedInvitationARN = invitation.ResourceShareInvitationArn + rm.setResourceFromInvitation(ko, &invitation) + break + } + } + + if !found { + return nil, ackerr.NotFound + } + + // Populate resources associated with the invitation + // This uses the ListPendingInvitationResources API (non-deprecated) + if acceptedInvitationARN != nil { + _ = rm.populateInvitationResources(ctx, ko, acceptedInvitationARN) + } + + return &resource{ko}, nil +} + +// sdkAcceptInvitation accepts a pending resource share invitation +// This is used when spec.acceptInvitation is set (Accept Mode) +// +// Accept Mode Overview: +// --------------------- +// In Accept Mode, the ResourceShare CRD is used to accept an incoming resource share +// invitation from another AWS account (cross-account sharing). The workflow is: +// +// 1. Account A (sender) creates a resource share and shares it with Account B (receiver) +// 2. AWS RAM sends an invitation to Account B +// 3. Account B creates a ResourceShare with spec.acceptInvitation.shareARN set +// 4. The controller accepts the invitation and populates status fields +// +// Deletion Behavior: +// ------------------ +// When deleting a ResourceShare in Accept Mode: +// - PENDING invitations: Rejected using RejectResourceShareInvitation +// - ACCEPTED invitations: Left using DisassociateResourceShare (if supported) +// +// IMPORTANT: Some resource types (e.g., Network Firewall) do not support self-disassociation. +// For these resources, the Kubernetes resource will be deleted successfully, but the AWS +// invitation will remain ACCEPTED until the share owner removes the principal. +// See sdkRejectOrLeaveShare() for detailed documentation on this limitation. +func (rm *resourceManager) sdkAcceptInvitation( + ctx context.Context, + desired *resource, +) (created *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.sdkAcceptInvitation") + defer func() { + exit(err) + }() + + if desired.ko.Spec.AcceptInvitation == nil || desired.ko.Spec.AcceptInvitation.ShareARN == nil { + return nil, fmt.Errorf("ShareARN is required in acceptInvitation") + } + + // First, find the pending invitation for this share ARN + getInput := &svcsdk.GetResourceShareInvitationsInput{ + ResourceShareArns: []string{*desired.ko.Spec.AcceptInvitation.ShareARN}, + } + + var getResp *svcsdk.GetResourceShareInvitationsOutput + getResp, err = rm.sdkapi.GetResourceShareInvitations(ctx, getInput) + rm.metrics.RecordAPICall("READ_ONE", "GetResourceShareInvitations", err) + if err != nil { + return nil, err + } + + // Find a pending invitation + var pendingInvitationARN *string + for _, invitation := range getResp.ResourceShareInvitations { + if invitation.Status == svcsdktypes.ResourceShareInvitationStatusPending { + pendingInvitationARN = invitation.ResourceShareInvitationArn + break + } + } + + if pendingInvitationARN == nil { + return nil, ackerr.NewTerminalError(fmt.Errorf("no pending invitation found for share ARN %s. "+ + "NOTE: If both AWS accounts are in the same AWS Organization and RAM Sharing with AWS Organizations is enabled, "+ + "this resource is not necessary", *desired.ko.Spec.AcceptInvitation.ShareARN)) + } + + // Accept the invitation + acceptInput := &svcsdk.AcceptResourceShareInvitationInput{ + ResourceShareInvitationArn: pendingInvitationARN, + } + + var acceptResp *svcsdk.AcceptResourceShareInvitationOutput + acceptResp, err = rm.sdkapi.AcceptResourceShareInvitation(ctx, acceptInput) + rm.metrics.RecordAPICall("CREATE", "AcceptResourceShareInvitation", err) + if err != nil { + return nil, err + } + + ko := desired.ko.DeepCopy() + rm.setStatusDefaults(ko) + rm.setResourceFromInvitation(ko, acceptResp.ResourceShareInvitation) + + // Populate resources associated with the invitation + // This uses the ListPendingInvitationResources API (non-deprecated) + if acceptResp.ResourceShareInvitation != nil && acceptResp.ResourceShareInvitation.ResourceShareInvitationArn != nil { + _ = rm.populateInvitationResources(ctx, ko, acceptResp.ResourceShareInvitation.ResourceShareInvitationArn) + } + + return &resource{ko}, nil +} + +// setResourceFromInvitation populates the resource status from an invitation +// This is used in Accept Mode to populate invitation-specific status fields +func (rm *resourceManager) setResourceFromInvitation( + ko *svcapitypes.ResourceShare, + invitation *svcsdktypes.ResourceShareInvitation, +) { + if invitation == nil { + return + } + + // Set invitation-specific status fields + if invitation.ResourceShareInvitationArn != nil { + ko.Status.InvitationARN = invitation.ResourceShareInvitationArn + } + if invitation.Status != "" { + ko.Status.InvitationStatus = aws.String(string(invitation.Status)) + } + if invitation.SenderAccountId != nil { + ko.Status.SenderAccountID = invitation.SenderAccountId + } + if invitation.ReceiverAccountId != nil { + ko.Status.ReceiverAccountID = invitation.ReceiverAccountId + } + if invitation.ResourceShareName != nil { + ko.Status.ShareName = invitation.ResourceShareName + } + if invitation.InvitationTimestamp != nil { + ko.Status.InvitationTime = &metav1.Time{*invitation.InvitationTimestamp} + } + if invitation.ResourceShareArn != nil { + arn := ackv1alpha1.AWSResourceName(*invitation.ResourceShareArn) + if ko.Status.ACKResourceMetadata == nil { + ko.Status.ACKResourceMetadata = &ackv1alpha1.ResourceMetadata{} + } + ko.Status.ACKResourceMetadata.ARN = &arn + } + + // Set share status + if invitation.Status != "" { + ko.Status.ShareStatus = aws.String(string(invitation.Status)) + } +} + +// populateInvitationResources fetches and populates the resources associated with an invitation +// Uses the ListPendingInvitationResources API (replaces deprecated ResourceShareAssociations field) +func (rm *resourceManager) populateInvitationResources( + ctx context.Context, + ko *svcapitypes.ResourceShare, + invitationARN *string, +) error { + rlog := ackrtlog.FromContext(ctx) + + if invitationARN == nil { + return nil + } + + rlog.Info("Fetching resources for invitation using ListPendingInvitationResources API", + "invitationARN", *invitationARN) + + // List resources associated with the pending invitation + input := &svcsdk.ListPendingInvitationResourcesInput{ + ResourceShareInvitationArn: invitationARN, + } + + resp, err := rm.sdkapi.ListPendingInvitationResources(ctx, input) + rm.metrics.RecordAPICall("READ", "ListPendingInvitationResources", err) + if err != nil { + // Don't fail the whole operation if we can't list resources + // Just log and continue + rlog.Info("Failed to list pending invitation resources (non-fatal)", "error", err) + return nil + } + + if resp.Resources != nil && len(resp.Resources) > 0 { + resources := make([]*string, 0, len(resp.Resources)) + for _, resource := range resp.Resources { + if resource.Arn != nil { + resources = append(resources, resource.Arn) + } + } + if len(resources) > 0 { + ko.Status.Resources = resources + rlog.Info("Populated resources from invitation", "resourceCount", len(resources)) + } + } else { + rlog.Info("No resources found in invitation") + } + + return nil +} diff --git a/test/e2e/ram_resource_share.py b/test/e2e/ram_resource_share.py index 953168d..6be1e36 100644 --- a/test/e2e/ram_resource_share.py +++ b/test/e2e/ram_resource_share.py @@ -136,4 +136,69 @@ def list_associated_resources(arn): if 'resourceShareAssociations' in resp and len(resp['resourceShareAssociations']) > 0: return resp['resourceShareAssociations'][0] except c.UnknownResourceException: + return None + + +def get_resource_share_invitations(share_arn=None): + """Returns resource share invitations. + + Args: + share_arn: Optional ShareARN to filter invitations + + Returns: + List of invitations, or None if error + """ + c = boto3.client('ram') + try: + params = {} + if share_arn: + params['resourceShareArns'] = [share_arn] + + resp = c.get_resource_share_invitations(**params) + if 'resourceShareInvitations' in resp: + return resp['resourceShareInvitations'] + return [] + except Exception as e: + return None + + +def accept_resource_share_invitation(invitation_arn): + """Accepts a resource share invitation. + + Args: + invitation_arn: ARN of the invitation to accept + + Returns: + The accepted invitation details, or None if error + """ + c = boto3.client('ram') + try: + resp = c.accept_resource_share_invitation( + resourceShareInvitationArn=invitation_arn + ) + if 'resourceShareInvitation' in resp: + return resp['resourceShareInvitation'] + return None + except Exception as e: + return None + + +def reject_resource_share_invitation(invitation_arn): + """Rejects a resource share invitation. + + Args: + invitation_arn: ARN of the invitation to reject + + Returns: + The rejected invitation details, or None if error + """ + c = boto3.client('ram') + try: + resp = c.reject_resource_share_invitation( + resourceShareInvitationArn=invitation_arn + ) + if 'resourceShareInvitation' in resp: + return resp['resourceShareInvitation'] + return None + except Exception as e: return None \ No newline at end of file diff --git a/test/e2e/resources/ram_resource_share_accept_mode.yaml b/test/e2e/resources/ram_resource_share_accept_mode.yaml new file mode 100644 index 0000000..9a583e1 --- /dev/null +++ b/test/e2e/resources/ram_resource_share_accept_mode.yaml @@ -0,0 +1,8 @@ +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: $RESOURCE_SHARE_NAME +spec: + acceptInvitation: + shareARN: $SHARE_ARN + diff --git a/test/e2e/resources/ram_resource_share_no_name.yaml b/test/e2e/resources/ram_resource_share_no_name.yaml new file mode 100644 index 0000000..233e534 --- /dev/null +++ b/test/e2e/resources/ram_resource_share_no_name.yaml @@ -0,0 +1,8 @@ +apiVersion: ram.services.k8s.aws/v1alpha1 +kind: ResourceShare +metadata: + name: $RESOURCE_SHARE_NAME +spec: + # Create Mode without name - should fail validation + allowExternalPrincipals: true + diff --git a/test/e2e/tests/test_resource_share_accept_mode.py b/test/e2e/tests/test_resource_share_accept_mode.py new file mode 100644 index 0000000..e66a4da --- /dev/null +++ b/test/e2e/tests/test_resource_share_accept_mode.py @@ -0,0 +1,147 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +"""Integration tests for the ResourceShare Accept Mode functionality. + +Accept Mode allows a ResourceShare to accept an incoming share invitation +from another AWS account, rather than creating a new share. +""" + +import pytest +import time +import logging + +from acktest.resources import random_suffix_name +from acktest.k8s import resource as k8s +from e2e import service_marker, CRD_GROUP, CRD_VERSION, load_ram_resource +from e2e.replacement_values import REPLACEMENT_VALUES + +RESOURCE_KIND = "ResourceShare" +RESOURCE_PLURAL = "resourceshares" + +CREATE_WAIT_AFTER_SECONDS = 10 +DELETE_WAIT_AFTER_SECONDS = 20 + + +@service_marker +class TestResourceShareAcceptMode: + """Tests for ResourceShare Accept Mode functionality. + + Accept Mode is used when you want to accept a resource share invitation + from another AWS account, rather than creating a new share. + """ + + def test_create_mode_name_validation(self): + """Test that creating a ResourceShare in Create Mode without a name fails. + + When acceptInvitation is NOT set (Create Mode), the name field is required. + This test verifies that the controller returns a Terminal error when name is missing. + """ + resource_name = random_suffix_name("no-name-test", 24) + + replacements = REPLACEMENT_VALUES.copy() + replacements["RESOURCE_SHARE_NAME"] = resource_name + + # Load ResourceShare CR without name field + resource_data = load_ram_resource( + "ram_resource_share_no_name", + additional_replacements=replacements, + ) + logging.debug(resource_data) + + # Create k8s resource + ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, RESOURCE_PLURAL, + resource_name, namespace="default", + ) + k8s.create_custom_resource(ref, resource_data) + + time.sleep(CREATE_WAIT_AFTER_SECONDS) + + # Verify resource has Terminal error condition + cr = k8s.get_resource(ref) + assert cr is not None + assert 'status' in cr + assert 'conditions' in cr['status'] + + # Check for ACK.Terminal condition + terminal_condition = None + for condition in cr['status']['conditions']: + if condition['type'] == 'ACK.Terminal': + terminal_condition = condition + break + + assert terminal_condition is not None, "Expected ACK.Terminal condition" + assert terminal_condition['status'] == 'True', "Expected Terminal condition to be True" + assert 'name is required' in terminal_condition['message'].lower(), \ + f"Expected error message about name being required, got: {terminal_condition['message']}" + + logging.info(f"Terminal error message: {terminal_condition['message']}") + + # Clean up + k8s.delete_custom_resource(ref, period_length=DELETE_WAIT_AFTER_SECONDS) + + @pytest.mark.skip(reason="Requires cross-account setup with actual invitation") + def test_accept_mode_happy_path(self): + """Test accepting a resource share invitation (Accept Mode). + + This test requires: + 1. Another AWS account (Account A) to create a resource share + 2. An invitation sent to the test account (Account B) + 3. The ShareARN of the invitation + + TODO: Implement this test when cross-account test infrastructure is available. + + Expected behavior: + - ResourceShare is created with acceptInvitation field + - Controller finds the pending invitation + - Controller accepts the invitation + - Status fields are populated: invitationARN, invitationStatus, senderAccountID, etc. + - Invitation status becomes ACCEPTED + """ + pass + + @pytest.mark.skip(reason="Requires cross-account setup with actual invitation") + def test_accept_mode_deletion(self): + """Test deleting a ResourceShare in Accept Mode. + + This test requires: + 1. An accepted resource share invitation + 2. A ResourceShare in Accept Mode that has accepted the invitation + + TODO: Implement this test when cross-account test infrastructure is available. + + Expected behavior: + - When deleting a ResourceShare with ACCEPTED invitation: + - Controller calls DisassociateResourceShare (leaves the share) + - Invitation is cleaned up in AWS + - Kubernetes resource is deleted + """ + pass + + @pytest.mark.skip(reason="Requires cross-account setup with actual invitation") + def test_accept_mode_nonexistent_invitation(self): + """Test accepting a non-existent invitation. + + This test verifies error handling when trying to accept an invitation + that doesn't exist. + + TODO: Implement this test when cross-account test infrastructure is available. + + Expected behavior: + - ResourceShare is created with acceptInvitation pointing to non-existent share + - Controller returns Terminal error + - Error message indicates no pending invitation found + """ + pass +