From daaef109be19276c80922591a6b96161c1db4921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Portela=20Afonso?= Date: Thu, 23 Jun 2022 15:19:26 +0100 Subject: [PATCH] feat: ASG Tag lambda function (#7) --- .github/workflows/prereleased.yml | 73 ++++++++++++- .github/workflows/released.yml | 64 ++++++++++- Package.resolved | 94 +++++++++++++++- Package.swift | 33 ++++-- README.md | 102 +++++++++++++++++- Sources/App/ASG/ASGClient.swift | 31 ++++++ Sources/App/ASG/ASGProvider.swift | 13 +++ Sources/App/Application.swift | 56 ++++++++++ Sources/App/EKS/EKSClient.swift | 33 ++++++ Sources/App/EKS/EKSProvider.swift | 14 +++ Sources/App/HTTP/HTTPClient.swift | 45 ++++++++ Sources/App/HTTP/HTTPClientProvider.swift | 10 ++ Sources/App/Hulk.swift | 62 +++++++++++ Sources/App/LambdaResult.swift | 4 + .../Codable/ClusterNodesTags+Codable.swift | 28 +++++ .../Codable/NodePool+Codable.swift | 25 +++++ .../CloudFormation/Codable/Tag+Codable.swift | 25 +++++ Sources/CloudFormation/run.swift | 79 ++++++++++++++ Sources/Models/ClusterNodesTags.swift | 19 ++++ Sources/Models/NodePool.swift | 11 ++ Sources/Models/Tag.swift | 11 ++ Sources/Run/main.swift | 5 - Tests/LambdaTests/lambda.swift | 8 -- 23 files changed, 818 insertions(+), 27 deletions(-) create mode 100644 Sources/App/ASG/ASGClient.swift create mode 100644 Sources/App/ASG/ASGProvider.swift create mode 100644 Sources/App/Application.swift create mode 100644 Sources/App/EKS/EKSClient.swift create mode 100644 Sources/App/EKS/EKSProvider.swift create mode 100644 Sources/App/HTTP/HTTPClient.swift create mode 100644 Sources/App/HTTP/HTTPClientProvider.swift create mode 100644 Sources/App/Hulk.swift create mode 100644 Sources/App/LambdaResult.swift create mode 100644 Sources/CloudFormation/Codable/ClusterNodesTags+Codable.swift create mode 100644 Sources/CloudFormation/Codable/NodePool+Codable.swift create mode 100644 Sources/CloudFormation/Codable/Tag+Codable.swift create mode 100644 Sources/CloudFormation/run.swift create mode 100644 Sources/Models/ClusterNodesTags.swift create mode 100644 Sources/Models/NodePool.swift create mode 100644 Sources/Models/Tag.swift delete mode 100644 Sources/Run/main.swift delete mode 100644 Tests/LambdaTests/lambda.swift diff --git a/.github/workflows/prereleased.yml b/.github/workflows/prereleased.yml index a2b7546..3fbfb95 100644 --- a/.github/workflows/prereleased.yml +++ b/.github/workflows/prereleased.yml @@ -14,6 +14,8 @@ env: GHCR_USERNAME: ${{ github.repository_owner }} GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + DOCKERHUB_REGISTRY: ydata/ + DOCKER_REPOSITORY: aws-asg-tags-lambda @@ -47,8 +49,41 @@ jobs: run: echo ::set-output name=value::${GITHUB_REF#refs/*/} - build: - name: Build + build-dockerhub: + name: Build and push to Docker Hub + runs-on: ubuntu-20.04 + + needs: + - prepare + + steps: + - uses: actions/checkout@v3 + + - uses: actions/cache@v3 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Login to Dockerhub Registry + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v3 + env: + DOCKER_IMAGE_TAG: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKER_REPOSITORY }}:${{ needs.prepare.outputs.version }} + with: + push: true + tags: ${{ env.DOCKER_IMAGE_TAG }} + + + build-ghcr: + name: Build and push to Github Container Registry runs-on: ubuntu-20.04 needs: @@ -79,3 +114,37 @@ jobs: with: push: true tags: ${{ env.DOCKER_IMAGE_TAG }} + + + update-manifests: + name: Update AWS Marketplace + runs-on: ubuntu-20.04 + + needs: + - prepare + - build-ghcr + + env: + COMPONENT: ASG_TAGS_LAMBDA_VERSION + + steps: + - name: Checkout AWS Marketplace repo + uses: actions/checkout@v3 + with: + repository: ydataai/aws-marketplace + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Update aws-marketplace + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: echo ${{ env.VERSION }} > ${{ env.COMPONENT }} + + - name: Commit and push image update into manifests repo + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + git config user.email "azory@ydata.ai" + git config user.name "Azory YData Bot" + git add ${{ env.COMPONENT }} + git commit -a -m "chore(bump): [CI] [DEV] bump ${{ env.COMPONENT }} to $VERSION" + git push origin master diff --git a/.github/workflows/released.yml b/.github/workflows/released.yml index 9f0d8b0..2e8e7dc 100644 --- a/.github/workflows/released.yml +++ b/.github/workflows/released.yml @@ -14,6 +14,8 @@ env: GHCR_USERNAME: ${{ github.repository_owner }} GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + DOCKERHUB_REGISTRY: ydata/ + DOCKER_REPOSITORY: aws-asg-tags-lambda @@ -54,8 +56,32 @@ jobs: run: echo "::set-output name=value::$(git rev-parse --short HEAD)" - docker: - name: Docker Tag and Push + docker-tag-dockerhub: + name: Docker Tag and Push to Docker Hub + runs-on: ubuntu-20.04 + + needs: + - prepare + + steps: + - name: Login to Dockerhub Registry + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Docker tag and push + env: + SOURCE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKER_REPOSITORY }}:${{ needs.prepare.outputs.version }}.${{ needs.prepare.outputs.build_number }} + DESTINATION: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKER_REPOSITORY }}:${{ needs.prepare.outputs.version }} + run: | + docker pull $SOURCE + docker tag $SOURCE $DESTINATION + docker push $DESTINATION + + + docker-tag-ghcr: + name: Docker Tag and Push to Github Container Registry runs-on: ubuntu-20.04 needs: @@ -77,3 +103,37 @@ jobs: docker pull $SOURCE docker tag $SOURCE $DESTINATION docker push $DESTINATION + + + update-manifests: + name: Update AWS Marketplace + runs-on: ubuntu-20.04 + + needs: + - prepare + - docker-tag-ghcr + + env: + COMPONENT: ASG_TAGS_LAMBDA_VERSION + + steps: + - name: Checkout AWS Marketplace repo + uses: actions/checkout@v3 + with: + repository: ydataai/aws-marketplace + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Update aws-marketplace + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: echo ${{ env.VERSION }} > ${{ env.COMPONENT }} + + - name: Commit and push image update into manifests repo + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + git config user.email "azory@ydata.ai" + git config user.name "Azory YData Bot" + git add ${{ env.COMPONENT }} + git commit -a -m "chore(bump): [CI] [PROD] bump ${{ env.COMPONENT }} to $VERSION" + git push origin master diff --git a/Package.resolved b/Package.resolved index 7508121..9bdcc33 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,57 @@ { "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "794dc9d42720af97cedd395e8cd2add9173ffd9a", + "version" : "1.11.1" + } + }, + { + "identity" : "jmespath.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/adam-fowler/jmespath.swift.git", + "state" : { + "revision" : "4513d319c4aaa6c3b2ac18e1e6566a803515ad91", + "version" : "1.0.2" + } + }, + { + "identity" : "soto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soto-project/soto.git", + "state" : { + "revision" : "9c938aadbbb33d6ed54d04dd6ba494f7f12e0905", + "version" : "6.0.0" + } + }, + { + "identity" : "soto-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soto-project/soto-core.git", + "state" : { + "revision" : "8e63c0f80db61f01c346f5109863bc2be29093e7", + "version" : "6.0.0" + } + }, + { + "identity" : "swift-aws-lambda-events", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ydataai/swift-aws-lambda-events.git", + "state" : { + "branch" : "main", + "revision" : "94642d229fea2459ee8400898431160d6f13ffb4" + } + }, { "identity" : "swift-aws-lambda-runtime", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-aws-lambda-runtime.git", "state" : { - "revision" : "699ada1724459582303c15aae64fa12ca4d33809", - "version" : "0.5.2" + "branch" : "main", + "revision" : "cb340de265665e23984b1f5de3ac4d413a337804" } }, { @@ -27,6 +72,15 @@ "version" : "1.4.2" } }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "1c1408bf8fc21be93713e897d2badf500ea38419", + "version" : "2.3.1" + } + }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", @@ -35,6 +89,42 @@ "revision" : "124119f0bb12384cef35aa041d7c3a686108722d", "version" : "2.40.0" } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "a75e92bde3683241c15df3dd905b7a6dcac4d551", + "version" : "1.12.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772", + "version" : "1.22.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "42436a25ff32c390465567f5c089a9a8ce8d7baf", + "version" : "2.20.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "2cb54f91ddafc90832c5fa247faf5798d0a7c204", + "version" : "1.13.0" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 88c7de7..7451a70 100644 --- a/Package.swift +++ b/Package.swift @@ -5,17 +5,36 @@ import PackageDescription let package = Package( name: "aws-asg-tags-lambda", + platforms: [ + .macOS(.v12) + ], dependencies: [ - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "0.5.2") + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"), + .package(url: "https://github.com/ydataai/swift-aws-lambda-events.git", branch: "main"), + .package(url: "https://github.com/soto-project/soto.git", from: "6.0.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.11.1") ], targets: [ + .target(name: "Models"), + .target( + name: "App", + dependencies: [ + .byName(name: "Models"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "SotoAutoScaling", package: "soto"), + .product(name: "SotoEKS", package: "soto") + ], + swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] + ), .executableTarget( - name: "Run", + name: "CloudFormation", dependencies: [ - .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime") - ]), - .testTarget( - name: "LambdaTests", - dependencies: ["Run"]) + .byName(name: "App"), + .byName(name: "Models"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") + ], + swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] + ) ] ) diff --git a/README.md b/README.md index bf4ff39..748a86c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,103 @@ +[![Released](https://img.shields.io/github/v/release/ydataai/aws-asg-tags-lambda?display_name=tag&label=release&logo=github&sort=semver&style=flat-square)](https://github.com/ydataai/aws-asg-tags-lambda/actions/workflows/released.yml) +[![PreReleased](https://img.shields.io/github/v/release/ydataai/aws-asg-tags-lambda?display_name=tag&include_prereleases&label=prerelease&logo=github&sort=semver&style=flat-square)](https://github.com/ydataai/aws-asg-tags-lambda/actions/workflows/prereleased.yml) +[![CI Status](https://img.shields.io/github/workflow/status/ydataai/aws-asg-tags-lambda/Merge%20Main?label=ci&logo=github&style=flat-square)](https://github.com/ydataai/aws-asg-tags-lambda/actions/workflows/merge-main.yml) +[![license](https://img.shields.io/github/license/ydataai/aws-asg-tags-lambda?label=license&style=flat-square)](https://github.com/ydataai/aws-asg-tags-lambda/blob/main/LICENSE) +[![Swift 5.6](https://img.shields.io/badge/Swift-5.6-orange.svg?style=flat-square&logo=swift)](https://developer.apple.com/swift/) + # AWS Auto Scaling Groups Tag lambda -A lambda that extracts information from k8s nodes into Auto Scaling Groups +A lambda that add tags to the auto scaling groups of each k8s node. + +It's user responsability to specify the cluster the pools and the tags for each pool, or specify it in the common tags, if you want the same tag for each node you want to process. + +## How to use + +### CloudFormation + +The execution role, it's necessary to connect to the EKS and EC2 for the auto scaling groups + +```yaml +EKSASGTagLambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Policies: + - PolicyName: !Join + - '-' + - - 'lambda-asg-tag' + - !Ref IntegerSuffix + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - eks:* + - ec2:* + Resource: '*' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole +``` + +The declaration of the lambda function, which will be used by the invoke + +```yaml +EKSASGTagLambdaFunction: + Type: AWS::Lambda::Function + Properties: + Role: !GetAtt EKSASGTagLambdaExecutionRole.Arn + PackageType: Image + Code: + ImageUri: !Ref EcrImageUri + Architectures: + - x86_64 + MemorySize: 1024 + Timeout: 300 +``` + +The lambda invokation + +```yaml +EKSASGTagLambdaInvoke: + Type: AWS::CloudFormation::CustomResource + DependsOn: EKSASGTagLambdaFunction + Version: "1.0" + Properties: + ServiceToken: !GetAtt EKSASGTagLambdaFunction.Arn + StackID: !Ref AWS::StackId + AccountID: !Ref AWS::AccountId + Region: !Ref AWS::Region + ClusterName: "the EKS cluster name" + CommonTags: + - Name: "A Tag" + Value: "A value for the tag" + NodePools: + - Name: "A node pool name" + Tags: + - Name: "Another Tag" + Value: "A value for another tag" + - Name: "Another pool name" + Tags: + - Name: "Another Tag" + Value: "A value for another tag" + +``` + + +## TODO +- [ ] Add generic context +- [ ] Tests +- [ ] Better Documentation +- [ ] Support other methods of usage + + +## About 👯‍♂️ + +With ❤️ from [YData](https://ydata.ai) [Development team](mailto://developers@ydata.ai) diff --git a/Sources/App/ASG/ASGClient.swift b/Sources/App/ASG/ASGClient.swift new file mode 100644 index 0000000..9d28f97 --- /dev/null +++ b/Sources/App/ASG/ASGClient.swift @@ -0,0 +1,31 @@ +import Foundation +import SotoAutoScaling + +protocol ASGClientRepresentable { + func updateTags(_ tags: [AutoScaling.Tag]) async throws +} + +struct ASGClient: ASGClientRepresentable { + let logger: Logger + let provider: Provider + + init(logger: Logger, provider: Provider) { + self.logger = logger + self.provider = provider + } + + func updateTags(_ tags: [AutoScaling.Tag]) async throws { + let updatedTags = tags.map { + AutoScaling.Tag( + key: $0.key, + propagateAtLaunch: $0.propagateAtLaunch ?? true, + resourceId: $0.resourceId, + resourceType: $0.resourceType ?? "auto-scaling-group", + value: $0.value) + } + + let request = AutoScaling.CreateOrUpdateTagsType(tags: updatedTags) + + try await provider.createOrUpdateTags(request, logger: logger) + } +} diff --git a/Sources/App/ASG/ASGProvider.swift b/Sources/App/ASG/ASGProvider.swift new file mode 100644 index 0000000..a104099 --- /dev/null +++ b/Sources/App/ASG/ASGProvider.swift @@ -0,0 +1,13 @@ +import Foundation +import SotoAutoScaling + +protocol ASGProvider { + func createOrUpdateTags(_ input: AutoScaling.CreateOrUpdateTagsType, logger: Logger) async throws +} + +extension AutoScaling: ASGProvider { + @inlinable + func createOrUpdateTags(_ input: CreateOrUpdateTagsType, logger: Logger) async throws { + try await createOrUpdateTags(input, logger: logger, on: nil) + } +} diff --git a/Sources/App/Application.swift b/Sources/App/Application.swift new file mode 100644 index 0000000..520eb56 --- /dev/null +++ b/Sources/App/Application.swift @@ -0,0 +1,56 @@ +import AWSLambdaRuntime +import Models +import SotoCore +import SotoAutoScaling +import SotoEKS + +public struct Application { + let context: LambdaInitializationContext + + let awsClient: AWSClient + let hulk: Hulk + + public init(context: LambdaInitializationContext) { + self.context = context + + self.awsClient = AWSClient( + httpClientProvider: .createNewWithEventLoopGroup(self.context.eventLoop), + logger: self.context.logger + ) + self.context.terminator.register(name: "\(type(of: AWSClient.self))", handler: self.awsClient.shutdown) + + let eks = EKS(client: self.awsClient) + let eksClient = EKSClient(logger: self.context.logger, provider: eks) + + let asg = AutoScaling(client: self.awsClient) + let asgClient = ASGClient(logger: self.context.logger, provider: asg) + + self.hulk = Hulk(asgClient: asgClient, eksClient: eksClient, logger: self.context.logger) + } + + public func run(with clusterInfo: ClusterNodesTags, runContext: LambdaContext) async -> LambdaResult { + runContext.logger.info("running lambda with event info \(clusterInfo)") + + do { + try await hulk.smash(clusterInfo) + + runContext.logger.info("successfully run lambda with info \(clusterInfo)") + + return .success(()) + } catch { + runContext.logger.error("failed to run lambda with \(clusterInfo) with error: \(error)") + + return .failure(error) + } + } +} + +extension AWSClient { + func shutdown(eventLoop: EventLoop) -> EventLoopFuture { + let promise = eventLoop.makePromise(of: Void.self) + + promise.completeWithTask { try await self.shutdown() } + + return promise.futureResult + } +} diff --git a/Sources/App/EKS/EKSClient.swift b/Sources/App/EKS/EKSClient.swift new file mode 100644 index 0000000..a47d696 --- /dev/null +++ b/Sources/App/EKS/EKSClient.swift @@ -0,0 +1,33 @@ +import SotoEKS + +protocol EKSClientRepresentable { + func describeNodeGroup(name: String, clusterName: String) async throws -> EKS.Nodegroup +} + +struct EKSClient: EKSClientRepresentable { + let logger: Logger + let provider: Provider + + init(logger: Logger, provider: Provider) { + self.logger = logger + self.provider = provider + } + + func describeNodeGroup(name: String, clusterName: String) async throws -> EKS.Nodegroup { + let request = EKS.DescribeNodegroupRequest(clusterName: clusterName, nodegroupName: name) + + let response = try await provider.describeNodegroup(request, logger: logger) + + guard let nodeGroup = response.nodegroup else { + throw Error.cannotFindNodeGroup(name, clusterName) + } + + return nodeGroup + } +} + +extension EKSClient { + enum Error: Swift.Error { + case cannotFindNodeGroup(_ name: String, _ clusterName: String) + } +} diff --git a/Sources/App/EKS/EKSProvider.swift b/Sources/App/EKS/EKSProvider.swift new file mode 100644 index 0000000..3414d08 --- /dev/null +++ b/Sources/App/EKS/EKSProvider.swift @@ -0,0 +1,14 @@ +import Foundation +import SotoEKS + +protocol EKSProvider { + func describeNodegroup(_ input: EKS.DescribeNodegroupRequest, logger: Logger) async throws + -> EKS.DescribeNodegroupResponse +} + +extension EKS: EKSProvider { + @inlinable + func describeNodegroup(_ input: DescribeNodegroupRequest, logger: Logger) async throws -> DescribeNodegroupResponse { + try await describeNodegroup(input, logger: logger, on: nil) + } +} diff --git a/Sources/App/HTTP/HTTPClient.swift b/Sources/App/HTTP/HTTPClient.swift new file mode 100644 index 0000000..7e7d5a7 --- /dev/null +++ b/Sources/App/HTTP/HTTPClient.swift @@ -0,0 +1,45 @@ +import AsyncHTTPClient +import Foundation +import Logging +import struct NIO.ByteBuffer + +public protocol HTTPClientRepresentable { + func terminateCloudFormationInvocation(_ url: String, event: E) async throws +} + +public enum HTTP { + public struct Client: HTTPClientRepresentable { + let provider: any HTTPClientProvider + let logger: Logger + let encoder: JSONEncoder + + public init(provider: any HTTPClientProvider, logger: Logger, encoder: JSONEncoder = JSONEncoder()) { + self.provider = provider + self.logger = logger + self.encoder = encoder + } + + public func terminateCloudFormationInvocation(_ url: String, event: E) async throws { + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Content-Type", value: "") + + var byteBuffer = ByteBuffer() + try encoder.encode(event, into: &byteBuffer) + request.body = .bytes(byteBuffer) + + let response = try await provider.execute(request, timeout: .seconds(30), logger: logger) + if response.status != .ok { + throw Error.failedWithResponse(response) + } + } + } +} + +extension HTTP.Client { + enum Error: Swift.Error { + case failedWithResponse(HTTPClientResponse) + } +} + + diff --git a/Sources/App/HTTP/HTTPClientProvider.swift b/Sources/App/HTTP/HTTPClientProvider.swift new file mode 100644 index 0000000..3d6ec23 --- /dev/null +++ b/Sources/App/HTTP/HTTPClientProvider.swift @@ -0,0 +1,10 @@ +import AsyncHTTPClient +import Foundation +import struct Logging.Logger +import struct NIO.TimeAmount + +public protocol HTTPClientProvider { + func execute(_ request: HTTPClientRequest, timeout: TimeAmount, logger: Logger?) async throws -> HTTPClientResponse +} + +extension HTTPClient: HTTPClientProvider {} diff --git a/Sources/App/Hulk.swift b/Sources/App/Hulk.swift new file mode 100644 index 0000000..32ca808 --- /dev/null +++ b/Sources/App/Hulk.swift @@ -0,0 +1,62 @@ +import Foundation +import Models +import SotoAutoScaling +import SotoEKS + +struct Hulk { + let asgClient: any ASGClientRepresentable + let eksClient: any EKSClientRepresentable + let logger: Logger + + func smash(_ clusterInfo: ClusterNodesTags) async throws { + logger.info("let's smash tags into ASGs with \(clusterInfo)") + + let asgNames = try await withThrowingTaskGroup( + of: (String, EKS.Nodegroup).self, + returning: [(String, [String])].self + ) { taskGroup in + clusterInfo.nodePools.forEach { nodePool in + taskGroup.addTask { + ( + nodePool.name, + try await eksClient.describeNodeGroup(name: nodePool.name, clusterName: clusterInfo.clusterName) + ) + } + + logger.debug("[TASKGROUP]: added task to fetch node info for \(nodePool.name)") + } + + logger.info("added \(clusterInfo.nodePools.count) tasks fetch nodes for \(clusterInfo.clusterName)") + + return try await taskGroup.reduce([(String, [String])]()) { finalResult, node in + logger.trace("extracting auto scaling groups for \(node.0) from \(node.1)") + + let groups = node.1.resources?.autoScalingGroups?.compactMap { $0.name } + + logger.debug("auto scaling groups for node \(node.0) 👉 \(String(describing: groups))") + + return groups.flatMap { finalResult + [(node.0, $0)] } ?? finalResult + } + } + + logger.info("fetched auto scaling groups from the cluster \(clusterInfo.clusterName): \n\(asgNames)") + + let tags = asgNames.reduce([AutoScaling.Tag]()) { finalResult, asg in + guard let nodePool = clusterInfo.nodePools[asg.0] else { return finalResult } + + let allTags = clusterInfo.commonTags + nodePool.tags + + logger.debug("tags to add to node \(nodePool.name): \(allTags)") + + return finalResult + allTags.flatMap { tag in + asg.1.map { AutoScaling.Tag(key: tag.name, resourceId: $0, value: tag.value) } + } + } + + logger.info("will smash tags \(tags)") + + try await asgClient.updateTags(tags) + + logger.info("smashed tags with \(clusterInfo)") + } +} diff --git a/Sources/App/LambdaResult.swift b/Sources/App/LambdaResult.swift new file mode 100644 index 0000000..4cd6111 --- /dev/null +++ b/Sources/App/LambdaResult.swift @@ -0,0 +1,4 @@ +import Foundation +import Models + +public typealias LambdaResult = Result diff --git a/Sources/CloudFormation/Codable/ClusterNodesTags+Codable.swift b/Sources/CloudFormation/Codable/ClusterNodesTags+Codable.swift new file mode 100644 index 0000000..cfbd931 --- /dev/null +++ b/Sources/CloudFormation/Codable/ClusterNodesTags+Codable.swift @@ -0,0 +1,28 @@ +import Foundation +import Models + +extension ClusterNodesTags: Codable { + enum CodingKeys: String, CodingKey { + case clusterName = "ClusterName" + case commonTags = "CommonTags" + case nodePools = "NodePools" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + clusterName: try container.decode(String.self, forKey: .clusterName), + commonTags: try container.decode([Tag].self, forKey: .commonTags), + nodePools: try container.decode([NodePool].self, forKey: .nodePools) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(clusterName, forKey: .clusterName) + try container.encode(commonTags, forKey: .commonTags) + try container.encode(nodePools, forKey: .nodePools) + } +} diff --git a/Sources/CloudFormation/Codable/NodePool+Codable.swift b/Sources/CloudFormation/Codable/NodePool+Codable.swift new file mode 100644 index 0000000..781faa9 --- /dev/null +++ b/Sources/CloudFormation/Codable/NodePool+Codable.swift @@ -0,0 +1,25 @@ +import Foundation +import Models + +extension NodePool: Codable { + enum CodingKeys: String, CodingKey { + case name = "Name" + case tags = "Tags" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + name: try container.decode(String.self, forKey: .name), + tags: try container.decode([Tag].self, forKey: .tags) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name, forKey: .name) + try container.encode(tags, forKey: .tags) + } +} diff --git a/Sources/CloudFormation/Codable/Tag+Codable.swift b/Sources/CloudFormation/Codable/Tag+Codable.swift new file mode 100644 index 0000000..9bc53b0 --- /dev/null +++ b/Sources/CloudFormation/Codable/Tag+Codable.swift @@ -0,0 +1,25 @@ +import Foundation +import Models + +extension Tag: Codable { + enum CodingKeys: String, CodingKey { + case name = "Name" + case value = "Value" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + name: try container.decode(String.self, forKey: .name), + value: try container.decode(String.self, forKey: .value) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name, forKey: .name) + try container.encode(value, forKey: .value) + } +} diff --git a/Sources/CloudFormation/run.swift b/Sources/CloudFormation/run.swift new file mode 100644 index 0000000..0f25c96 --- /dev/null +++ b/Sources/CloudFormation/run.swift @@ -0,0 +1,79 @@ +import App +import AsyncHTTPClient +import AWSLambdaEvents +import AWSLambdaRuntime +import Models +import NIO + +@main +struct CloudFormationHandler: LambdaHandler { + typealias Event = CloudFormation.Request + typealias Output = Void + + let app: Application + let httpClient: HTTP.Client + + init(context: LambdaInitializationContext) async throws { + self.app = Application(context: context) + + let asyncHTTPClient = HTTPClient(eventLoopGroupProvider: .shared(context.eventLoop)) + context.terminator.register(name: "HTTPClient", handler: asyncHTTPClient.shutdown) + + self.httpClient = HTTP.Client(provider: asyncHTTPClient, logger: context.logger) + } + + func handle(_ event: Event, context: LambdaContext) async throws -> Output { + guard let resourceProperties = event.resourceProperties else { + throw Error.missingResourceProperties + } + + let response = await app.run(with: resourceProperties, runContext: context).encode(for: event) + + try await httpClient.terminateCloudFormationInvocation(event.responseURL, event: response) + } +} + +extension LambdaResult { + func encode(for request: CloudFormation.Request) -> CloudFormation.Response { + switch self { + case .success: + return CloudFormation.Response( + status: .success, + requestId: request.requestId, + logicalResourceId: request.logicalResourceId, + stackId: request.stackId, + physicalResourceId: request.physicalResourceId, + reason: nil, + noEcho: nil, + data: nil + ) + case .failure(let error): + return CloudFormation.Response( + status: .failed, + requestId: request.requestId, + logicalResourceId: request.logicalResourceId, + stackId: request.stackId, + physicalResourceId: request.physicalResourceId, + reason: error.localizedDescription, + noEcho: nil, + data: nil + ) + } + } +} + +extension CloudFormationHandler { + enum Error: Swift.Error { + case missingResourceProperties + } +} + +extension HTTPClient { + func shutdown(eventLoop: EventLoop) -> EventLoopFuture { + let promise = eventLoop.makePromise(of: Void.self) + + promise.completeWithTask { try await self.shutdown() } + + return promise.futureResult + } +} diff --git a/Sources/Models/ClusterNodesTags.swift b/Sources/Models/ClusterNodesTags.swift new file mode 100644 index 0000000..ab52658 --- /dev/null +++ b/Sources/Models/ClusterNodesTags.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct ClusterNodesTags { + public let clusterName: String + public let commonTags: [Tag] + public let nodePools: [NodePool] + + public init(clusterName: String, commonTags: [Tag], nodePools: [NodePool]) { + self.clusterName = clusterName + self.commonTags = commonTags + self.nodePools = nodePools + } +} + +public extension Array where Element == NodePool { + subscript(nodePool: String) -> NodePool? { + first { $0.name == nodePool } + } +} diff --git a/Sources/Models/NodePool.swift b/Sources/Models/NodePool.swift new file mode 100644 index 0000000..21af839 --- /dev/null +++ b/Sources/Models/NodePool.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct NodePool { + public let name: String + public let tags: [Tag] + + public init(name: String, tags: [Tag]) { + self.name = name + self.tags = tags + } +} diff --git a/Sources/Models/Tag.swift b/Sources/Models/Tag.swift new file mode 100644 index 0000000..196bf0b --- /dev/null +++ b/Sources/Models/Tag.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct Tag { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } +} diff --git a/Sources/Run/main.swift b/Sources/Run/main.swift deleted file mode 100644 index 24325d0..0000000 --- a/Sources/Run/main.swift +++ /dev/null @@ -1,5 +0,0 @@ -import AWSLambdaRuntime - -Lambda.run { (_, name: String, callback: @escaping (Result) -> Void) in - callback(.success("Hello, \(name)")) -} diff --git a/Tests/LambdaTests/lambda.swift b/Tests/LambdaTests/lambda.swift deleted file mode 100644 index d1bd6d6..0000000 --- a/Tests/LambdaTests/lambda.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest -import class Foundation.Bundle - -final class LambdaTests: XCTestCase { - func testExample() throws { - XCTAssertTrue(true) - } -}