support basic auth and create deployment container
This commit is contained in:
0
.dockerignore
Normal file
0
.dockerignore
Normal file
62
Dockerfile
Normal file
62
Dockerfile
Normal 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"]
|
||||
57
Makefile
57
Makefile
@@ -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
14
docker-compose.yaml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
86
main.odin
86
main.odin
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user