Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
# Pulso uses Elixir's built-in `JSON` module (Elixir 1.18+); the
# `Jason` dependency is only present transitively for optional deps
# of other libraries. Any direct `Jason.*` reference in our code
# should fail CI. See AGENTS.md > Conventions.
{Credo.Check.Warning.ForbiddenModule,
[
modules: [
{Jason, "Use Elixir's built-in `JSON` module instead of Jason (see AGENTS.md)."}
]
]},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
Expand Down
40 changes: 21 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ jobs:
env:
MIX_ENV: test
PULSO_INTEGRATION: "1"
PULSO_MINIO_ENDPOINT: "http://localhost:9000"
PULSO_MINIO_BUCKET: "pulso"
PULSO_MINIO_REGION: "us-east-1"
PULSO_MINIO_ACCESS_KEY_ID: "minioadmin"
PULSO_MINIO_SECRET_ACCESS_KEY: "minioadmin"
# RustFS in docker-compose binds to these host ports by default (see
# mise/utilities/dev_instance_env.sh for the local per-worktree scheme).
PULSO_RUSTFS_API_PORT: "11100"
PULSO_RUSTFS_CONSOLE_PORT: "12100"
PULSO_S3_ENDPOINT: "http://localhost:11100"
PULSO_S3_BUCKET: "pulso"
PULSO_S3_REGION: "us-east-1"
PULSO_S3_ACCESS_KEY_ID: "rustfsadmin"
PULSO_S3_SECRET_ACCESS_KEY: "rustfsadmin"
steps:
- uses: actions/checkout@v4

Expand All @@ -105,29 +109,27 @@ jobs:
restore-keys: |
${{ runner.os }}-mix-deps-

- name: Start MinIO
- name: Start RustFS via docker compose
run: |
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
quay.io/minio/minio:latest server /data
for _ in $(seq 1 30); do
if curl -sf http://localhost:9000/minio/health/live > /dev/null; then
echo "MinIO is up"
docker compose up -d rustfs
for _ in $(seq 1 60); do
if curl -sf "http://localhost:${PULSO_RUSTFS_API_PORT}/" > /dev/null 2>&1 \
|| curl -sf -o /dev/null -w '%{http_code}' "http://localhost:${PULSO_RUSTFS_API_PORT}/" | grep -qE '^(200|403|404)$'; then
echo "RustFS is up"
exit 0
fi
sleep 1
done
echo "MinIO did not start in time"
docker logs minio
echo "RustFS did not start in time"
docker compose logs rustfs
exit 1

- name: Create bucket
env:
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_DEFAULT_REGION: us-east-1
run: aws --endpoint-url http://localhost:9000 s3 mb s3://pulso
AWS_ACCESS_KEY_ID: ${{ env.PULSO_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ env.PULSO_S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ env.PULSO_S3_REGION }}
run: aws --endpoint-url "http://localhost:${PULSO_RUSTFS_API_PORT}" s3 mb "s3://${PULSO_S3_BUCKET}"

- name: Install dependencies
run: mix deps.get
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ pulso-*.tar
# shared object into priv/native/. Both are build artifacts.
native/*/target/
/priv/native/

# Per-worktree dev-instance suffix written by mise/utilities/dev_instance_env.sh.
# Persisted inside .git/worktrees/*/pulso-dev-instance by design; the
# top-level fallback is tracked here as a safety net for setups where the
# git-scoped path is unwritable.
/.pulso-dev-instance
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Storage backend URLs are read from `config :pulso, Pulso.Loki, base_url: ...` an
## Conventions

