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
58 changes: 58 additions & 0 deletions docs/pages/guides/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ docker run --rm hookdeck/outpost migrate plan
Execute the migration (creates new structures, preserves old ones):

```bash
# Apply the next pending migration
docker run --rm -it hookdeck/outpost migrate apply

# Or apply all pending migrations in sequence
docker run --rm -it hookdeck/outpost migrate apply --all
```

The `--all` flag runs each pending migration in version order, automatically verifying each one before proceeding to the next. If any migration fails verification, the process stops immediately.

:::info
The `-it` flags enable interactive mode for confirmation prompts. Add `--yes` to skip confirmations in automated environments.
:::
Expand Down Expand Up @@ -101,6 +107,58 @@ Cleanup operations require successful verification by default:
- Prevents accidental data loss
- Can be overridden with `--force` flag (use with caution)

## Running in Private Environments

Redis is often deployed in private networks (VPCs, internal subnets) that aren't accessible from your local machine. The migration tool needs direct network access to Redis, so you'll need to run it from within the same network.

### Common Approaches

**Run from an existing host in the network:**

If you have SSH access to a host that can reach Redis (e.g., your application server), you can run the migration there:

```bash
# SSH into a host with Redis access
ssh your-server

# Run migration using Docker
docker run --rm -it \
-e REDIS_HOST=redis.internal \
-e REDIS_PASSWORD=... \
hookdeck/outpost:v0.12.0 migrate apply --all
```

**Kubernetes:**

Run the migration as a one-off pod in the same namespace/cluster:

```bash
kubectl run outpost-migrate --rm -it --restart=Never \
--image=hookdeck/outpost:v0.12.0 \
--env="REDIS_HOST=redis.internal" \
--env="REDIS_PASSWORD=..." \
-- migrate apply --all
```

**Port forwarding:**

If direct access isn't possible, you can forward the Redis port to your local machine:

```bash
# Example: SSH tunnel
ssh -L 6379:redis.internal:6379 bastion-host

# Then run migration locally
docker run --rm -it \
-e REDIS_HOST=host.docker.internal \
-e REDIS_PORT=6379 \
hookdeck/outpost:v0.12.0 migrate apply --all
```

:::tip
Use the same network configuration that Outpost itself uses to connect to Redis. If Outpost can reach Redis, running the migration tool in the same environment will work.
:::

## Configuration

