diff --git a/.swift-version b/.swift-version index 358e78e6..0df17dd0 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -6.1.0 \ No newline at end of file +6.2.1 \ No newline at end of file diff --git a/App/AutomaAppShared/Sources/AutomaAppShared/Screens/Debug/DebugMenu.swift b/App/AutomaAppShared/Sources/AutomaAppShared/Screens/Debug/DebugMenu.swift index 98f45c96..fba4f189 100644 --- a/App/AutomaAppShared/Sources/AutomaAppShared/Screens/Debug/DebugMenu.swift +++ b/App/AutomaAppShared/Sources/AutomaAppShared/Screens/Debug/DebugMenu.swift @@ -17,7 +17,7 @@ import SwiftUI /// Used to switch between different API environments internal enum BaseEnvironmentUrl: String, CaseIterable { /// Local development environment - case localhost = "http://localhost:8080" + case localhost = "http://localhost:6886" /// Production environment case production = "https://api-production.getautoma.app" /// Sandbox testing environment diff --git a/Backend/DevDockerfile b/Backend/DevDockerfile index d5c31edf..b6ed5f50 100644 --- a/Backend/DevDockerfile +++ b/Backend/DevDockerfile @@ -1,4 +1,4 @@ -FROM swift:6.1.0-jammy AS build +FROM swift:6.2.0-jammy AS build RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ && apt-get -q update \ diff --git a/Backend/Dockerfile b/Backend/Dockerfile index a2eeb6ed..0987eec2 100644 --- a/Backend/Dockerfile +++ b/Backend/Dockerfile @@ -1,7 +1,7 @@ # ================================ # Build image # ================================ -FROM swift:6.1.0-jammy AS build +FROM swift:6.2.0-jammy AS build # Install OS updates RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ diff --git a/Backend/OldDockerfile b/Backend/OldDockerfile new file mode 100644 index 00000000..39694fcc --- /dev/null +++ b/Backend/OldDockerfile @@ -0,0 +1,34 @@ +FROM swift:6.2.0-jammy AS build + +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ + && apt-get install bash \ + && apt-get install -y openssl libssl-dev libcurl4 libxml2 \ + && rm -r /var/lib/apt/lists/* + +WORKDIR /build + +RUN --mount=type=secret,id=GITHUB_SSH_AUTHENTICATION_TOKEN \ + TOKEN=$(cat /run/secrets/GITHUB_SSH_AUTHENTICATION_TOKEN) && \ + git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" + +COPY Package.swift . +COPY Package.resolved . + +COPY DataTypes ./DataTypes +RUN --mount=type=cache,target=/build/.build swift package resolve + +COPY . . + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Expose PrometheusService server port +EXPOSE 6834 + +CMD /bin/bash -c "sleep 100000" diff --git a/Backend/OlderDockerfile b/Backend/OlderDockerfile new file mode 100644 index 00000000..f81e0a66 --- /dev/null +++ b/Backend/OlderDockerfile @@ -0,0 +1,97 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.2.0-jammy AS build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev \ + && apt-get install -y openssl libssl-dev + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. + +RUN --mount=type=secret,id=GITHUB_SSH_AUTHENTICATION_TOKEN \ + TOKEN=$(cat /run/secrets/GITHUB_SSH_AUTHENTICATION_TOKEN) && \ + git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" + +COPY Package.swift . +COPY Package.resolved . + +COPY DataTypes ./DataTypes +RUN --mount=type=cache,target=/build/.build swift package resolve +# Copy entire repo into container +COPY . . + +# Build everything, with optimizations, with static linking, and using jemalloc +# N.B.: The static version of jemalloc is incompatible with the static Swift runtime. +# RUN --mount=type=cache,target=/build/.build swift build -c release \ +RUN --mount=type=cache,target=/build/.build swift build \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +# RUN --mount=type=cache,target=/build/.build cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ +RUN --mount=type=cache,target=/build/.build cp "$(swift build --package-path /build --show-bin-path)/App" ./ + +# Copy static swift backtracer binary to staging area +RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ + +# Copy resources bundled by SPM to staging area +# RUN --mount=type=cache,target=/build/.build find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; +RUN --mount=type=cache,target=/build/.build find -L "$(swift build --package-path /build --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resources from the public directory and views directory if the directories exist +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true + +# ================================ +# Run image +# ================================ +FROM ubuntu:jammy + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + libjemalloc2 \ + ca-certificates \ + tzdata \ + && apt-get install bash \ + && apt-get install -y openssl libssl-dev libcurl4 libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a vapor user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=vapor:vapor /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static + +# Ensure all further commands run as the vapor user +USER vapor:vapor + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Expose PrometheusService server port +EXPOSE 6834 + +CMD /bin/bash -c "eval './App $RUN_COMMAND'" diff --git a/Backend/Package.resolved b/Backend/Package.resolved index 1f991034..2cb07f95 100644 --- a/Backend/Package.resolved +++ b/Backend/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "70949b7001f2afbc09ce48fe8e74c79342a0ebb035c9f66150debe1305d29cd3", + "originHash" : "da402b8112bb9effe58819a094f653dad53d0e1dc5c9038d949d2b991c47a36e", "pins" : [ { "identity" : "alamofire", @@ -100,6 +100,42 @@ "version" : "0.25.0" } }, + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "8bc237c565deb14cd2ffda0b23759bdcdaa244b7", + "version" : "2.2.0" + } + }, + { + "identity" : "grpc-swift-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-extras.git", + "state" : { + "revision" : "f6fc3a28a896cb5e01365758c717a3d56c16a2b2", + "version" : "2.1.0" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "11d1f0917aa30ca50e2c78d216e8ae8c72f42af1", + "version" : "2.3.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "c008d356d9e9c2255602711888bf25b542a30919", + "version" : "2.1.1" + } + }, { "identity" : "jmespath.swift", "kind" : "remoteSourceControl", @@ -199,15 +235,6 @@ "version" : "1.29.0" } }, - { - "identity" : "queues", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/queues.git", - "state" : { - "revision" : "f1764d5fef7e8ede8f3585deb63ece01ea25c2db", - "version" : "1.17.2" - } - }, { "identity" : "routing-kit", "kind" : "remoteSourceControl", @@ -298,6 +325,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "6ffef195ed4ba98ee98029970c94db7edc60d4c6", + "version" : "1.0.1" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", @@ -424,6 +460,15 @@ "version" : "1.8.3" } }, + { + "identity" : "swift-otel-semantic-conventions", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-otel/swift-otel-semantic-conventions.git", + "state" : { + "revision" : "69185e48088c4abdd438b74d332d103431849f63", + "version" : "1.38.0" + } + }, { "identity" : "swift-prometheus", "kind" : "remoteSourceControl", @@ -478,6 +523,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -487,6 +541,15 @@ "version" : "1.6.3" } }, + { + "identity" : "swift-temporal-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-temporal-sdk.git", + "state" : { + "revision" : "55a222f02f279333489c7a1657195d7489a92cf2", + "version" : "0.6.0" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", @@ -514,15 +577,6 @@ "version" : "4.117.0" } }, - { - "identity" : "vapor-queues-fluent-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", - "state" : { - "revision" : "e2ce6775850bdbe277cb2e5792d05eff42434f52", - "version" : "3.0.0-beta1" - } - }, { "identity" : "websocket-kit", "kind" : "remoteSourceControl", diff --git a/Backend/Package.swift b/Backend/Package.swift index dab30892..bd3e9e2b 100644 --- a/Backend/Package.swift +++ b/Backend/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 import PackageDescription /// Initializes Package @@ -24,16 +24,13 @@ public let package = Package( .package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.5"), .package(url: "https://github.com/soto-project/soto.git", from: "7.3.0"), .package(url: "https://github.com/swift-server/swift-prometheus.git", from: "2.0.0"), - .package( - url: "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", - from: "3.0.0-beta1" - ), .package(url: "https://github.com/nmdias/FeedKit.git", from: "10.0.0-rc.3"), .package(url: "https://github.com/GetAutomaApp/swift-retry.git", branch: "main"), .package(url: "https://github.com/GetAutomaApp/Fakery", branch: "master"), .package(url: "https://github.com/GetAutomaApp/AutomaUtilities", branch: "main"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.11.1"), .package(url: "https://github.com/lovetodream/swift-log-loki.git", branch: "main"), + .package(url: "https://github.com/apple/swift-temporal-sdk.git", from: "0.6.0") ], targets: [ .executableTarget( @@ -52,13 +49,13 @@ public let package = Package( .product(name: "SotoS3", package: "soto"), .product(name: "SotoSNS", package: "soto"), .product(name: "Prometheus", package: "swift-prometheus"), - .product(name: "QueuesFluentDriver", package: "vapor-queues-fluent-driver"), .product(name: "SotoTextract", package: "soto"), .product(name: "FeedKit", package: "FeedKit"), .product(name: "DMRetry", package: "swift-retry"), .product(name: "AutomaUtilities", package: "AutomaUtilities"), "SwiftSoup", .product(name: "LoggingLoki", package: "swift-log-loki"), + .product(name: "Temporal", package: "swift-temporal-sdk"), ], exclude: [ "Documentation.md", diff --git a/Backend/README.md b/Backend/README.md index 351eafa9..62040457 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -1,2 +1,9 @@ 1. Create `./infra/docker-secrets/` directory with the following files and secrets: 1. "GITHUB_SSH_AUTHENTICATION_TOKEN": A Github fine-grained token that allows cloning AutomaUtilities repository (private) + +TODOS: +- [ ] CURRENT BRANCH: deploy temporal and temporal worker on fly.io. Delete vapor-queues worker fly.io config and apps in organizations +- [ ] Fix profile picture image generation not working (current problem: OpenAI platform billing hard limit reached) +- [ ] FUTURE: Fix swift protobuf package warnings (find upstream package, make a pull request to swift protobuf to support latest + swift version - fix warnings) and update upstream package `Package.swift` to use + latest protobuf version. diff --git a/Backend/Sources/App/Commands/TemporalWorkerCommand.swift b/Backend/Sources/App/Commands/TemporalWorkerCommand.swift new file mode 100644 index 00000000..24c670a4 --- /dev/null +++ b/Backend/Sources/App/Commands/TemporalWorkerCommand.swift @@ -0,0 +1,87 @@ +// TemporalWorkerCommand.swift +// Copyright (c) 2026 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Temporal +import Vapor + +struct TemporalWorkerCommand: AsyncCommand { + struct Signature: CommandSignature {} + + var help: String { + "startup temporal worker" + } + + func run(using context: CommandContext, signature _: Signature) async throws { + try await AppConfigurator( + app: context.application, + config: .init( + shouldSetupAPI: false, + metricsPort: 6_835 + ) + ).configure() + try await setupTemporal(logger: context.application.logger) + } + + private func setupTemporal(logger: Logger) async throws { + logSetupTemporalWorkerStarted(logger) + let worker = try getTemporalWorker( + hostname: TemporalClient.getServerHostnameFromEnv() + ) + logSetupTemporalWorkerConfigured(logger) + do { + try await worker.run() + } catch { + // Capture the current stack trace + let stackTrace = Thread.callStackSymbols.joined(separator: "\n") + + logger.info( + "Error occurred while running tmeporal worker", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "error": .string(error.localizedDescription), + "stackTrace": .string(stackTrace), + ] + ) + + throw error + } + } + + private func logSetupTemporalWorkerStarted(_ logger: Logger) { + logger.info( + "Setting up temporal worker started.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } + + private func getTemporalWorker(hostname temporalServerHostname: String) throws -> TemporalWorker { + try TemporalWorker( + configuration: .init( + namespace: "default", + taskQueue: "default-queue", + instrumentation: .init(serverHostname: temporalServerHostname) + ), + target: .dns(host: temporalServerHostname, port: 7_233), + transportSecurity: .plaintext, + activities: [ + ProfilePictureActivities().activities.createPicture, + TransactionalMessageActivities().activities.sendMessage + ], + workflows: [CreateProfilePictureWorkflow.self, SendTransactionalMessageWorkflow.self], + logger: Logger(label: "temporal-worker") + ) + } + + private func logSetupTemporalWorkerConfigured(_ logger: Logger) { + logger.info( + "Configured temporal worker instance.", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + ] + ) + } +} diff --git a/Backend/Sources/App/Controllers/AuthenticationController/AuthenticationController.swift b/Backend/Sources/App/Controllers/AuthenticationController/AuthenticationController.swift index 0e351da2..533ac1a3 100644 --- a/Backend/Sources/App/Controllers/AuthenticationController/AuthenticationController.swift +++ b/Backend/Sources/App/Controllers/AuthenticationController/AuthenticationController.swift @@ -5,6 +5,7 @@ import DataTypes import Fluent +import Temporal import Vapor /// Controller for handling authentication-related routes. @@ -47,9 +48,47 @@ internal struct AuthenticationController: RouteCollection { throw GenericErrors.userAlreadyExists } - return try await authService.sendAuthCode( - phoneNumber: dto.phoneNumber, queue: req.queue - ) + return try await useTemporalClient(request: req) { temporalClient in + try await authService.sendAuthCode( + phoneNumber: dto.phoneNumber, + temporalClient: temporalClient + ) + } + } + + private func useTemporalClient( + request: Request, + _ callback: @escaping (TemporalClient) async throws -> T + ) async throws -> T { + let temporalClient = request.temporalClient + + // Start the Temporal worker in the background + let workerTask = Task { + do { + try await temporalClient.run() + } catch { + request.logger.error("Temporal worker failed: \(error)") + throw error + } + } + + // Give the worker a moment to start + try await Task.sleep(for: .seconds(2)) + + var result: T + do { + // Execute the callback with the client + result = try await callback(temporalClient) + + // Give any workflows a moment to start + try await Task.sleep(for: .seconds(1)) + + } catch { + request.logger.error("Error in Temporal operation: \(error)") + throw error + } + workerTask.cancel() + return result } /// Registers a new user with the provided phone number and code. @@ -69,7 +108,12 @@ internal struct AuthenticationController: RouteCollection { throw GenericErrors.userAlreadyExists } - return try await authService.register(.init(authCodePayload: dto, signer: req.jwt), queue: req.queue) + return try await useTemporalClient(request: req) { temporalClient in + try await authService.register( + .init(authCodePayload: dto, signer: req.jwt), + temporalClient: temporalClient + ) + } } /// Sends a login code to the user's phone number. @@ -90,9 +134,11 @@ internal struct AuthenticationController: RouteCollection { throw GenericErrors.userNotFound } - return try await authService.sendAuthCode( - phoneNumber: dto.phoneNumber, queue: req.queue - ) + return try await useTemporalClient(request: req) { temporalClient in + try await authService.sendAuthCode( + phoneNumber: dto.phoneNumber, temporalClient: temporalClient + ) + } } /// Logs in a user with the provided phone number and code. diff --git a/Backend/Sources/App/Extensions/Temporal/TemporalClientExtensions.swift b/Backend/Sources/App/Extensions/Temporal/TemporalClientExtensions.swift new file mode 100644 index 00000000..0c690f39 --- /dev/null +++ b/Backend/Sources/App/Extensions/Temporal/TemporalClientExtensions.swift @@ -0,0 +1,13 @@ +// TemporalClientExtensions.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Temporal +import Vapor + +internal extension TemporalClient { + static func getServerHostnameFromEnv() throws -> String { + try Environment.getOrThrow("TEMPORAL_SERVER_HOSTNAME") + } +} diff --git a/Backend/Sources/App/Extensions/Vapor/ApplicationExtensions.swift b/Backend/Sources/App/Extensions/Vapor/ApplicationExtensions.swift new file mode 100644 index 00000000..efbc00af --- /dev/null +++ b/Backend/Sources/App/Extensions/Vapor/ApplicationExtensions.swift @@ -0,0 +1,22 @@ +// ApplicationExtensions.swift +// Copyright (c) 2026 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Temporal +import Vapor + +internal extension Application { + var temporalClient: TemporalClient { + let hostname = try! TemporalClient.getServerHostnameFromEnv() + return try! TemporalClient( + target: .dns( + host: hostname, + port: 7_233 + ), + transportSecurity: .plaintext, + configuration: .init(instrumentation: .init(serverHostname: hostname)), + logger: Logger(label: "temporal-client") + ) + } +} diff --git a/Backend/Sources/App/Extensions/Vapor/EnvironmentExtensions.swift b/Backend/Sources/App/Extensions/Vapor/EnvironmentExtensions.swift deleted file mode 100644 index 6006e470..00000000 --- a/Backend/Sources/App/Extensions/Vapor/EnvironmentExtensions.swift +++ /dev/null @@ -1,28 +0,0 @@ -// EnvironmentExtensions.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Vapor - -internal extension Environment { - enum AppMode { - case http - case queue(name: String) - } - - var appMode: AppMode { - if - CommandLine.arguments.contains("queues") || - CommandLine.arguments.contains("vapor-queues") - { - guard let name = CommandLine.arguments.firstIndex(of: "--queue") else { - return .queue(name: "default") - } - - return .queue(name: CommandLine.arguments[name + 1]) - } else { - return .http - } - } -} diff --git a/Backend/Sources/App/Extensions/Vapor/RequestExtensions.swift b/Backend/Sources/App/Extensions/Vapor/RequestExtensions.swift index a1054fb3..43658898 100644 --- a/Backend/Sources/App/Extensions/Vapor/RequestExtensions.swift +++ b/Backend/Sources/App/Extensions/Vapor/RequestExtensions.swift @@ -4,6 +4,7 @@ // All rights reserved. import Fluent +import Temporal import Vapor /// Extension on rquest to add DB aliases @@ -18,3 +19,9 @@ internal extension Request { db(.readOnly) } } + +internal extension Request { + var temporalClient: TemporalClient { + application.temporalClient + } +} diff --git a/Backend/Sources/App/Models/UserModel.swift b/Backend/Sources/App/Models/UserModel.swift index f19ab0cf..90b3a0d6 100644 --- a/Backend/Sources/App/Models/UserModel.swift +++ b/Backend/Sources/App/Models/UserModel.swift @@ -86,25 +86,10 @@ public final class UserModel: Model, @unchecked Sendable { /// Converts the model to a `UserDTO`. /// - Returns: An instance of `UserDTO`. public func toDTO(logger: Logger) throws -> UserDTO { - let profilePictureUrl: String? - do { - guard - let profilePictureKey - else { - let userId = try requireID().uuidString - logger.error( - "Profile picture key is nil, could not convert user model to DTO.", - metadata: [ - "to": .string("\(String(describing: Self.self)).\(#function)"), - "user_id": .string(userId), - ] - ) - throw URLError(.badURL) - } - - profilePictureUrl = try TigrisService(logger: logger).getTigrisUrl(profilePictureKey) - } catch { - profilePictureUrl = nil + let profilePictureUrl: String? = if let profilePictureKey { + try TigrisService(logger: logger).getTigrisUrl(profilePictureKey) + } else { + nil } return .init( diff --git a/Backend/Sources/App/Procs/Jobs/ProfilePictureAsyncJob/ProfilePictureAsyncJob.swift b/Backend/Sources/App/Procs/Jobs/ProfilePictureAsyncJob/ProfilePictureAsyncJob.swift deleted file mode 100644 index 83e0fea4..00000000 --- a/Backend/Sources/App/Procs/Jobs/ProfilePictureAsyncJob/ProfilePictureAsyncJob.swift +++ /dev/null @@ -1,72 +0,0 @@ -// ProfilePictureAsyncJob.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import DataTypes -import Foundation -import Queues -import Vapor - -/// Input payload for the profile picture job. -internal struct ProfilePictureJobInput: Codable { - /// The user data transfer object. - public let payload: UserDTO -} - -/// Asynchronous job to create a profile picture. -internal struct ProfilePictureAsyncJob: AsyncJob { - public typealias Payload = ProfilePictureJobInput - - /// Processes the job to create a profile picture. - /// - Parameters: - /// - context: The queue context. - /// - payload: The input payload containing user data. - /// - Throws: Throws an error if the profile picture creation fails. - public func dequeue(_ context: QueueContext, _ payload: ProfilePictureJobInput) async throws { - let logger = context.logger - let profilePictureService = ProfilePictureService(logger: context.logger) - - // Create a profile picture for the user - let profilePictureKey = try await profilePictureService.createProfilePicture( - for: payload.payload - ) - - // Log the successful creation of the profile picture - logger.info( - "Profile picture created", - metadata: [ - "to": .string("ProfilePictureAsyncJob.dequeue"), - "userId": .string(payload.payload.id?.uuidString ?? ""), - "username": .string(payload.payload.username), - "profilePictureKey": .string(profilePictureKey), - ] - ) - } - - /// Handles errors that occur during the job processing. - /// - Parameters: - /// - context: The queue context. - /// - error: The error that occurred. - /// - payload: The input payload containing user data. - /// - Throws: Throws an error if logging fails. - public func error(_ context: QueueContext, _ error: any Error, _ payload: ProfilePictureJobInput) throws { - let logger = context.logger - - // Capture the current stack trace - let stackTrace = Thread.callStackSymbols.joined(separator: "\n") - - // Log the error details - logger.error( - "Failed to create profile picture", - metadata: [ - "to": .string("ProfilePictureAsyncJob.error"), - "userId": .string(payload.payload.id?.uuidString ?? ""), - "username": .string(payload.payload.username), - "error": .string(error.localizedDescription), - "debugInfo": .string("\(error)"), - "stackTrace": .string(stackTrace), - ] - ) - } -} diff --git a/Backend/Sources/App/Procs/Jobs/TransactionalMessageAsyncJob/TransactionalMessageAsyncJob.swift b/Backend/Sources/App/Procs/Jobs/TransactionalMessageAsyncJob/TransactionalMessageAsyncJob.swift deleted file mode 100644 index b14ea9ea..00000000 --- a/Backend/Sources/App/Procs/Jobs/TransactionalMessageAsyncJob/TransactionalMessageAsyncJob.swift +++ /dev/null @@ -1,56 +0,0 @@ -// TransactionalMessageAsyncJob.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Foundation -import Queues -import Vapor - -/// Input payload for the transactional message job. -internal struct TransactionalMessageJobInput: Codable { - /// The content of the message. - public let content: String - /// The phone number to send the message to. - public let toPhoneNumber: String -} - -/// Asynchronous job to send a transactional message. -internal struct TransactionalMessageAsyncJob: AsyncJob { - public typealias Payload = TransactionalMessageJobInput - - /// Processes the job to send a transactional message. - /// - Parameters: - /// - context: The queue context. - /// - payload: The input payload containing message data. - /// - Throws: Throws an error if the message sending fails. - public func dequeue(_ context: QueueContext, _ payload: TransactionalMessageJobInput) async throws { - let snsService = try SNSService() - - // Send the SMS message - _ = try await snsService - .sendSmS( - to: payload.toPhoneNumber, - message: payload.content, - logger: context.logger - ) - } - - /// Handles errors that occur during the job processing. - /// - Parameters: - /// - context: The queue context. - /// - error: The error that occurred. - /// - payload: The input payload containing message data. - /// - Throws: Throws an error if logging fails. - public func error(_ context: QueueContext, _ error: Error, _ payload: TransactionalMessageJobInput) throws { - // Log the error details - context.logger.info( - "Error occurred while processing job", - metadata: [ - "to": .string("TransactionalMessageAsyncJob.dequeue"), - "error": .string(error.localizedDescription), - "payload": .string("\(payload.content)"), - ] - ) - } -} diff --git a/Backend/Sources/App/Procs/Temporal/Activites/ProfilePictureActivities.swift b/Backend/Sources/App/Procs/Temporal/Activites/ProfilePictureActivities.swift new file mode 100644 index 00000000..70ecda8d --- /dev/null +++ b/Backend/Sources/App/Procs/Temporal/Activites/ProfilePictureActivities.swift @@ -0,0 +1,60 @@ +// ProfilePictureActivities.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import DataTypes +import Foundation +import Temporal +import Vapor + +internal struct CreateProfilePictureActivityInput: Sendable, Codable { + let payload: UserDTO +} + +/// Temporal activity for user profile picture creation. +@ActivityContainer +internal struct ProfilePictureActivities { + /// Create + /// - Parameters: + /// - input: The input for creating a profile picture. + /// - Throws: Throws an error if the profile picture creation fails. + @Sendable @Activity + public func createPicture(input: CreateProfilePictureActivityInput) async throws { + let payload = input.payload + let logger = Logger(label: "temporal") + let profilePictureService = ProfilePictureService(logger: logger) + + do { + // Create a profile picture for the user + let profilePictureKey = try await profilePictureService.createProfilePicture( + for: payload + ) + // Log the successful creation of the profile picture + logger.info( + "Profile picture created", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "userId": .string(payload.id?.uuidString ?? ""), + "username": .string(payload.username), + "profilePictureKey": .string(profilePictureKey), + ] + ) + } catch { + // Capture the current stack trace + let stackTrace = Thread.callStackSymbols.joined(separator: "\n") + + // Log the error details + logger.error( + "Failed to create profile picture", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "userId": .string(payload.id?.uuidString ?? ""), + "username": .string(payload.username), + "error": .string(error.localizedDescription), + "stackTrace": .string(stackTrace), + ] + ) + } + } +} diff --git a/Backend/Sources/App/Procs/Temporal/Activites/TransactionalMessageActivities.swift b/Backend/Sources/App/Procs/Temporal/Activites/TransactionalMessageActivities.swift new file mode 100644 index 00000000..9a100d05 --- /dev/null +++ b/Backend/Sources/App/Procs/Temporal/Activites/TransactionalMessageActivities.swift @@ -0,0 +1,53 @@ +// TransactionalMessageActivities.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Foundation +import Temporal +import Vapor + +/// Input payload for send transactional message activity. +internal struct SendTransactionalMessageActivityInput: Sendable, Codable { + /// The content of the message. + public let content: String + /// The phone number to send the message to. + public let toPhoneNumber: String +} + +/// Activites for sending a transactional message. +@ActivityContainer +internal struct TransactionalMessageActivities { + /// Activity for sending a transactional message. + /// - Parameters: + /// - input: The input for sending a transactional message. + /// - Throws: Throws an error if the message sending fails. + @Sendable @Activity + public func sendMessage(input: SendTransactionalMessageActivityInput) async throws { + let logger = Logger(label: "temporal") + let snsService = try SNSService() + + // Send the SMS message + do { + _ = try await snsService + .sendSmS( + to: input.toPhoneNumber, + message: input.content, + logger: logger + ) + } catch { + // Capture the current stack trace + let stackTrace = Thread.callStackSymbols.joined(separator: "\n") + + logger.info( + "Error occurred while processing activity", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "error": .string(error.localizedDescription), + "input": .string("\(input.content)"), + "stackTrace": .string(stackTrace), + ] + ) + } + } +} diff --git a/Backend/Sources/App/Procs/Temporal/Workflows/CreateProfilePictureWorkflow.swift b/Backend/Sources/App/Procs/Temporal/Workflows/CreateProfilePictureWorkflow.swift new file mode 100644 index 00000000..1ea25161 --- /dev/null +++ b/Backend/Sources/App/Procs/Temporal/Workflows/CreateProfilePictureWorkflow.swift @@ -0,0 +1,18 @@ +// CreateProfilePictureWorkflow.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Temporal +import Vapor + +@Workflow +internal final class CreateProfilePictureWorkflow { + func run(input: CreateProfilePictureActivityInput) async throws { + try await Workflow.executeActivity( + ProfilePictureActivities.Activities.CreatePicture.self, + options: ActivityOptions(startToCloseTimeout: .seconds(30)), + input: input + ) + } +} diff --git a/Backend/Sources/App/Procs/Temporal/Workflows/SendTransactionalMessageWorkflow.swift b/Backend/Sources/App/Procs/Temporal/Workflows/SendTransactionalMessageWorkflow.swift new file mode 100644 index 00000000..31c7e2cc --- /dev/null +++ b/Backend/Sources/App/Procs/Temporal/Workflows/SendTransactionalMessageWorkflow.swift @@ -0,0 +1,34 @@ +// SendTransactionalMessageWorkflow.swift +// Copyright (c) 2025 GetAutomaApp +// All source code and related assets are the property of GetAutomaApp. +// All rights reserved. + +import Temporal +import Vapor + +@Workflow +internal final class SendTransactionalMessageWorkflow { + func run(input: SendTransactionalMessageActivityInput) async throws { + do { + try await Workflow.executeActivity( + TransactionalMessageActivities.Activities.SendMessage.self, + options: ActivityOptions(startToCloseTimeout: .seconds(30)), + input: input + ) + } catch { + // Capture the current stack trace + let stackTrace = Thread.callStackSymbols.joined(separator: "\n") + + let logger = Logger(label: "send-transactional-message-workflow") + logger.info( + "Error occurred while processing workflow", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "error": .string(error.localizedDescription), + "input": .string("\(input.content)"), + "stackTrace": .string(stackTrace), + ] + ) + } + } +} diff --git a/Backend/Sources/App/Services/AuthenticationService/AuthenticationService.swift b/Backend/Sources/App/Services/AuthenticationService/AuthenticationService.swift index 0cd29453..13c16528 100644 --- a/Backend/Sources/App/Services/AuthenticationService/AuthenticationService.swift +++ b/Backend/Sources/App/Services/AuthenticationService/AuthenticationService.swift @@ -7,7 +7,7 @@ import AutomaUtilities import DataTypes import Fluent import JWT -import Queues +import Temporal import Vapor internal struct RootAuthenticationService: AuthenticationService { @@ -24,13 +24,13 @@ internal struct RootAuthenticationService: AuthenticationService { /// Register a user public func register( _ payload: UserRegistrationPayload, - queue: Queue + temporalClient: TemporalClient ) async throws -> AuthenticationTokensPayloadDTO { var registrator = UserRegistrationService(.init( writeDb: config.writeDb, readDb: config.readDb, logger: config.logger, - queue: queue, + temporalClient: temporalClient, payload: payload )) return try await registrator.register() @@ -48,7 +48,9 @@ internal struct RootAuthenticationService: AuthenticationService { } /// Send authentication code - public func sendAuthCode(phoneNumber: String, queue: Queue) async throws -> AuthenticationCodeResponseDTO { + public func sendAuthCode(phoneNumber: String, + temporalClient: TemporalClient) async throws -> AuthenticationCodeResponseDTO + { let code = RandomService.randomCode() logAuthCodeAttempt(phoneNumber: phoneNumber, code: code) @@ -60,9 +62,9 @@ internal struct RootAuthenticationService: AuthenticationService { return try await sendOrHandleAuthCode( phoneNumber: phoneNumber, - queue: queue, code: code, - codeModelId: codeModelId + codeModelId: codeModelId, + temporalClient: temporalClient ) } @@ -145,9 +147,9 @@ internal struct RootAuthenticationService: AuthenticationService { private func sendOrHandleAuthCode( phoneNumber: String, - queue: Queue, code: String, - codeModelId: UUID + codeModelId: UUID, + temporalClient: TemporalClient, ) async throws -> AuthenticationCodeResponseDTO { do { return try await helper.sendAuthCode( @@ -156,7 +158,7 @@ internal struct RootAuthenticationService: AuthenticationService { phoneNumber: phoneNumber, codeModelId: codeModelId ), - queue: queue + temporalClient: temporalClient ) } catch let error as GenericErrors { await helper.sendTelemetryDataOnAuthCodeSendFail( diff --git a/Backend/Sources/App/Services/AuthenticationService/AuthenticationServiceHelper.swift b/Backend/Sources/App/Services/AuthenticationService/AuthenticationServiceHelper.swift index 571a9338..a4d2a20e 100644 --- a/Backend/Sources/App/Services/AuthenticationService/AuthenticationServiceHelper.swift +++ b/Backend/Sources/App/Services/AuthenticationService/AuthenticationServiceHelper.swift @@ -7,7 +7,7 @@ import AutomaUtilities import DataTypes import Fluent import JWT -import Queues +import Temporal import Vapor internal actor AuthenticationServiceHelper { @@ -70,13 +70,14 @@ internal actor AuthenticationServiceHelper { } /// Sends a verification code to the given phone number and returns the result. - public func sendAuthCode(_ payload: SendAuthCodePayload, queue: Queue) async throws -> AuthenticationCodeResponseDTO + public func sendAuthCode(_ payload: SendAuthCodePayload, + temporalClient: TemporalClient) async throws -> AuthenticationCodeResponseDTO { try await AuthCodeSender(.init( writeDb: config.writeDb, readDb: config.writeDb, logger: config.logger, - queue: queue, + temporalClient: temporalClient, payload: payload )).send() } @@ -102,7 +103,7 @@ internal actor AuthenticationServiceHelper { } private func getRateLimitString() throws -> String { - try Environment.getOrThrow("AUTHENTICATION_CODE_RATE_LIMIT") + try Environment.getOrThrow("AUTHENTICATION_CODE_DISTANCE") } private func castCodeRateLimitToNumber(_ rateLimitString: String) throws -> Double { @@ -570,16 +571,36 @@ internal struct AuthCodeSender { } private func startSendCodeJob() async throws { - try await config.queue.dispatch( - TransactionalMessageAsyncJob.self, - .init( - content: MessageFormatterService - .craftVerificationCodeMessage( - code: config.payload.code - ), - toPhoneNumber: config.payload.phoneNumber + do { + _ = try await config.temporalClient.startWorkflow( + type: SendTransactionalMessageWorkflow.self, + options: .init( + id: "send-transactional-message-\(config.payload.codeModelId)", + taskQueue: "default-queue" + ), + input: .init( + content: MessageFormatterService + .craftVerificationCodeMessage( + code: config.payload.code + ), + toPhoneNumber: config.payload.phoneNumber + ) ) - ) + } catch { + // Capture the current stack trace + let stackTrace = Thread.callStackSymbols.joined(separator: "\n") + + config.logger.info( + "Error occurred while starting workflow to send authentication code", + metadata: [ + "to": .string("\(String(describing: Self.self)).\(#function)"), + "error": .string(error.localizedDescription), + "stackTrace": .string(stackTrace), + ] + ) + + throw error + } } private func getCodeDeletionTime() -> Date { @@ -594,8 +615,8 @@ internal struct AuthCodeSenderConfig: AuthenticationServiceConfig { public let readDb: Database /// Logger public let logger: Logger - /// Queue to submit token to - public let queue: Queue + // Temporal client to execute workflows + public let temporalClient: TemporalClient /// Payload to submit public let payload: SendAuthCodePayload } diff --git a/Backend/Sources/App/Services/AuthenticationService/UserRegistrationService.swift b/Backend/Sources/App/Services/AuthenticationService/UserRegistrationService.swift index e0a44ece..d41ede18 100644 --- a/Backend/Sources/App/Services/AuthenticationService/UserRegistrationService.swift +++ b/Backend/Sources/App/Services/AuthenticationService/UserRegistrationService.swift @@ -7,7 +7,7 @@ import AutomaUtilities import DataTypes import Fluent import JWT -import Queues +import Temporal import Vapor internal struct UserRegistrationService: AuthenticationService { @@ -70,7 +70,15 @@ internal struct UserRegistrationService: AuthenticationService { let userDTO = try user.toDTO(logger: config.logger) let profilePictureKey = try ProfilePictureService(logger: config.logger).generateImageKey(for: userDTO) - try await config.queue.dispatch(ProfilePictureAsyncJob.self, .init(payload: userDTO)) + let temporalClient = config.temporalClient + _ = try await temporalClient.startWorkflow( + type: CreateProfilePictureWorkflow.self, + options: .init( + id: "create-user-profile-pic-\(userDTO.id?.uuidString ?? userDTO.username)-\(Date())", + taskQueue: "default-queue" + ), + input: .init(payload: userDTO) + ) user.profilePictureKey = profilePictureKey @@ -99,8 +107,8 @@ internal struct UserRegistrationConfig: AuthenticationServiceConfig { public let readDb: Database /// Logger public let logger: Logger - /// Queue to submit messages & generation stuff to - public let queue: Queue + // Temporal client to execute workflows + public let temporalClient: TemporalClient /// Payload to register user public let payload: UserRegistrationPayload } diff --git a/Backend/Sources/App/configure.swift b/Backend/Sources/App/configure.swift index 3a6818f1..3d7e3d55 100644 --- a/Backend/Sources/App/configure.swift +++ b/Backend/Sources/App/configure.swift @@ -7,29 +7,41 @@ import AutomaUtilities import Fluent import FluentPostgresDriver import JWT -import Queues -import QueuesFluentDriver import Vapor internal func configure(_ app: Application) async throws { - try await AppConfigurator(app: app).configure() + registerCommands(app: app) + let shouldSetupAPI = !["temporal-worker"].contains(CommandLine.arguments.last) + if shouldSetupAPI { + try await AppConfigurator( + app: app, + config: .init( + shouldSetupAPI: true + ) + ).configure() + } +} + +internal func registerCommands(app: Application) { + app.asyncCommands.use(TemporalWorkerCommand(), as: "temporal-worker") } internal struct AppConfigurator { private let app: Application + private let config: AppConfiguratorConfig private let environment = Environment.get("ENVIRONMENT") ?? "local" private let primaryDatabaseURL: String? = try? DatabaseURLs.primary.get() private let regionalDatabaseURL: String? = try? DatabaseURLs.regional.get() /// Initializes Application - public init(app: Application) { + public init(app: Application, config: AppConfiguratorConfig) { self.app = app + self.config = config } /// Configures the entire application public func configure() async throws { registerMiddleware() - registerQueues() let hasDatabaseURLs = (primaryDatabaseURL != nil) || (regionalDatabaseURL != nil) if hasDatabaseURLs { try await configureWhenDatabaseURLsAvailable() @@ -40,20 +52,16 @@ internal struct AppConfigurator { app.middleware.use(ErrorStringMiddleware()) } - private func registerQueues() { - if ["local", "testing"].contains(environment) == false { - app.asyncCommands.use(QueuesCommand(application: app), as: "vapor-queues") - } - } - private func configureWhenDatabaseURLsAvailable() async throws { try await DatabaseConfigurator(app: app).configureDatabases() - try registerControllers() try await startPrometheusService() - try await addAuthenticationJWTKey() - configureQueues() - addJobsToQueue() - configureServer() + + app.logger.info("Should setup API: \(config.shouldSetupAPI). Environment: \(environment)") + if config.shouldSetupAPI { + try registerControllers() + try await addAuthenticationJWTKey() + configureServer() + } } private func registerControllers() throws { @@ -64,17 +72,7 @@ internal struct AppConfigurator { } private func startPrometheusService() async throws { - if environment != "local" { - try await PrometheusService().startServer() - return - } - - switch app.environment.appMode { - case .queue: - try await PrometheusService().startServer(port: 6_835) - default: - try await PrometheusService().startServer() - } + try await PrometheusService().startServer(port: config.metricsPort) } private func addAuthenticationJWTKey() async throws { @@ -82,19 +80,18 @@ internal struct AppConfigurator { await app.jwt.keys.add(hmac: .init(stringLiteral: encryptionSecret), digestAlgorithm: .sha256) } - private func configureQueues() { - app.queues.use(.fluent(useSoftDeletes: true)) - app.queues.configuration.workerCount = 1 - app.queues.configuration.refreshInterval = .seconds(5) + private func configureServer() { + app.http.server.configuration.responseCompression = .enabled } - private func addJobsToQueue() { - app.queues.add(TransactionalMessageAsyncJob()) - app.queues.add(ProfilePictureAsyncJob()) - } + public struct AppConfiguratorConfig { + let shouldSetupAPI: Bool + let metricsPort: UInt16 - private func configureServer() { - app.http.server.configuration.responseCompression = .enabled + init(shouldSetupAPI: Bool = true, metricsPort: UInt16 = 6_834) { + self.shouldSetupAPI = shouldSetupAPI + self.metricsPort = metricsPort + } } } @@ -133,7 +130,6 @@ internal struct DatabaseConfigurator { app.migrations.add(JWTTokenShouldBeBoundToParentUserObjectMigration1735140054()) app.migrations.add(UserProfileAddProfilePictureMigration1735216565()) app.migrations.add(UserProfileConvertIdToImageKeyMigration1735294202()) - app.migrations.add(JobMetadataMigrate()) app.migrations.add(RemoveUserStorageMigration1739456565()) app.migrations.add(AddAcceptedColumnMigration1740658649()) app.migrations.add(TwitterOAuthTokenMigration1741687313()) diff --git a/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobIntegrationTests.swift b/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobIntegrationTests.swift deleted file mode 100644 index a52a8864..00000000 --- a/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobIntegrationTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -// ProfilePictureAsyncJobIntegrationTests.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Testing - -/// Integration tests for the `ProfilePictureAsyncJob`. -/// These tests verify the job's ability to process profile picture generation tasks. -@Suite("ProfilePictureAsyncJobIntegrationTests") -internal struct ProfilePictureAsyncJobIntegrationTests { - /// Placeholder test for the profile picture async job. - /// This test will be implemented with specific scenarios for the job. - @Test("test name here") - public func nameHere() { - // Placeholder for test implementation - } -} diff --git a/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobUnitTests.swift b/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobUnitTests.swift deleted file mode 100644 index e54cbe28..00000000 --- a/Backend/Tests/Procs/Jobs/ProfilePictureAsyncJobTests/ProfilePictureAsyncJobUnitTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -// ProfilePictureAsyncJobUnitTests.swift -// Copyright (c) 2025 GetAutomaApp -// All source code and related assets are the property of GetAutomaApp. -// All rights reserved. - -import Testing - -/// Unit tests for the `ProfilePictureAsyncJob`. -/// These tests verify the job's internal logic and functionality. -@Suite("ProfilePictureAsyncJobUnitTests") -internal struct ProfilePictureAsyncJobUnitTests { - /// Placeholder test for the profile picture async job. - /// This test will be implemented with specific unit test scenarios. - @Test("Test Name") - public func test() { - // Placeholder for test implementation - } -} diff --git a/Backend/docker-compose.yml b/Backend/docker-compose.yml index 09fe063d..3571b5e4 100644 --- a/Backend/docker-compose.yml +++ b/Backend/docker-compose.yml @@ -18,6 +18,16 @@ services: condition: service_healthy localstack: condition: service_healthy + temporal: + condition: service_started + grafana: + condition: service_started + prometheus: + condition: service_started + loki: + condition: service_started + promtail: + condition: service_started environment: RUN_COMMAND: "serve --hostname 0.0.0.0" METRICS_PORT: "6834" @@ -27,7 +37,7 @@ services: - monitoring - backend - worker-dev: + temporal-worker: volumes: - ./:/app/ - /app/.build @@ -42,13 +52,22 @@ services: condition: service_healthy localstack: condition: service_healthy + temporal: + condition: service_started + grafana: + condition: service_started + prometheus: + condition: service_started + loki: + condition: service_started + promtail: + condition: service_started environment: - RUN_COMMAND: "queues" + RUN_COMMAND: "temporal-worker" METRICS_PORT: "6835" networks: - monitoring - backend - postgres: image: postgres:latest container_name: postgres-container @@ -129,7 +148,14 @@ services: - prom_data:/prometheus networks: - monitoring - + temporal: + image: temporalio/temporal + container_name: temporal + command: ["server", "start-dev", "--ip", "0.0.0.0"] + ports: + - 8233:8233 + networks: + - backend volumes: postgres_data: driver: local diff --git a/Backend/infra/fly/fly-jobs.toml b/Backend/infra/fly/fly-jobs.toml deleted file mode 100644 index 273fe1fb..00000000 --- a/Backend/infra/fly/fly-jobs.toml +++ /dev/null @@ -1,22 +0,0 @@ -# fly.toml app configuration file generated for backend-solitary-voice-65 on 2024-12-14T04:09:07-08:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'automa-backend-workers-__FLY_ENVIRONMENT__' -primary_region = 'jnb' - -[build] - -[env] -RUN_COMMAND = "vapor-queues" - -[[vm]] -memory = '1024mb' -cpu_kind = 'shared' -cpus = 2 -min_machines_running = 1 - -[metrics] -port = 6834 -path = "/metrics" diff --git a/Backend/infra/fly/temporal-worker.toml b/Backend/infra/fly/temporal-worker.toml new file mode 100644 index 00000000..e40e760f --- /dev/null +++ b/Backend/infra/fly/temporal-worker.toml @@ -0,0 +1,21 @@ +app = 'automa-backend-temporal-worker-__FLY_ENVIRONMENT__' +primary_region = 'jnb' + +[env] +RUN_COMMAND = 'temporal-worker' + +[http_service] +internal_port = 8080 +force_https = true +auto_stop_machines = 'off' +auto_start_machines = false +min_machines_running = 0 + +[[vm]] +memory = '1gb' +cpu_kind = 'shared' +cpus = 2 + +[[metrics]] +port = 6834 +path = '/metrics' diff --git a/Backend/infra/fly/temporal/Dockerfile b/Backend/infra/fly/temporal/Dockerfile new file mode 100644 index 00000000..76f1febf --- /dev/null +++ b/Backend/infra/fly/temporal/Dockerfile @@ -0,0 +1,27 @@ +FROM temporalio/ui:2.27.2 as ui +FROM temporalio/server:1.24.1.0 as server + +WORKDIR /etc/temporal + +FROM temporalio/auto-setup:1.24.1.0 as final + +COPY --from=ui --chown=temporal:temporal /home/ui-server /home/ui-server +RUN rm -rf /home/ui-server/config/* + +EXPOSE 7233 8080 + +ENV DB=postgres12 +ENV DB_PORT=5432 + +ENV BIND_ON_IP=0.0.0.0 +ENV TEMPORAL_BROADCAST_ADDRESS=0.0.0.0 +ENV DEFAULT_NAMESPACE=default + +ENV DYNAMIC_CONFIG_FILE_PATH=/etc/temporal/config/dynamicconfig/docker.yaml + +# These two .sh files are defined below +COPY ./start.sh /etc/temporal/start.sh +COPY ./start-ui.sh /etc/temporal/start-ui.sh +CMD ["autosetup"] + +ENTRYPOINT ["/etc/temporal/start.sh"] diff --git a/Backend/infra/fly/temporal/database.toml b/Backend/infra/fly/temporal/database.toml new file mode 100644 index 00000000..d394ee63 --- /dev/null +++ b/Backend/infra/fly/temporal/database.toml @@ -0,0 +1,5 @@ +app = 'automa-backend-temporal-postgres-sandbox' +primary_region = 'jnb' + +[build] +- diff --git a/Backend/infra/fly/temporal/fly.toml b/Backend/infra/fly/temporal/fly.toml new file mode 100644 index 00000000..23701275 --- /dev/null +++ b/Backend/infra/fly/temporal/fly.toml @@ -0,0 +1,49 @@ +app = 'automa-backend-temporal-sandbox' +primary_region = 'jnb' + +[processes] +app = '/etc/temporal/entrypoint.sh autosetup' +ui = '/etc/temporal/start-ui.sh' + +[env] +LOG_LEVEL = 'debug' + +[[services]] +protocol = 'tcp' +internal_port = 7233 +auto_stop_machines = true +auto_start_machines = true +min_machines_running = 1 +processes = ['app'] + +[[services.ports]] +port = 7233 +# handlers = ['http'] # Do not expose to public, public ipv4/6 should be removed already +# alpn h2 is needed for the grpc protocol +[services.ports.tls_options] +alpn = ['h2'] + +[[services.tcp_checks]] +interval = '10s' +timeout = '2s' +grace_period = '5s' + +[[services]] +protocol = 'tcp' +internal_port = 8080 +min_machines_running = 0 +processes = ['ui'] + +# Ideally, don't expose the UI to the public, keep it behind a CDN (eg Cloudflare) and whitelist the IP +# or make it public but set up SSO +[[services.ports]] +port = 8080 +# handlers = ['http'] + +# [[services.ports]] +# port = 443 +# handlers = ['tls', 'http'] + +[[vm]] +size = 'shared-cpu-1x' +memory = "1gb" diff --git a/Backend/infra/fly/temporal/start-ui.sh b/Backend/infra/fly/temporal/start-ui.sh new file mode 100755 index 00000000..1c1d58e3 --- /dev/null +++ b/Backend/infra/fly/temporal/start-ui.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +# The Temporal UI Server expects the script to be executed at the `/home/ui-server` +cd /home/ui-server +# Assuming your server/ui is running in the same Fly app (but different process) +# Change this to another fly app or IP if running elsewhere. +export TEMPORAL_ADDRESS="${FLY_APP_NAME}.flycast:7233" +export TEMPORAL_CSRF_COOKIE_INSECURE=true +./start-ui-server.sh diff --git a/Backend/infra/fly/temporal/start.sh b/Backend/infra/fly/temporal/start.sh new file mode 100755 index 00000000..b4203bd1 --- /dev/null +++ b/Backend/infra/fly/temporal/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +# This is called via the fly.toml: +# [processes] +# server = "/etc/temporal/entrypoint.sh autosetup" +# ui = "/etc/temporal/start-ui.sh" +# +# This script itself is called in the Dockerfile: +# ENTRYPOINT ["/etc/temporal/start.sh"] +exec "$@" diff --git a/package.json b/package.json index 01da9961..161f433d 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "deploy:backend:staging": "npm run fly:config -- fly staging | npm run deploy:to:fly", "deploy:backend:production": "npm run fly:config -- fly production | npm run deploy:to:fly", "deploy:to:fly": "tail -n 1 | xargs -Ixx cp xx Backend/.fly.toml && cd Backend && flyctl deploy --ha=false --config=.fly.toml --build-secret GITHUB_SSH_AUTHENTICATION_TOKEN=$(cat ./infra/docker-secrets/GITHUB_SSH_AUTHENTICATION_TOKEN)", - "deploy:worker:sandbox": "npm run fly:config -- fly-jobs sandbox | npm run deploy:to:fly", - "deploy:worker:production": "npm run fly:config -- fly-jobs production | npm run deploy:to:fly", + "deploy:worker:sandbox": "npm run fly:config -- temporal-worker sandbox | npm run deploy:to:fly", + "deploy:worker:production": "npm run fly:config -- temporal-worker production | npm run deploy:to:fly", "build:all": "npx npm-run-all --parallel build:ui-kit build:backend", "build:ui-kit": "cd App/AutomaUIKit && swift build", "build:backend": "cd Backend && swift build", @@ -24,7 +24,9 @@ "install:swiftlint": "brew install swiftlint", "install:swiftgen": "brew install swiftgen", "install:swiftformat": "brew install swiftformat", - "install:all": "npx npm-run-all --sequential install:swiftlint install:swiftgen install:swiftformat config", + "install:temporal": "brew install temporal", + "install:all": "npx npm-run-all --sequential install:swiftlint install:temporal install:swiftgen install:swiftformat config", + "temporal:up": "temporal server start-dev", "compose:up": "npm run compose -- up -d", "compose:build": "npm run compose -- build", "compose:build:no-cache": "npm run compose -- build --no-cache",