DigitalOcean Droplet Cost Monitor

v1.0.1
1 PublicApp

A fault-tolerant cost monitoring system for DigitalOcean droplets using actor model.

MIT 14 downloads 1 favorites
Updated 3 months ago Repository
terminal

Install

wippy add butschster/domonitor

DigitalOcean Droplet Cost Monitor

A fault-tolerant cost monitoring system for DigitalOcean droplets using Wippy's actor model.

Features

  • Periodic Polling: Fetches droplet data from DigitalOcean API every 5 minutes
  • Cost Calculation: Computes monthly, daily, and hourly costs with detailed breakdown
  • Data Persistence: Stores cost history in SQLite database
  • HTTP API: REST endpoints to query current costs via http.endpoint
  • Threshold Alerting: Logs alerts when costs exceed configurable threshold
  • Fault Tolerance: Supervisor actor automatically restarts failed children
  • Actor Model: Clean separation of concerns via message passing

Quick Start

1. Configure Environment

Edit .env and add your DigitalOcean API token:

# Get your token from: https://cloud.digitalocean.com/account/api/tokens
DO_API_TOKEN=your_actual_token_here
DO_COST_THRESHOLD=100.00

Important: Never commit .env to version control! It's already in .gitignore.

2. Initialize Project

wippy init

3. Start Monitoring

wippy run butschster/domonitor

The supervisor will start all actors and begin monitoring.

HTTP API

The application exposes a REST API for querying cost data.

Base URL

http://localhost:8090/api/v1/domonitor

Endpoints

GET /costs/current

Returns the current calculated costs from memory store.

Entry: app.domonitor:endpoint_current_costs

Request:

curl http://localhost:8090/api/v1/domonitor/costs/current

Response (200 OK):

{
  "data": {
    "total_monthly": 150.00,
    "total_daily": 5.00,
    "total_hourly": 0.21,
    "droplet_count": 3,
    "droplets": [
      ...
    ]
  },
  "meta": {
    "source": "memory_store",
    "cached": true
  }
}

Response (404 Not Found):

{
  "error": "Not found",
  "message": "No cost data available yet. Wait for the next polling cycle."
}

Response (503 Service Unavailable):

{
  "error": "Service unavailable",
  "message": "Failed to connect to state store"
}

Architecture

Supervisor (do_supervisor)
    │
    ├── Persister (do_persister) ──→ SQLite DB + Memory Store
    │
    ├── Calculator (do_calculator)
    │       │
    │       └── costs_calculated ──→ Persister, Alerter
    │
    ├── Alerter (do_alerter) ──→ Log Alerts
    │
    └── Poller (do_poller)
            │
            └── droplets_fetched ──→ Calculator, Persister

Entry Registry

The project uses Wippy's entry registry system to define all components declaratively in _index.yaml files. Entries are identified by namespace:name format (e.g., app.domonitor:poller).

Entry Kinds Used

| Kind | Description | Example Entry | |----------------------|----------------------------------|----------------------------------------| | ns.definition | Namespace metadata | app.domonitor:definition | | env.variable | Environment variable reference | app.domonitor:do_api_token | | env.storage.file | File-based env storage (.env) | app:env | | env.storage.router | Routes env reads across storages | app:env_router | | db.sql.sqlite | SQLite database connection | app:db | | store.memory | In-memory key-value store | app:state_store | | http.service | HTTP server binding to port | app:gateway | | http.router | Route prefix + middleware | app.domonitor:api | | http.endpoint | Individual HTTP endpoint | app.domonitor:endpoint_current_costs | | function.lua | Reusable Lua function | app.domonitor:fetch_droplets | | process.lua | Actor process definition | app.domonitor:poller | | process.host | Process execution host | app.domonitor:processes | | process.service | Managed service with lifecycle | app.domonitor:supervisor_service |

Registry Files

  • src/_index.yaml - Shared infrastructure (database, store, HTTP gateway)
  • src/domonitor/_index.yaml - Application entries (actors, functions, endpoints)
  • src/domonitor/migrations/_index.yaml - Database migration entries

Finding Entries

Use registry.find() to query entries programmatically:

-- Find all Lua functions
local entries, err = registry.find({kind = "function.lua"})

-- Find endpoints in a namespace
local entries, err = registry.find({kind = "http.endpoint", namespace = "app.domonitor"})

Configuration

Environment Variables

The project supports two ways to configure environment variables:

  1. .env file (Recommended for development)
  2. OS environment variables (Recommended for production)

The .env file takes priority over OS environment variables.

Configuration via .env File

Create a .env file in the project root:

# DigitalOcean API Configuration
DO_API_TOKEN=your_digitalocean_token_here

# Cost Alerting
DO_COST_THRESHOLD=100.00

Configuration via OS Environment