The `outpost` CLI tool uses the same configuration as Outpost itself. Configuration can be provided through environment variables, a configuration file (using `--config` flag), or CLI flags (e.g., `--redis-host`, `--redis-password`).
Expand Down
23 changes: 10 additions & 13 deletions docs/pages/guides/upgrade-v0.12.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,11 @@ See the [API Reference](/docs/api/tenants#list-tenants) for details.
### Migrations Included

- **002_timestamps**: Converts timestamp fields to Unix format for timezone-agnostic sorting
- **003_entity**: Creates RediSearch indexes for tenant listing
- **003_entity**: Adds entity field to tenant and destination records for RediSearch filtering

### Running Migrations

Migrations run automatically at startup. For production environments, manual migration is recommended.
These migrations require a maintenance window and must be run manually before starting Outpost v0.12.

:::note
These commands require environment variables for Redis connection (`REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, etc.). You can also use `--config` to pass a config file. See the [Schema Migration Guide](/guides/migration#configuration) for details.
Expand All @@ -249,19 +249,16 @@ These commands require environment variables for Redis connection (`REDIS_HOST`,
# Preview changes
docker run --rm -e REDIS_HOST=... -e REDIS_PASSWORD=... hookdeck/outpost:v0.12.0 migrate plan

# Apply migrations
# Apply the next pending migration (one at a time)
docker run --rm -it -e REDIS_HOST=... -e REDIS_PASSWORD=... hookdeck/outpost:v0.12.0 migrate apply

# Apply all pending migrations in sequence
docker run --rm -it -e REDIS_HOST=... -e REDIS_PASSWORD=... hookdeck/outpost:v0.12.0 migrate apply --all

# Verify migrations
docker run --rm -e REDIS_HOST=... -e REDIS_PASSWORD=... hookdeck/outpost:v0.12.0 migrate verify
```

For systems with high write throughput, re-run migrations after the initial upgrade:

```bash
docker run --rm -it -e REDIS_HOST=... -e REDIS_PASSWORD=... hookdeck/outpost:v0.12.0 migrate apply --rerun
```

:::tip[Migration Tool Reference]
For detailed information on the migration workflow, safety features, and troubleshooting, see the [Schema Migration Guide](/guides/migration).
:::
Expand All @@ -278,11 +275,11 @@ For detailed information on the migration workflow, safety features, and trouble
- [ ] Back up Redis data

2. **During upgrade:**

- [ ] Stop Outpost
- [ ] Run migrations: `migrate apply --all`
- [ ] Verify migrations completed: `migrate verify`
- [ ] Set webhook signature environment variables if maintaining backward compatibility
- [ ] Run migrations (automatic or manual)

3. **After upgrading:**
- [ ] Verify migrations completed: `migrate verify`
- [ ] For high-volume systems: `migrate apply --rerun`
- [ ] Start Outpost v0.12
- [ ] Test webhook signature verification
10 changes: 10 additions & 0 deletions internal/apirouter/log_handlers.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package apirouter

import (
"errors"
"net/http"
"strconv"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/hookdeck/outpost/internal/cursor"
"github.com/hookdeck/outpost/internal/logging"
"github.com/hookdeck/outpost/internal/logstore"
"github.com/hookdeck/outpost/internal/models"
Expand Down Expand Up @@ -269,6 +271,10 @@ func (h *LogHandlers) listDeliveriesInternal(c *gin.Context, tenantID string) {

response, err := h.logStore.ListDeliveryEvent(c.Request.Context(), req)
if err != nil {
if errors.Is(err, cursor.ErrInvalidCursor) || errors.Is(err, cursor.ErrVersionMismatch) {
AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(err))
return
}
AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err))
return
}
Expand Down Expand Up @@ -431,6 +437,10 @@ func (h *LogHandlers) listEventsInternal(c *gin.Context, tenantID string) {

response, err := h.logStore.ListEvent(c.Request.Context(), req)
if err != nil {
if errors.Is(err, cursor.ErrInvalidCursor) || errors.Is(err, cursor.ErrVersionMismatch) {
AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(err))
return
}
AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err))
return
}
Expand Down
80 changes: 80 additions & 0 deletions internal/cursor/cursor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Package cursor provides a unified cursor encoding/decoding utility for pagination.
// Cursors are versioned and resource-scoped, allowing different parts of the system
// to use cursors without collision.
package cursor

import (
"errors"
"fmt"
"math/big"
"strings"
)

var (
// ErrInvalidCursor indicates the cursor is malformed or cannot be decoded.
ErrInvalidCursor = errors.New("invalid cursor")

// ErrVersionMismatch indicates the cursor version doesn't match the expected version.
ErrVersionMismatch = errors.New("cursor version mismatch")
)

// Base62Encode encodes a string to base62.
func Base62Encode(s string) string {
if s == "" {
return ""
}
num := new(big.Int)
num.SetBytes([]byte(s))
return num.Text(62)
}

// Base62Decode decodes a base62 string.
func Base62Decode(s string) (string, error) {
if s == "" {
return "", nil
}
num := new(big.Int)
num, ok := num.SetString(s, 62)
if !ok {
return "", ErrInvalidCursor
}
return string(num.Bytes()), nil
}

// Encode creates a versioned cursor string.
// Format: {resource}v{version:02d}:{data}, then base62 encoded.
// Example: "evtv01:position_data" -> base62
func Encode(resource string, version int, data string) string {
raw := fmt.Sprintf("%sv%02d:%s", resource, version, data)
return Base62Encode(raw)
}

// Decode decodes and validates a cursor string.
// Returns the data portion if the cursor matches the expected resource and version.
// Returns ErrInvalidCursor if the cursor is malformed.
// Returns ErrVersionMismatch if the version doesn't match.
func Decode(encoded string, resource string, version int) (string, error) {
if encoded == "" {
return "", nil
}

raw, err := Base62Decode(encoded)
if err != nil {
return "", err
}

// Expected prefix: {resource}v{version:02d}:
expectedPrefix := fmt.Sprintf("%sv%02d:", resource, version)

if !strings.HasPrefix(raw, expectedPrefix) {
// Check if it's a version mismatch vs completely invalid
resourcePrefix := resource + "v"
if strings.HasPrefix(raw, resourcePrefix) {
// Has correct resource but wrong version
return "", fmt.Errorf("%w: expected version %02d", ErrVersionMismatch, version)
}
return "", ErrInvalidCursor
}

return raw[len(expectedPrefix):], nil
}
Loading