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 configuration
|
||||||
PROJECT_NAME := jormundb
|
PROJECT_NAME := jormundb
|
||||||
@@ -6,6 +6,16 @@ ODIN := odin
|
|||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
SRC_DIR := .
|
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)
|
# C++ shim (WAL replication helpers via RocksDB C++ API)
|
||||||
SHIM_DIR := rocksdb_shim
|
SHIM_DIR := rocksdb_shim
|
||||||
SHIM_LIB := $(BUILD_DIR)/libjormun_rocksdb_shim.a
|
SHIM_LIB := $(BUILD_DIR)/libjormun_rocksdb_shim.a
|
||||||
@@ -16,9 +26,6 @@ CXX := g++
|
|||||||
AR := ar
|
AR := ar
|
||||||
CXXFLAGS := -O2 -fPIC -std=c++20 $(INCLUDE_PATH)
|
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 and compression libraries
|
||||||
ROCKSDB_LIBS := -lrocksdb -lstdc++ -lsnappy -llz4 -lzstd -lz -lbz2
|
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
|
COMMON_FLAGS := -vet -strict-style
|
||||||
|
|
||||||
# Linker flags
|
# Linker flags
|
||||||
EXTRA_LINKER_FLAGS := $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS)
|
EXTRA_LINKER_FLAGS = $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS)
|
||||||
|
|
||||||
# Runtime configuration
|
# Runtime configuration
|
||||||
PORT ?= 8002
|
PORT ?= 8002
|
||||||
@@ -55,22 +62,22 @@ YELLOW := \033[0;33m
|
|||||||
RED := \033[0;31m
|
RED := \033[0;31m
|
||||||
NC := \033[0m # No Color
|
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)"
|
@echo "$(BLUE)Building RocksDB C++ shim...$(NC)"
|
||||||
$(CXX) $(CXXFLAGS) -c $(SHIM_SRCS) -o $(BUILD_DIR)/rocksdb_shim.o
|
$(CXX) $(CXXFLAGS) -c $(SHIM_SRCS) -o $(BUILD_DIR)/rocksdb_shim.o
|
||||||
$(AR) rcs $(SHIM_LIB) $(BUILD_DIR)/rocksdb_shim.o
|
$(AR) rcs $(SHIM_LIB) $(BUILD_DIR)/rocksdb_shim.o
|
||||||
@echo "$(GREEN)✓ Built: $(SHIM_LIB)$(NC)"
|
@echo "$(GREEN)✓ Built: $(SHIM_LIB)$(NC)"
|
||||||
|
|
||||||
$(BUILD_DIR):
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
# Build debug version
|
build: $(SHIM_LIB) | $(BUILD_DIR)/.dir
|
||||||
build: $(SHIM_LIB)
|
|
||||||
@echo "$(BLUE)Building $(PROJECT_NAME) (debug)...$(NC)"
|
@echo "$(BLUE)Building $(PROJECT_NAME) (debug)...$(NC)"
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
$(ODIN) build $(SRC_DIR) \
|
$(ODIN) build $(SRC_DIR) \
|
||||||
$(COMMON_FLAGS) \
|
$(COMMON_FLAGS) \
|
||||||
$(DEBUG_FLAGS) \
|
$(DEBUG_FLAGS) \
|
||||||
@@ -78,10 +85,8 @@ build: $(SHIM_LIB)
|
|||||||
-extra-linker-flags:"$(EXTRA_LINKER_FLAGS)"
|
-extra-linker-flags:"$(EXTRA_LINKER_FLAGS)"
|
||||||
@echo "$(GREEN)✓ Build complete: $(BUILD_DIR)/$(PROJECT_NAME)$(NC)"
|
@echo "$(GREEN)✓ Build complete: $(BUILD_DIR)/$(PROJECT_NAME)$(NC)"
|
||||||
|
|
||||||
# Build optimized release version
|
release: $(SHIM_LIB) | $(BUILD_DIR)/.dir
|
||||||
release: $(SHIM_LIB)
|
|
||||||
@echo "$(BLUE)Building $(PROJECT_NAME) (release)...$(NC)"
|
@echo "$(BLUE)Building $(PROJECT_NAME) (release)...$(NC)"
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
$(ODIN) build $(SRC_DIR) \
|
$(ODIN) build $(SRC_DIR) \
|
||||||
$(COMMON_FLAGS) \
|
$(COMMON_FLAGS) \
|
||||||
$(RELEASE_FLAGS) \
|
$(RELEASE_FLAGS) \
|
||||||
@@ -154,6 +159,28 @@ dev: clean build run
|
|||||||
quick:
|
quick:
|
||||||
@$(MAKE) build run
|
@$(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
|
# Show help
|
||||||
help:
|
help:
|
||||||
@echo "$(BLUE)JormunDB - DynamoDB-compatible database$(NC)"
|
@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,
|
ItemCollectionSizeLimitExceededException,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
SerializationException,
|
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 {
|
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"
|
type_str = "com.amazonaws.dynamodb.v20120810#InternalServerError"
|
||||||
case .SerializationException:
|
case .SerializationException:
|
||||||
type_str = "com.amazonaws.dynamodb.v20120810#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)
|
return fmt.aprintf(`{{"__type":"%s","message":"%s"}}`, type_str, message)
|
||||||
|
|||||||
86
main.odin
86
main.odin
@@ -13,6 +13,7 @@ Config :: struct {
|
|||||||
port: int,
|
port: int,
|
||||||
data_dir: string,
|
data_dir: string,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
|
access_key: string,
|
||||||
|
|
||||||
// HTTP server config
|
// HTTP server config
|
||||||
max_body_size: int,
|
max_body_size: int,
|
||||||
@@ -22,6 +23,12 @@ Config :: struct {
|
|||||||
max_requests_per_connection: int,
|
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() {
|
main :: proc() {
|
||||||
// Parse configuration
|
// Parse configuration
|
||||||
config := parse_config()
|
config := parse_config()
|
||||||
@@ -52,12 +59,17 @@ main :: proc() {
|
|||||||
max_requests_per_connection = config.max_requests_per_connection,
|
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(
|
server, server_ok := server_init(
|
||||||
context.allocator,
|
context.allocator,
|
||||||
config.host,
|
config.host,
|
||||||
config.port,
|
config.port,
|
||||||
handle_dynamodb_request,
|
handle_dynamodb_request,
|
||||||
engine,
|
handler_ctx,
|
||||||
server_config,
|
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
|
// 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 {
|
handle_dynamodb_request :: proc(ctx_raw: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response {
|
||||||
engine := cast(^dynamodb.Storage_Engine)ctx
|
// 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
|
// All allocations in this function use the request arena automatically
|
||||||
response := response_init(request_alloc)
|
response := response_init(request_alloc)
|
||||||
response_add_header(&response, "Content-Type", "application/x-amz-json-1.0")
|
response_add_header(&response, "Content-Type", "application/x-amz-json-1.0")
|
||||||
response_add_header(&response, "x-amzn-RequestId", "local-request-id")
|
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
|
// Get X-Amz-Target header to determine operation
|
||||||
target := request_get_header(request, "X-Amz-Target")
|
target := request_get_header(request, "X-Amz-Target")
|
||||||
if target == nil {
|
if target == nil {
|
||||||
@@ -2045,6 +2116,10 @@ make_error_response :: proc(response: ^HTTP_Response, err_type: dynamodb.DynamoD
|
|||||||
#partial switch err_type {
|
#partial switch err_type {
|
||||||
case .InternalServerError:
|
case .InternalServerError:
|
||||||
status = .Internal_Server_Error
|
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:
|
case:
|
||||||
status = .Bad_Request
|
status = .Bad_Request
|
||||||
}
|
}
|
||||||
@@ -2071,6 +2146,7 @@ parse_config :: proc() -> Config {
|
|||||||
read_buffer_size = 8 * 1024, // 8 KB
|
read_buffer_size = 8 * 1024, // 8 KB
|
||||||
enable_keep_alive = true,
|
enable_keep_alive = true,
|
||||||
max_requests_per_connection = 1000,
|
max_requests_per_connection = 1000,
|
||||||
|
access_key = "", // no auth required unless set via env var
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables (lower priority)
|
// 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)
|
// Command line arguments (highest priority)
|
||||||
args := os.args[1:] // Skip program name
|
args := os.args[1:] // Skip program name
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user