diff --git a/.gitignore b/.gitignore index 0a05440..e33ea89 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ playground.xcworkspace # NOTE: `Package.resolved` is intentionally NOT ignored — committing it locks # dependency versions across the team and CI. +# Docker / environment +.env +!.env.local + # Fastlane fastlane/report.xml fastlane/Preview.html diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore new file mode 100644 index 0000000..5842fb7 --- /dev/null +++ b/Services/Website/.dockerignore @@ -0,0 +1,6 @@ +.build +.swiftpm +.env.local +docker-compose.* +Makefile +README.md \ No newline at end of file diff --git a/Services/Website/.env.local b/Services/Website/.env.local new file mode 100644 index 0000000..a3d7faa --- /dev/null +++ b/Services/Website/.env.local @@ -0,0 +1,42 @@ +# Copy this file to `.env` and adjust values as needed. +# cp .env.example .env +# +# Compose reads `.env` automatically to fill the ${VAR} placeholders in +# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via +# swift-configuration (allowMissing: true), so any extra app config keys placed +# here are picked up by the running service too. + +# --- Image / deployment ------------------------------------------------------- + +# Host name of the container running the Website service. +HOST_CONTAINER=containers.rock-n-code.com + +# Owner of the container running the Website service. +HOST_OWNER=loud + +# Password for authenticating to the container registry. +HOST_PASSWORD= + +# Host port mapped to the container's port 8080. +HOST_PORT=8080 + +# User name for authenticating to the container registry. +HOST_USER= + +# Name of the Docker image to pull/run. +IMAGE_NAME=website + +# Platform of the Docker image to pull/run. +IMAGE_PLATFORM=linux/arm64 + +# Tag of the image to pull/run. +# Use a semver in production; avoid `latest` so rollbacks are unambiguous. +IMAGE_TAG=latest + +# --- Application config ------------------------------------------------------- + +# Server name reported by the HTTP service. +HTTP_SERVER_NAME=LoudWebsite + +# Log verbosity: trace | debug | info | notice | warning | error | critical +LOG_LEVEL=info diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile new file mode 100644 index 0000000..a08d2fb --- /dev/null +++ b/Services/Website/Dockerfile @@ -0,0 +1,87 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.3-noble 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 \ + && rm -rf /var/lib/apt/lists/* + +# 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. +COPY ./Package.* ./ +RUN swift package resolve + +# Copy entire repo into container +COPY . . + +# Build the application, with optimizations, with static linking, and using jemalloc +RUN swift build -c release \ + --product "Website" \ + --static-swift-stdlib \ + -Xlinker -ljemalloc + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Website" ./ + +# 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 find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resouces 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 image +# ================================ +FROM ubuntu:noble + +# 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 \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a hummingbird user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=hummingbird:hummingbird /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 hummingbird user +USER hummingbird:hummingbird + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./Website"] +CMD ["--http-host", "0.0.0.0", "--http-port", "8080"] diff --git a/Services/Website/Makefile b/Services/Website/Makefile new file mode 100644 index 0000000..98cb6b7 --- /dev/null +++ b/Services/Website/Makefile @@ -0,0 +1,107 @@ +# Website service — operational commands +# --- Configuration ------------------------------------------------------------ + +# Load configuration from .env (the same file Compose reads). +ENV_FILE := $(if $(wildcard .env),.env,.env.local) +include $(ENV_FILE) +export + +# Variables +IMAGE_URL := $(HOST_CONTAINER)/$(HOST_OWNER)/$(IMAGE_NAME) + +# Arguments +override version ?= $(IMAGE_TAG) + +# Show help if no target is specified. +.DEFAULT_GOAL := help + +# --- Swift package ------------------------------------------------------------ + +.PHONY: pkg-build +pkg-build: ## Build the Swift package + @swift build + +.PHONY: pkg-release +pkg-release: ## Release the Swift package + @swift build -c release + +.PHONY: pkg-test +pkg-test: ## Run the Swift package tests + @swift test \ + --disable-xctest \ + --enable-code-coverage \ + --enable-swift-testing \ + --parallel + +.PHONY: pkg-clean +pkg-clean: ## Remove the Swift build artifacts + @swift package clean + +.PHONY: pkg-reset +pkg-reset: ## Resets the complete SPM cache/build folder + @swift package reset + +.PHONY: pkg-deps +pkg-deps: ## Lists the SPM package dependencies + @swift package show-dependencies + +.PHONY: pkg-outdated +pkg-outdated: ## Lists the SPM package dependencies that can be updated + @swift package update --dry-run + +.PHONY: pkg-update +pkg-update: ## Updates the SPM package dependencies + @swift package update + +# --- Local development -------------------------------------------------------- + +.PHONY: img-build +img-build: ## Build the local dev image + @docker compose build + +.PHONY: img-mount +img-mount: ## Mount the service locally (build if needed) + @docker compose up --build --detach + +.PHONY: img-unmount +img-unmount: ## Unmount and remove the local service + @docker compose down + @$(MAKE) img-remove + +# --- Registry deployment ------------------------------------------------------ + +.PHONY: img-release +img-release: ## Build the production (amd64) image, tag with version + latest, push both + @if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \ + echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \ + exit 1; \ + fi + @docker build \ + --platform $(IMAGE_PLATFORM) \ + --tag $(IMAGE_URL):$(version) \ + --tag $(IMAGE_URL):latest \ + . + @echo "${HOST_PASSWORD}" \ + | docker login $(HOST_CONTAINER) \ + --username $(HOST_USER) \ + --password-stdin + @docker push $(IMAGE_URL):$(version) + @docker push $(IMAGE_URL):latest + @docker logout $(HOST_CONTAINER) + @$(MAKE) img-remove + +.PHONY: img-remove +img-remove: # Removes the generated Docker images + @images="$$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep '$(IMAGE_NAME)' | awk '{print $$1}')"; \ + if [ -n "$$images" ]; then \ + docker image rm --force $$images; \ + else \ + echo "No '$(IMAGE_NAME)' images to remove."; \ + fi + +# --- Meta --------------------------------------------------------------------- + +.PHONY: help +help: ## Show available commands + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift new file mode 100644 index 0000000..a6d6bf0 --- /dev/null +++ b/Services/Website/Package.swift @@ -0,0 +1,74 @@ +// swift-tools-version:6.3 + +import PackageDescription + +let package = Package( + name: "Website", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + ], + products: [ + .executable( + name: "Website", + targets: [ + "Website", + "WebsiteCore", + ] + ) + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird.git", + from: "2.25.0" + ), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: [ + .defaults, + "CommandLineArguments", + ] + ), + ], + targets: [ + .executableTarget( + name: "Website", + dependencies: [ + .product( + name: "Configuration", + package: "swift-configuration" + ), + .product( + name: "Hummingbird", + package: "hummingbird" + ), + ], + path: "Sources/App" + ), + .target( + name: "WebsiteCore", + dependencies: [], + path: "Sources/Library" + ), + .testTarget( + name: "WebsiteTests", + dependencies: [ + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + .byName(name: "Website"), + ], + path: "Tests/App" + ), + .testTarget( + name: "WebsiteCoreTests", + dependencies: [ + .byName(name: "WebsiteCore"), + ], + path: "Tests/Library" + ), + ] +) diff --git a/Services/Website/README.md b/Services/Website/README.md new file mode 100644 index 0000000..eb5764d --- /dev/null +++ b/Services/Website/README.md @@ -0,0 +1,2 @@ +# Website +Hummingbird server framework project \ No newline at end of file diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift new file mode 100644 index 0000000..d5df224 --- /dev/null +++ b/Services/Website/Sources/App/App+build.swift @@ -0,0 +1,62 @@ +import Configuration +import Hummingbird +import Logging + +/// Build application +/// - Parameter reader: configuration reader +func application( + reader: ConfigReader +) async throws -> some ApplicationProtocol { + let logLevel = reader.string( + forKey: "log.level", + as: Logger.Level.self, + default: .info + ) + let serverName = reader.string( + forKey: "http.serverName", + default: "LoudWebsite" + ) + + return Application( + router: try router(), + configuration: ApplicationConfiguration( + reader: reader.scoped(to: "http") + ), + logger: logger( + serverName: serverName, + logLevel: logLevel + ) + ) +} + +// MARK: - Helpers + +// Request context used by application +private typealias AppRequestContext = BasicRequestContext + +/// Build logger +private func logger( + serverName: String, + logLevel: Logger.Level +) -> Logger { + var logger = Logger(label: serverName) + + logger.logLevel = logLevel + + return logger +} + +/// Build router +private func router() throws -> Router { + let router = Router(context: AppRequestContext.self) + + router.addMiddleware { + LogRequestsMiddleware(.info) + } + + router.get("/") { _, _ in + return "Hello!" + } + + return router +} diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift new file mode 100644 index 0000000..c46a698 --- /dev/null +++ b/Services/Website/Sources/App/App.swift @@ -0,0 +1,28 @@ +import Configuration +import Hummingbird +import Logging + +@main +struct App { + static func main() async throws { + let reader = try await ConfigReader( + providers: [ + CommandLineArgumentsProvider(), + EnvironmentVariablesProvider(), + EnvironmentVariablesProvider( + environmentFilePath: ".env", + allowMissing: true + ), + InMemoryProvider(values: [ + "http.serverName": "LoudWebsite" + ]), + ] + ) + + let app = try await application( + reader: reader + ) + + try await app.runService() + } +} diff --git a/Services/Website/Sources/Library/Library.swift b/Services/Website/Sources/Library/Library.swift new file mode 100644 index 0000000..139597f --- /dev/null +++ b/Services/Website/Sources/Library/Library.swift @@ -0,0 +1,2 @@ + + diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift new file mode 100644 index 0000000..379de9f --- /dev/null +++ b/Services/Website/Tests/App/AppTests.swift @@ -0,0 +1,34 @@ +import Configuration +import Hummingbird +import HummingbirdTesting +import Logging +import Testing + +@testable import Website + +private let reader = ConfigReader(providers: [ + InMemoryProvider(values: [ + "http.host": "127.0.0.1", + "http.port": "0", + "log.level": "trace", + ]) +]) + +@Suite +struct AppTests { + @Test + func hello() async throws { + let app = try await application( + reader: reader + ) + + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } +} diff --git a/Services/Website/Tests/Library/LibraryTest.swift b/Services/Website/Tests/Library/LibraryTest.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Services/Website/Tests/Library/LibraryTest.swift @@ -0,0 +1 @@ + diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml new file mode 100644 index 0000000..22467b1 --- /dev/null +++ b/Services/Website/docker-compose.override.yml @@ -0,0 +1,18 @@ +# Local development overrides. +# Compose merges this file on top of docker-compose.yml automatically, so a +# plain `docker compose up` builds from source instead of pulling a registry image: +# +# docker compose up --build # build locally and run +# docker compose up -d # reuse the last local build +# +# It reuses the `image:` name from the base file, so the local build is tagged +# the same way the production image would be. +services: + website: + image: ${IMAGE_NAME}:${IMAGE_TAG:-latest} + platform: linux/arm64 + build: + context: . + dockerfile: Dockerfile + environment: + LOG_LEVEL: debug diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml new file mode 100644 index 0000000..8a29503 --- /dev/null +++ b/Services/Website/docker-compose.yml @@ -0,0 +1,22 @@ +name: loud-platform + +# Production base configuration. +# Deploys a pre-built image pulled from a registry — no build step. +# +# docker compose -f docker-compose.yml pull +# docker compose -f docker-compose.yml up -d +# +# The `-f docker-compose.yml` flag is important in production: it skips the +# docker-compose.override.yml file, which Compose would otherwise merge in +# automatically for local development. +services: + website: + image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest} + platform: linux/amd64 + container_name: ${HOST_OWNER}-${IMAGE_NAME} + restart: unless-stopped + ports: + - "${HOST_PORT:-8080}:8080" + environment: + LOG_LEVEL: ${LOG_LEVEL:-info} + HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}