export DO_API_TOKEN="your_digitalocean_api_token"
export DO_COST_THRESHOLD="100"

Available Variables

| Variable | Required | Default | Description | |---------------------|----------|---------|----------------------------------| | DO_API_TOKEN | Yes | - | DigitalOcean API token | | DO_COST_THRESHOLD | No | 100.00 | Monthly cost alert threshold ($) |

Environment Storage Architecture

The project uses a router pattern for environment storage defined in src/_index.yaml:

# File-based environment storage
- name: env
  kind: env.storage.file
  file_path: ".env"
  auto_create: true

# Router reads from file storage
- name: env_router
  kind: env.storage.router
  storages:
    - app:env  # .env file

Environment variables are accessed via entries in src/domonitor/_index.yaml:

- name: do_api_token
  kind: env.variable
  storage: app:env_router
  variable: DO_API_TOKEN
  • Reads: Routes through app:env_router to app:env (file storage)
  • Writes: Writes to .env file via env.set()
  • Security: .env file should have restricted permissions

Constants (in source)

| File | Constant | Default | Description | |-------------------------|-------------------------|---------|------------------------| | actors/poller.lua | POLL_INTERVAL | 5m | API polling interval | | actors/alerter.lua | ALERT_COOLDOWN | 3600 | Seconds between alerts | | actors/supervisor.lua | HEALTH_CHECK_INTERVAL | 30s | Health check frequency |

Viewing Data

Via HTTP API

The recommended way to access current cost data:

curl http://localhost:8090/api/v1/domonitor/costs/current

Via Memory Store (Lua)

Access the current_costs key directly in code:

local store = require("store")
local mem_store = store.get("app:state_store")
local costs_json = mem_store:get("current_costs")

Database Configuration

The database is configured in src/_index.yaml as app:db. By default it uses :memory: (in-memory SQLite). For persistent storage, change the file property:

- name: db
  kind: db.sql.sqlite
  file: "data/domonitor.db"  # File-based storage

Then query with:

sqlite3 data/domonitor.db "SELECT * FROM cost_history ORDER BY id DESC LIMIT 1;"

Project Structure

src/
├── _index.yaml                    # Shared infrastructure entries (app namespace)
│                                  #   - app:env (env.storage.file)
│                                  #   - app:env_router (env.storage.router)
│                                  #   - app:db (db.sql.sqlite)
│                                  #   - app:state_store (store.memory)
│                                  #   - app:gateway (http.service on :8090)
│
└── domonitor/
    ├── _index.yaml                # Application entries (app.domonitor namespace)
    │                              #   HTTP: api router, endpoint_current_costs
    │                              #   Functions: fetch_droplets, calculate_*
    │                              #   Actors: poller, calculator, persister, alerter, supervisor
    │                              #   Services: *_service with lifecycle management
    │
    ├── wippy.yaml                 # Module manifest
    ├── .env                       # Environment variables (gitignored)
    ├── README.md
    │
    ├── sdk/
    │   └── digitalocean.lua       # DO API client (fetch_droplets, fetch_droplet)
    │
    ├── calc/
    │   └── costs.lua              # Cost calculations (calculate_monthly_cost, calculate_daily_cost)
    │
    ├── handlers/
    │   └── costs.lua              # HTTP handler for GET /costs/current
    │
    ├── actors/
    │   ├── supervisor.lua         # Root supervisor - manages actor lifecycle
    │   ├── poller.lua             # Periodic API polling (5 min intervals)
    │   ├── calculator.lua         # Stateless cost computation
    │   ├── persister.lua          # DB + store writes
    │   └── alerter.lua            # Threshold-based notifications
    │
    └── migrations/
        ├── _index.yaml            # Migration entries
        └── 001_create_cost_history.lua

Troubleshooting

Actors Not Starting

  • Verify wippy.lock is current: wippy init
  • Check environment variables are set
  • Review logs for error messages

No Data in Database

  • Ensure migration ran: check for cost_history table
  • Verify DO API token is valid
  • Check network connectivity to DigitalOcean API

No Alerts

  • Verify costs exceed threshold
  • Check cooldown period (default 1 hour)
  • Review alerter logs for "suppressed by cooldown" messages

Supervision Issues

  • Supervisor logs health checks every 30 seconds
  • Check for "missing children detected" warnings
  • Review restart counts in health check logs

Development

Adding New Droplet Sizes

Edit calc/costs.lua and add to PRICING_TABLE:

local PRICING_TABLE = {
    -- ... existing entries
    ["new-size-slug"] = 99.00,
}

Changing Alert Behavior

The alerter logs structured warnings. To add webhooks or other notifications, modify actors/alerter.lua.

Manual Actor Restart

Send a message to the supervisor:

process.send(supervisor_pid, "restart_child", {name = "poller"})

License

MIT