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
5 changes: 5 additions & 0 deletions .changeset/agent-db-naming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opsen/agent': minor
---

Remove opsen\_ prefix and client scoping from database and role names. Clients now have full control over naming — database and role names are globally unique, first-come-first-served.
5 changes: 5 additions & 0 deletions .changeset/agent-timestamps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opsen/agent': minor
---

Track created_at and modified_at timestamps in state for databases and compose projects. Timestamps are surfaced in status API responses.
5 changes: 5 additions & 0 deletions .changeset/agent-typed-outputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opsen/agent': minor
---

Replace `Output<unknown>` with strongly typed response interfaces on all dynamic resource outputs (ComposeProject, Database, DatabaseRole, IngressRoutes).
3 changes: 2 additions & 1 deletion packages/agent/go/Dockerfile.build
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /opsen-agent ./cmd/opsen-agent
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${VERSION}" -o /opsen-agent ./cmd/opsen-agent

FROM scratch
COPY --from=builder /opsen-agent /opsen-agent
11 changes: 10 additions & 1 deletion packages/agent/go/cmd/opsen-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
Expand All @@ -12,10 +13,18 @@ import (
"github.com/opsen/agent/internal/server"
)

var Version = "dev"

func main() {
configPath := flag.String("config", "/etc/opsen-agent/agent.yaml", "path to agent config file")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()

if *showVersion {
fmt.Println(Version)
os.Exit(0)
}

cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("failed to load config", "error", err)
Expand All @@ -34,7 +43,7 @@ func main() {
go clientStore.Watch()
}

srv, err := server.New(cfg, clientStore, logger)
srv, err := server.New(cfg, clientStore, logger, Version)
if err != nil {
slog.Error("failed to create server", "error", err)
os.Exit(1)
Expand Down
10 changes: 10 additions & 0 deletions packages/agent/go/internal/roles/compose/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"sync"
"time"

"github.com/opsen/agent/internal/config"
)
Expand All @@ -16,6 +17,8 @@ type ProjectResources struct {
MemoryMb int `json:"memory_mb"`
Cpus float64 `json:"cpus"`
PolicyHash string `json:"policy_hash,omitempty"` // hash of policy fields affecting deployment
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
}

// TotalResources is the aggregated usage across all projects for a client.
Expand Down Expand Up @@ -130,11 +133,18 @@ func (t *ResourceTracker) Set(clientName, projectName string, resources *Project
t.mu.Lock()
defer t.mu.Unlock()

now := time.Now().UTC().Format(time.RFC3339)
client := t.Clients[clientName]
if client == nil {
client = &ClientResources{Projects: make(map[string]*ProjectResources)}
t.Clients[clientName] = client
}
if existing := client.Projects[projectName]; existing != nil && existing.CreatedAt != "" {
resources.CreatedAt = existing.CreatedAt
} else {
resources.CreatedAt = now
}
resources.ModifiedAt = now
client.Projects[projectName] = resources
t.save()
}
Expand Down
47 changes: 34 additions & 13 deletions packages/agent/go/internal/roles/db/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,19 @@ func (h *Handler) CreateDatabase(w http.ResponseWriter, r *http.Request) {
return
}

dbName := fmt.Sprintf("opsen_%s_%s", client.Client, name)
roleName := fmt.Sprintf("opsen_%s_%s_%s", client.Client, name, req.Owner.Username)
// Database names are globally unique — first-come-first-served
dbName := name
roleName := req.Owner.Username

if owner := h.tracker.DatabaseOwner(dbName); owner != "" {
writeJSON(w, http.StatusConflict, map[string]string{"error": fmt.Sprintf("database name '%s' is already taken", dbName)})
return
}

if h.tracker.RoleInUse(roleName) {
writeJSON(w, http.StatusConflict, map[string]string{"error": fmt.Sprintf("role name '%s' is already taken", roleName)})
return
}

// Create role
roleOpts := RoleOptions{
Expand Down Expand Up @@ -250,15 +261,17 @@ func (h *Handler) DatabaseStatus(w http.ResponseWriter, r *http.Request) {
}

writeJSON(w, http.StatusOK, map[string]any{
"database": record.DatabaseName,
"owner": record.OwnerRole,
"additional_roles": record.AdditionalRoles,
"size_mb": sizeMb,
"max_size_mb": record.MaxSizeMb,
"connection_limit": record.ConnectionLimit,
"database": record.DatabaseName,
"owner": record.OwnerRole,
"additional_roles": record.AdditionalRoles,
"size_mb": sizeMb,
"max_size_mb": record.MaxSizeMb,
"connection_limit": record.ConnectionLimit,
"active_connections": connCount,
"extensions": record.Extensions,
"quota_exceeded": record.QuotaExceeded,
"extensions": record.Extensions,
"quota_exceeded": record.QuotaExceeded,
"created_at": record.CreatedAt,
"modified_at": record.ModifiedAt,
})
}

Expand All @@ -271,6 +284,8 @@ func (h *Handler) listDatabases(w http.ResponseWriter, client *config.ClientPoli
SizeMb int `json:"size_mb"`
MaxSizeMb int `json:"max_size_mb"`
QuotaExceeded bool `json:"quota_exceeded"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
}

var databases []dbInfo
Expand All @@ -286,6 +301,8 @@ func (h *Handler) listDatabases(w http.ResponseWriter, client *config.ClientPoli
SizeMb: sizeMb,
MaxSizeMb: record.MaxSizeMb,
QuotaExceeded: record.QuotaExceeded,
CreatedAt: record.CreatedAt,
ModifiedAt: record.ModifiedAt,
})
}
}
Expand Down Expand Up @@ -419,7 +436,12 @@ func (h *Handler) CreateRole(w http.ResponseWriter, r *http.Request) {
return
}

roleName := fmt.Sprintf("opsen_%s_%s_%s", client.Client, dbName, username)
roleName := username

if h.tracker.RoleInUse(roleName) {
writeJSON(w, http.StatusConflict, map[string]string{"error": fmt.Sprintf("role name '%s' is already taken", roleName)})
return
}

roleOpts := RoleOptions{
Password: req.Password,
Expand Down Expand Up @@ -464,8 +486,7 @@ func (h *Handler) DropRole(w http.ResponseWriter, r *http.Request) {
return
}

// Apply same prefix as CreateRole
roleName := fmt.Sprintf("opsen_%s_%s_%s", client.Client, dbName, username)
roleName := username

// Find and remove role from additional roles
found := false
Expand Down
43 changes: 43 additions & 0 deletions packages/agent/go/internal/roles/db/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"sync"
"time"
)

// DatabaseRecord tracks a single provisioned database.
Expand All @@ -17,6 +18,8 @@ type DatabaseRecord struct {
MaxSizeMb int `json:"max_size_mb"`
Extensions []string `json:"extensions"`
QuotaExceeded bool `json:"quota_exceeded"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
}

// ClientDatabases tracks all databases for one client.
Expand Down Expand Up @@ -78,11 +81,18 @@ func (t *ResourceTracker) Set(clientName, dbName string, record *DatabaseRecord)
t.mu.Lock()
defer t.mu.Unlock()

now := time.Now().UTC().Format(time.RFC3339)
client := t.Clients[clientName]
if client == nil {
client = &ClientDatabases{Databases: make(map[string]*DatabaseRecord)}
t.Clients[clientName] = client
}
if existing := client.Databases[dbName]; existing != nil && existing.CreatedAt != "" {
record.CreatedAt = existing.CreatedAt
} else {
record.CreatedAt = now
}
record.ModifiedAt = now
client.Databases[dbName] = record
t.save()
}
Expand Down Expand Up @@ -151,6 +161,39 @@ func (t *ResourceTracker) SetQuotaExceeded(clientName, dbName string, exceeded b
t.save()
}