- **HTTP client**: use `Req`. Never `HTTPoison`, `Tesla`, `:httpc`, or `Finch` directly.
- **JSON**: `Jason` (Phoenix's configured library).
- **JSON**: use Elixir's built-in `JSON` module (Elixir 1.18+), never `Jason`. Phoenix's `:json_library` is set to `JSON` in `config/config.exs`, and a Credo rule (`Credo.Check.Warning.ForbiddenModule`) fails CI on any direct `Jason.*` reference. Jason may still appear as a transitive dep of `phoenix` or a dev dep, but no code in `lib/`, `config/`, or `test/` may call it.
- **New backends** go under `Pulso.<Backend>` (e.g. `Pulso.Mimir`, `Pulso.Tempo`), with the same read-only-first shape as `Pulso.Loki`. Every read function must accept a `:base_url` override in opts.
- **MCP tools** live in `Pulso.MCP.Tools`. Each tool has an `inputSchema`, and its `call/2` clause returns `{:ok, [content_block]}` or `{:error, reason}`. Content blocks follow the MCP shape: `%{"type" => "text", "text" => "..."}`.
- **Alerting** (when added): each rule is its own supervised process, cluster-wide singleton via Horde. Rules that require exactly-once firing route through `ra`.
Expand Down
12 changes: 10 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@
# General application configuration
import Config

alias Pulso.Auth.Open

# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]

# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Use Elixir's built-in JSON module for Phoenix (avoids the Jason dependency;
# see AGENTS.md conventions).
config :phoenix, :json_library, JSON

# Explicit auth default. Environment-specific configs override; prod requires
# a runtime override to `Pulso.Auth.SharedSecret` via runtime.exs — an unset
# release still raises rather than falling back to open access.
config :pulso, Pulso.Auth, module: Open

# Configure the endpoint
config :pulso, PulsoWeb.Endpoint,
Expand Down
6 changes: 6 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import Config
# Do not print debug messages in production
config :logger, level: :info

# A sentinel that runtime.exs is expected to overwrite. If a release starts
# without runtime.exs having populated the real auth module, `Pulso.Auth`
# raises rather than serving requests with the Open (accept-everything)
# fallback that ships in config.exs.
config :pulso, Pulso.Auth, module: :must_configure_at_runtime

config :pulso, PulsoWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
Expand Down
78 changes: 78 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,90 @@ import Config
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
alias Pulso.Auth.SharedSecret
alias Pulso.Storage.S3

if System.get_env("PHX_SERVER") do
config :pulso, PulsoWeb.Endpoint, server: true
end

config :pulso, PulsoWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]

# Log storage adapter. Tests keep the in-memory adapter (see config/test.exs);
# dev and prod use the S3 adapter against any S3-compatible endpoint. RustFS
# runs locally via docker-compose.yml — the dev defaults below match its
# out-of-the-box credentials. Prod requires the env vars to be set explicitly.
case config_env() do
:dev ->
config :pulso, Pulso.Storage, adapter: S3

config :pulso, S3,
bucket: System.get_env("PULSO_S3_BUCKET", "pulso"),
# mise/utilities/dev_instance_env.sh sets PULSO_S3_ENDPOINT per worktree.
# The fallback matches the docker-compose default host port when mise
# is not in the loop.
endpoint: System.get_env("PULSO_S3_ENDPOINT", "http://localhost:11100"),
region: System.get_env("PULSO_S3_REGION", "us-east-1"),
access_key_id: System.get_env("PULSO_S3_ACCESS_KEY_ID", "rustfsadmin"),
secret_access_key: System.get_env("PULSO_S3_SECRET_ACCESS_KEY", "rustfsadmin"),
allow_http: System.get_env("PULSO_S3_ALLOW_HTTP", "true") in ["1", "true", "yes"]

:prod ->
require_env = fn name ->
case System.get_env(name) do
value when is_binary(value) and value != "" ->
value

_ ->
raise """
environment variable #{name} is missing or empty.
Pulso.Storage.S3 requires bucket/region/credentials in prod.
"""
end
end

# Tenant tokens. Expected shape: a JSON object mapping tenant name to
# "sha256$<hex-of-sha256-of-token>". Deployments compute the hash offline
# and store only the digest in env, never the plaintext token.
tokens =
case System.get_env("PULSO_TENANT_TOKENS") do
blob when is_binary(blob) and blob != "" ->
case JSON.decode(blob) do
{:ok, map} when is_map(map) ->
map

{:ok, _} ->
raise "PULSO_TENANT_TOKENS must decode to a JSON object"

{:error, reason} ->
raise "PULSO_TENANT_TOKENS is not valid JSON: #{inspect(reason)}"
end

_ ->
raise """
environment variable PULSO_TENANT_TOKENS is missing or empty.
Pulso.Auth.SharedSecret requires at least one tenant token in prod.
"""
end

config :pulso, Pulso.Auth,
module: SharedSecret,
tokens: tokens

config :pulso, Pulso.Storage, adapter: S3

config :pulso, S3,
bucket: require_env.("PULSO_S3_BUCKET"),
endpoint: System.get_env("PULSO_S3_ENDPOINT"),
region: require_env.("PULSO_S3_REGION"),
access_key_id: require_env.("PULSO_S3_ACCESS_KEY_ID"),
secret_access_key: require_env.("PULSO_S3_SECRET_ACCESS_KEY"),
allow_http: System.get_env("PULSO_S3_ALLOW_HTTP", "false") in ["1", "true", "yes"]

