support basic auth and create deployment container

This commit is contained in:
2026-03-06 03:50:52 -05:00
parent 5ee3df86f1
commit 6450f905c3
6 changed files with 207 additions and 18 deletions

0
.dockerignore Normal file
View File

62
Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# Stage 1: Builder
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y \
build-essential \
g++ \
librocksdb-dev \
libsnappy-dev \
liblz4-dev \
libzstd-dev \
zlib1g-dev \
libbz2-dev \
curl \
clang \
&& rm -rf /var/lib/apt/lists/*
# Install Odin compiler
RUN curl -Lo /tmp/odin.tar.gz https://github.com/odin-lang/Odin/releases/download/dev-2026-02/odin-linux-amd64-dev-2026-02.tar.gz \
&& tar -xzf /tmp/odin.tar.gz -C /tmp \
&& mv /tmp/odin-linux-amd64-nightly+2026-02-04 /opt/odin \
&& ln -s /opt/odin/odin /usr/local/bin/odin \
&& chmod +x /opt/odin/odin \
&& odin version \
&& rm /tmp/odin.tar.gz
WORKDIR /app
COPY . .
# just use the make file
RUN make release
# Stage 2: Runtime for deployment
FROM ubuntu:24.04 AS runtime
# Only runtime shared libs
RUN apt-get update && apt-get install -y \
librocksdb8.9 \
libsnappy1v5 \
liblz4-1 \
libzstd1 \
zlib1g \
libbz2-1.0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# only need the compiled binary from the builder stage
COPY --from=builder /app/build/jormundb .
RUN mkdir -p /data
#USER jormun
EXPOSE 8002
VOLUME ["/data"]
ENV JORMUN_HOST=0.0.0.0
ENV JORMUN_PORT=8002
ENV JORMUN_DATA_DIR=/data
ENV JORMUN_VERBOSE=0
ENTRYPOINT ["./jormundb"]

View File

@@ -1,4 +1,4 @@
.PHONY: all build release run test clean fmt help install
.PHONY: all build release run test clean fmt help install container registry-login container-build container-push
# Project configuration
PROJECT_NAME := jormundb
@@ -6,6 +6,16 @@ ODIN := odin
BUILD_DIR := build
SRC_DIR := .
# Container configuration
REGISTRY := atl.vultrcr.com/jormun
IMAGE_NAME := jormundb
IMAGE_TAG ?= latest
FULL_IMAGE := $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
# Registry credentials
CR_USER ?= $(error CR_USER is not set)
CR_PASS ?= $(error CR_PASS is not set)
# C++ shim (WAL replication helpers via RocksDB C++ API)
SHIM_DIR := rocksdb_shim
SHIM_LIB := $(BUILD_DIR)/libjormun_rocksdb_shim.a
@@ -16,9 +26,6 @@ CXX := g++
AR := ar
CXXFLAGS := -O2 -fPIC -std=c++20 $(INCLUDE_PATH)
# name of the docker compose file for the python tests
SDK_TEST_COMPOSE := docker-compose-python-sdk-test.yaml
# RocksDB and compression libraries
ROCKSDB_LIBS := -lrocksdb -lstdc++ -lsnappy -llz4 -lzstd -lz -lbz2
@@ -40,7 +47,7 @@ RELEASE_FLAGS := -o:speed -disable-assert -no-bounds-check
COMMON_FLAGS := -vet -strict-style
# Linker flags
EXTRA_LINKER_FLAGS := $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS)
EXTRA_LINKER_FLAGS = $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS)
# Runtime configuration
PORT ?= 8002
@@ -55,22 +62,22 @@ YELLOW := \033[0;33m
RED := \033[0;31m
NC := \033[0m # No Color
$(SHIM_LIB): $(SHIM_SRCS) $(SHIM_HDRS) | $(BUILD_DIR)
# To this — use a sentinel file instead of the dir name
$(BUILD_DIR)/.dir:
@mkdir -p $(BUILD_DIR)
@touch $(BUILD_DIR)/.dir
$(SHIM_LIB): $(SHIM_SRCS) $(SHIM_HDRS) | $(BUILD_DIR)/.dir
@echo "$(BLUE)Building RocksDB C++ shim...$(NC)"
$(CXX) $(CXXFLAGS) -c $(SHIM_SRCS) -o $(BUILD_DIR)/rocksdb_shim.o
$(AR) rcs $(SHIM_LIB) $(BUILD_DIR)/rocksdb_shim.o
@echo "$(GREEN)✓ Built: $(SHIM_LIB)$(NC)"
$(BUILD_DIR):
@mkdir -p $(BUILD_DIR)
# Default target
all: build
# Build debug version
build: $(SHIM_LIB)
build: $(SHIM_LIB) | $(BUILD_DIR)/.dir
@echo "$(BLUE)Building $(PROJECT_NAME) (debug)...$(NC)"
@mkdir -p $(BUILD_DIR)
$(ODIN) build $(SRC_DIR) \
$(COMMON_FLAGS) \
$(DEBUG_FLAGS) \
@@ -78,10 +85,8 @@ build: $(SHIM_LIB)
-extra-linker-flags:"$(EXTRA_LINKER_FLAGS)"
@echo "$(GREEN)✓ Build complete: $(BUILD_DIR)/$(PROJECT_NAME)$(NC)"
# Build optimized release version
release: $(SHIM_LIB)
release: $(SHIM_LIB) | $(BUILD_DIR)/.dir
@echo "$(BLUE)Building $(PROJECT_NAME) (release)...$(NC)"
@mkdir -p $(BUILD_DIR)
$(ODIN) build $(SRC_DIR) \
$(COMMON_FLAGS) \
$(RELEASE_FLAGS) \
@@ -154,6 +159,28 @@ dev: clean build run
quick:
@$(MAKE) build run
# Login to container registry
registry-login:
@echo "$(BLUE)Logging into container registry...$(NC)"
@echo "$(CR_PASS)" | docker login https://$(REGISTRY) -u $(CR_USER) --password-stdin
@echo "$(GREEN)✓ Logged in to $(REGISTRY)$(NC)"
# Build container image
container-build:
@echo "$(BLUE)Building container image $(FULL_IMAGE)...$(NC)"
docker build -t $(FULL_IMAGE) .
@echo "$(GREEN)✓ Built: $(FULL_IMAGE)$(NC)"
# Push container image
container-push:
@echo "$(BLUE)Pushing $(FULL_IMAGE)...$(NC)"
docker push $(FULL_IMAGE)
@echo "$(GREEN)✓ Pushed: $(FULL_IMAGE)$(NC)"
# Login, build, and push in one shot
container: registry-login container-build container-push
@echo "$(GREEN)✓ Container $(FULL_IMAGE) built and pushed$(NC)"
# Show help
help:
@echo "$(BLUE)JormunDB - DynamoDB-compatible database$(NC)"

14
docker-compose.yaml Normal file
View File

@@ -0,0 +1,14 @@
services:
jormundb:
image: atl.vultrcr.com/jormun/jormundb:latest
pull_policy: always
restart: unless-stopped
network_mode: host
volumes:
- ./data:/data
environment:
JORMUN_HOST: 0.0.0.0
JORMUN_PORT: 8002
JORMUN_DATA_DIR: /data
JORMUN_VERBOSE: 0
JORMUN_ACCESS_KEY: AKIAIOSFODNN7EXAMPLE

View File

@@ -334,6 +334,8 @@ DynamoDB_Error_Type :: enum {
ItemCollectionSizeLimitExceededException,
InternalServerError,
SerializationException,
MissingAuthenticationTokenException, // HTTP 403
UnrecognizedClientException, // HTTP 400 Dynamo uses 400 for some invalid auth stuff
}
error_to_response :: proc(err_type: DynamoDB_Error_Type, message: string) -> string {
@@ -356,6 +358,10 @@ error_to_response :: proc(err_type: DynamoDB_Error_Type, message: string) -> str
type_str = "com.amazonaws.dynamodb.v20120810#InternalServerError"
case .SerializationException:
type_str = "com.amazonaws.dynamodb.v20120810#SerializationException"
case .MissingAuthenticationTokenException:
type_str = "com.amazonaws.dynamodb.v20120810#MissingAuthenticationTokenException"
case .UnrecognizedClientException:
type_str = "com.amazonaws.dynamodb.v20120810#UnrecognizedClientException"
}
return fmt.aprintf(`{{"__type":"%s","message":"%s"}}`, type_str, message)

View File

@@ -13,6 +13,7 @@ Config :: struct {
port: int,
data_dir: string,
verbose: bool,
access_key: string,
// HTTP server config
max_body_size: int,
@@ -22,6 +23,12 @@ Config :: struct {
max_requests_per_connection: int,
}
// wraprer to pass both engine and access_key to handler
Handler_Context :: struct {
engine: ^dynamodb.Storage_Engine,
access_key: string, // Empty string means no auth required
}
main :: proc() {
// Parse configuration
config := parse_config()
@@ -52,12 +59,17 @@ main :: proc() {
max_requests_per_connection = config.max_requests_per_connection,
}
// Create handler context
handler_ctx := new(Handler_Context, context.allocator)
handler_ctx.engine = engine
handler_ctx.access_key = config.access_key // Copy the string (its just a slice header)
server, server_ok := server_init(
context.allocator,
config.host,
config.port,
handle_dynamodb_request,
engine,
handler_ctx,
server_config,
)
@@ -76,15 +88,74 @@ main :: proc() {
}
}
// Extract Access Key ID from AWS sig4 Auth header
// Format is something like .... AWS4-HMAC-SHA256 Credential=<access_key>/20230101/region/dynamodb/aws4_request, ... whatever
parse_access_key_from_auth :: proc(auth_header: string) -> (access_key: string, ok: bool) {
// Must start with AWS4-HMAC-SHA256
if !strings.has_prefix(auth_header, "AWS4-HMAC-SHA256") {
return "", false
}
// Find Credential= (case-sensitive per AWS spec)
cred_idx := strings.index(auth_header, "Credential=")
if cred_idx == -1 {
return "", false
}
// Move past "Credential="
start := cred_idx + len("Credential=")
if start >= len(auth_header) {
return "", false
}
// Find end of access key (first '/' or ',' or space)
end := start
for end < len(auth_header) {
c := auth_header[end]
if c == '/' || c == ',' || c == ' ' {
break
}
end += 1
}
if end == start {
return "", false
}
return auth_header[start:end], true
}
// DynamoDB request handler - called for each HTTP request with request-scoped arena allocator
handle_dynamodb_request :: proc(ctx: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response {
engine := cast(^dynamodb.Storage_Engine)ctx
handle_dynamodb_request :: proc(ctx_raw: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response {
// Snag the engine from the ctx wrapper
ctx := cast(^Handler_Context)ctx_raw
engine := ctx.engine
// All allocations in this function use the request arena automatically
response := response_init(request_alloc)
response_add_header(&response, "Content-Type", "application/x-amz-json-1.0")
response_add_header(&response, "x-amzn-RequestId", "local-request-id")
// AUTH CHECK!
// This is just a simple string match for speed, if we actually need to handle full blown keys, we should probably make an internal table that stores that like mysql or something
if ctx.access_key != "" {
auth_header := request_get_header(request, "Authorization")
if auth_header == nil {
// No auth header provided but required
return make_error_response(&response, .MissingAuthenticationTokenException,
"Request is missing Authentication Token")
}
provided_key, parse_ok := parse_access_key_from_auth(auth_header.?)
if !parse_ok || provided_key != ctx.access_key {
// Wrong key or malformed header
return make_error_response(&response, .UnrecognizedClientException,
"The security token included in the request is invalid")
}
}
// Get X-Amz-Target header to determine operation
target := request_get_header(request, "X-Amz-Target")
if target == nil {
@@ -2045,6 +2116,10 @@ make_error_response :: proc(response: ^HTTP_Response, err_type: dynamodb.DynamoD
#partial switch err_type {
case .InternalServerError:
status = .Internal_Server_Error
case .MissingAuthenticationTokenException:
status = .Forbidden // 403
case .UnrecognizedClientException:
status = .Bad_Request // 400 (dynamo uses 400 for this i guess because it's more of a malform than bad auth)
case:
status = .Bad_Request
}
@@ -2071,6 +2146,7 @@ parse_config :: proc() -> Config {
read_buffer_size = 8 * 1024, // 8 KB
enable_keep_alive = true,
max_requests_per_connection = 1000,
access_key = "", // no auth required unless set via env var
}
// Environment variables (lower priority)
@@ -2098,6 +2174,10 @@ parse_config :: proc() -> Config {
}
}
if access_key, ok := os.lookup_env("JORMUN_ACCESS_KEY"); ok {
config.access_key = access_key
}
// Command line arguments (highest priority)
args := os.args[1:] // Skip program name