// DatabaseOwner returns the client that owns a database name, or "" if available.
func (t *ResourceTracker) DatabaseOwner(dbName string) string {
t.mu.RLock()
defer t.mu.RUnlock()

for clientName, client := range t.Clients {
if _, ok := client.Databases[dbName]; ok {
return clientName
}
}
return ""
}

// RoleInUse checks if a role name is used by any database across all clients.
func (t *ResourceTracker) RoleInUse(roleName string) bool {
t.mu.RLock()
defer t.mu.RUnlock()

for _, client := range t.Clients {
for _, record := range client.Databases {
if record.OwnerRole == roleName {
return true
}
for _, r := range record.AdditionalRoles {
if r == roleName {
return true
}
}
}
}
return false
}

// AllDatabases returns all database records across all clients.
func (t *ResourceTracker) AllDatabases() map[string]map[string]*DatabaseRecord {
t.mu.RLock()
Expand Down
47 changes: 8 additions & 39 deletions packages/agent/go/internal/roles/db/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func validateCreateRequest(name string, req *CreateDatabaseRequest, policy *conf
return violations
}

// validateDatabaseName checks the database name against policy.
// validateDatabaseName checks the database name is a valid PostgreSQL identifier.
func validateDatabaseName(name string, policy *config.DbPolicy) []string {
var violations []string

Expand All @@ -68,52 +68,28 @@ func validateDatabaseName(name string, policy *config.DbPolicy) []string {
return violations
}

if len(name) > 50 {
violations = append(violations, "database name too long: maximum 50 characters")
}

// Only allow lowercase alphanumeric and underscores
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
violations = append(violations, "database name must contain only lowercase letters, digits, and underscores")
break
}
}