:test ->
:noop
end

if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
Expand Down
83 changes: 55 additions & 28 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,66 @@
# Local development stack for Pulso.
#
# `docker compose up -d` gives you MinIO on http://localhost:9000 with a
# preseeded bucket named `pulso`. The console is on http://localhost:9001
# (user/password: minioadmin/minioadmin).
# `docker compose up -d` gives you a RustFS (S3-compatible) server with a
# preseeded bucket named `pulso`. The published ports are derived from
# PULSO_RUSTFS_API_PORT / PULSO_RUSTFS_CONSOLE_PORT, which
# mise/utilities/dev_instance_env.sh scopes per git worktree so parallel
# worktrees do not collide. Defaults (9195 / 9198) apply when the vars are
# not set. Credentials for the console: rustfsadmin / rustfsadmin.
#
# RustFS is used instead of MinIO because MinIO's community edition is no
# longer actively maintained. Pulso only talks to the S3 API, so any
# S3-compatible backend works; RustFS is a drop-in that stays maintained.

services:
minio:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
rustfs:
image: rustfs/rustfs:latest
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
# RUSTFS_VOLUMES is required — without it RustFS exits at startup. We
# run single-volume in dev and CI; the 4-volume erasure layout that
# RustFS ships in its own compose expects four distinct physical disks
# and refuses to start when they resolve to the same st_dev (any
# laptop or CI runner).
RUSTFS_VOLUMES: /data/rustfs0
RUSTFS_ADDRESS: 0.0.0.0:9000
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_ACCESS_KEY: rustfsadmin
RUSTFS_SECRET_KEY: rustfsadmin
# Bind to loopback only so the well-known dev credentials cannot be reached
# from another host on the LAN. Host ports come from the per-worktree env
# to keep multiple checkouts from fighting over 9000/9001.
ports:
- "9000:9000"
- "9001:9001"
- "127.0.0.1:${PULSO_RUSTFS_API_PORT:-11100}:9000"
- "127.0.0.1:${PULSO_RUSTFS_CONSOLE_PORT:-12100}:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
- rustfs-data:/data

minio-init:
image: quay.io/minio/mc:latest
rustfs-init:
image: amazon/aws-cli:2
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing local/pulso;
mc anonymous set none local/pulso;
echo 'pulso bucket ready';
"
- rustfs
environment:
AWS_ACCESS_KEY_ID: rustfsadmin
AWS_SECRET_ACCESS_KEY: rustfsadmin
AWS_DEFAULT_REGION: us-east-1
entrypoint:
- /bin/sh
- -c
- |
for i in $$(seq 1 60); do
if aws --endpoint-url http://rustfs:9000 s3 mb s3://pulso 2>/dev/null; then
echo "pulso bucket created"
exit 0
fi
if aws --endpoint-url http://rustfs:9000 s3api head-bucket --bucket pulso 2>/dev/null; then
echo "pulso bucket already exists"
exit 0
fi
echo "waiting for rustfs..."
sleep 2
done
echo "gave up waiting for rustfs" >&2
exit 1

volumes:
minio-data:
rustfs-data:
30 changes: 23 additions & 7 deletions lib/pulso/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ defmodule Pulso.Application do

@impl true
def start(_type, _args) do
children = [
PulsoWeb.Telemetry,
{DNSCluster, query: Application.get_env(:pulso, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Pulso.PubSub},
Memory,
PulsoWeb.Endpoint
]
children =
[
PulsoWeb.Telemetry,
{DNSCluster, query: Application.get_env(:pulso, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Pulso.PubSub}
] ++ storage_children() ++ [PulsoWeb.Endpoint]

# See https://elixir.hexdocs.pm/Supervisor.html
# for other strategies and supported options
Expand All @@ -30,4 +29,21 @@ defmodule Pulso.Application do
PulsoWeb.Endpoint.config_change(changed, removed)
:ok
end

# `Memory` only runs when it is the configured adapter — in `mix test` no
# adapter is set, so `Pulso.Storage.adapter/0` falls back to it. In dev and
# prod the S3 adapter is configured and Memory would just be dead weight.
defp storage_children do
adapter =
case Application.get_env(:pulso, Pulso.Storage) do
nil -> nil
env -> Keyword.get(env, :adapter)
end

case adapter do
nil -> [Memory]
Memory -> [Memory]
_ -> []
end
end
end
Loading
Loading