// Must start with a letter
if len(name) > 0 && !(name[0] >= 'a' && name[0] <= 'z') {
violations = append(violations, "database name must start with a letter")
if !isValidIdentifier(name) {
violations = append(violations, "database name must be 1-63 characters, lowercase letters/digits/underscores, starting with a letter")
}

return violations
}

// validateUsername checks a username against the client's username policy.
// validateUsername checks a username is a valid PostgreSQL identifier and respects policy restrictions.
func validateUsername(username string, policy *config.DbPolicy) []string {
var violations []string
up := policy.Username

if username == "" {
violations = append(violations, "username is required")
return violations
}

if up.MaxLength > 0 && len(username) > up.MaxLength {
violations = append(violations, fmt.Sprintf("username too long: maximum %d characters", up.MaxLength))
}

// Only allow lowercase alphanumeric and underscores
for _, c := range username {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
violations = append(violations, "username must contain only lowercase letters, digits, and underscores")
break
}
if !isValidIdentifier(username) {
violations = append(violations, "username must be 1-63 characters, lowercase letters/digits/underscores, starting with a letter")
return violations
}

// Must start with a letter
if len(username) > 0 && !(username[0] >= 'a' && username[0] <= 'z') {
violations = append(violations, "username must start with a letter")
}
up := policy.Username

// Check denied names
lower := strings.ToLower(username)
Expand All @@ -130,13 +106,6 @@ func validateUsername(username string, policy *config.DbPolicy) []string {
}
}

// Check required prefix
if up.RequiredPrefix != "" {
if !strings.HasPrefix(lower, strings.ToLower(up.RequiredPrefix)) {
violations = append(violations, fmt.Sprintf("username must start with '%s'", up.RequiredPrefix))
}
}

return violations
}

Expand Down
5 changes: 3 additions & 2 deletions packages/agent/go/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ type Server struct {
composeHandler *compose.Handler
}

func New(cfg *config.AgentConfig, clientStore *config.ClientStore, logger *slog.Logger) (*Server, error) {
func New(cfg *config.AgentConfig, clientStore *config.ClientStore, logger *slog.Logger, version string) (*Server, error) {
mux := http.NewServeMux()

// Health endpoint — no auth required
healthBody := fmt.Sprintf(`{"status":"ok","version":%q}`, version)
mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
w.Write([]byte(healthBody))
})

// Compose role (Docker Compose project deployments)
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/agent-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { serializeAgentConfig, serializeClientPolicy } from './config'
import type { AgentInstallerArgs } from './types'

const GO_SRC_DIR = path.resolve(__dirname, '..', 'go')
const PKG_VERSION: string = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8')).version

/** Wraps a shell command with sudo when the SSH user is not root. */
function sudo(connUser: pulumi.Input<string> | undefined, cmd: string): pulumi.Output<string> {
Expand Down Expand Up @@ -40,8 +41,7 @@ export class AgentInstaller extends pulumi.ComponentResource {
`${name}-build`,
{
dir: GO_SRC_DIR,
create:
'docker build -f Dockerfile.build -o type=local,dest=./out . 2>&1 && sha256sum ./out/opsen-agent | cut -d" " -f1',
create: `docker build --build-arg VERSION=${PKG_VERSION} -f Dockerfile.build -o type=local,dest=./out . 2>&1 && sha256sum ./out/opsen-agent | cut -d" " -f1`,
triggers: [sourceHash],
},
{ parent: this },
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export type { PlatformCA, IssuedCert } from './pki'

// Dynamic resources
export { ComposeProject } from './resources/compose-project'
export type { ComposeProjectArgs, PortMappings } from './resources/compose-project'
export type { ComposeProjectArgs, PortMappings, ComposeDeployResult } from './resources/compose-project'
export { IngressRoutes } from './resources/ingress-routes'
export type { IngressRoutesArgs, IngressRouteArgs } from './resources/ingress-routes'
export type { IngressRoutesArgs, IngressRouteArgs, IngressUpdateResult } from './resources/ingress-routes'
export { Database } from './resources/database'
export type { DatabaseArgs } from './resources/database'
export type { DatabaseArgs, DatabaseCreateResult } from './resources/database'
export { DatabaseRole } from './resources/database-role'
export type { DatabaseRoleArgs } from './resources/database-role'
export type { DatabaseRoleArgs, DatabaseRoleCreateResult } from './resources/database-role'
Loading
Loading