diff --git a/.changeset/agent-db-naming.md b/.changeset/agent-db-naming.md new file mode 100644 index 0000000..5ce3e24 --- /dev/null +++ b/.changeset/agent-db-naming.md @@ -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. diff --git a/.changeset/agent-timestamps.md b/.changeset/agent-timestamps.md new file mode 100644 index 0000000..9206725 --- /dev/null +++ b/.changeset/agent-timestamps.md @@ -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. diff --git a/.changeset/agent-typed-outputs.md b/.changeset/agent-typed-outputs.md new file mode 100644 index 0000000..80c9373 --- /dev/null +++ b/.changeset/agent-typed-outputs.md @@ -0,0 +1,5 @@ +--- +'@opsen/agent': minor +--- + +Replace `Output` with strongly typed response interfaces on all dynamic resource outputs (ComposeProject, Database, DatabaseRole, IngressRoutes). diff --git a/packages/agent/go/Dockerfile.build b/packages/agent/go/Dockerfile.build index ffef776..d20f2d8 100644 --- a/packages/agent/go/Dockerfile.build +++ b/packages/agent/go/Dockerfile.build @@ -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 diff --git a/packages/agent/go/cmd/opsen-agent/main.go b/packages/agent/go/cmd/opsen-agent/main.go index 0282ad9..5e05517 100644 --- a/packages/agent/go/cmd/opsen-agent/main.go +++ b/packages/agent/go/cmd/opsen-agent/main.go @@ -2,6 +2,7 @@ package main import ( "flag" + "fmt" "log/slog" "os" "os/signal" @@ -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) @@ -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) diff --git a/packages/agent/go/internal/roles/compose/tracker.go b/packages/agent/go/internal/roles/compose/tracker.go index 0994bc3..09290d3 100644 --- a/packages/agent/go/internal/roles/compose/tracker.go +++ b/packages/agent/go/internal/roles/compose/tracker.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "sync" + "time" "github.com/opsen/agent/internal/config" ) @@ -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. @@ -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() } diff --git a/packages/agent/go/internal/roles/db/handler.go b/packages/agent/go/internal/roles/db/handler.go index 8f37994..9ce6aff 100644 --- a/packages/agent/go/internal/roles/db/handler.go +++ b/packages/agent/go/internal/roles/db/handler.go @@ -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{ @@ -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, }) } @@ -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 @@ -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, }) } } @@ -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, @@ -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 diff --git a/packages/agent/go/internal/roles/db/tracker.go b/packages/agent/go/internal/roles/db/tracker.go index e856db3..3a3b30f 100644 --- a/packages/agent/go/internal/roles/db/tracker.go +++ b/packages/agent/go/internal/roles/db/tracker.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "sync" + "time" ) // DatabaseRecord tracks a single provisioned database. @@ -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. @@ -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() } @@ -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() diff --git a/packages/agent/go/internal/roles/db/validate.go b/packages/agent/go/internal/roles/db/validate.go index a81bdf9..c144630 100644 --- a/packages/agent/go/internal/roles/db/validate.go +++ b/packages/agent/go/internal/roles/db/validate.go @@ -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 @@ -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) @@ -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 } diff --git a/packages/agent/go/internal/server/server.go b/packages/agent/go/internal/server/server.go index f47a894..db4ebb4 100644 --- a/packages/agent/go/internal/server/server.go +++ b/packages/agent/go/internal/server/server.go @@ -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) diff --git a/packages/agent/src/agent-installer.ts b/packages/agent/src/agent-installer.ts index 533dc35..54e5e11 100644 --- a/packages/agent/src/agent-installer.ts +++ b/packages/agent/src/agent-installer.ts @@ -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 | undefined, cmd: string): pulumi.Output { @@ -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 }, diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 815374c..76dd607 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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' diff --git a/packages/agent/src/resources/compose-project.ts b/packages/agent/src/resources/compose-project.ts index 22803bd..7c62aea 100644 --- a/packages/agent/src/resources/compose-project.ts +++ b/packages/agent/src/resources/compose-project.ts @@ -8,13 +8,22 @@ interface ComposeProjectInputs { files: Record } +/** Response from the agent's compose deploy endpoint. */ +export interface ComposeDeployResult { + status: string + project: string + services?: string[] + policy_modifications?: string[] + ports?: PortMappings +} + const composeProjectProvider: pulumi.dynamic.ResourceProvider = { async create(inputs: ComposeProjectInputs) { const resp = await agentRequest(inputs.connection, 'PUT', `/v1/compose/projects/${inputs.project}`, { files: inputs.files, }) checkResponse(resp, [200]) - const body = resp.body as Record + const body = resp.body as ComposeDeployResult return { id: inputs.project, outs: { ...inputs, deployResult: body, ports: body.ports }, @@ -34,7 +43,7 @@ const composeProjectProvider: pulumi.dynamic.ResourceProvider = { files: news.files, }) checkResponse(resp, [200]) - const body = resp.body as Record + const body = resp.body as ComposeDeployResult return { outs: { ...news, deployResult: body, ports: body.ports } } }, @@ -69,7 +78,7 @@ export type PortMappings = Record> export class ComposeProject extends pulumi.dynamic.Resource { declare readonly project: pulumi.Output - declare readonly deployResult: pulumi.Output + declare readonly deployResult: pulumi.Output /** Allocated host port mappings: service → container_port → host_port */ declare readonly ports: pulumi.Output diff --git a/packages/agent/src/resources/database-role.ts b/packages/agent/src/resources/database-role.ts index 4baa733..7b17f0c 100644 --- a/packages/agent/src/resources/database-role.ts +++ b/packages/agent/src/resources/database-role.ts @@ -10,6 +10,12 @@ interface DatabaseRoleInputs { readOnly: boolean } +/** Response from the agent's role create endpoint. */ +export interface DatabaseRoleCreateResult { + status: string + role: string +} + const databaseRoleProvider: pulumi.dynamic.ResourceProvider = { async create(inputs: DatabaseRoleInputs) { const resp = await agentRequest( @@ -24,7 +30,7 @@ const databaseRoleProvider: pulumi.dynamic.ResourceProvider = { checkResponse(resp, [200, 201]) return { id: `${inputs.database}/${inputs.role}`, - outs: { ...inputs, createResult: resp.body }, + outs: { ...inputs, createResult: resp.body as DatabaseRoleCreateResult }, } }, @@ -43,7 +49,7 @@ const databaseRoleProvider: pulumi.dynamic.ResourceProvider = { read_only: news.readOnly, }) checkResponse(resp, [200, 201]) - return { outs: { ...news, createResult: resp.body } } + return { outs: { ...news, createResult: resp.body as DatabaseRoleCreateResult } } }, async delete(_id, props: DatabaseRoleInputs) { @@ -86,7 +92,7 @@ export interface DatabaseRoleArgs { export class DatabaseRole extends pulumi.dynamic.Resource { declare readonly database: pulumi.Output declare readonly role: pulumi.Output - declare readonly createResult: pulumi.Output + declare readonly createResult: pulumi.Output constructor(name: string, args: DatabaseRoleArgs, opts?: pulumi.CustomResourceOptions) { super(databaseRoleProvider, name, { ...args, readOnly: args.readOnly ?? false, createResult: undefined }, opts) diff --git a/packages/agent/src/resources/database.ts b/packages/agent/src/resources/database.ts index a941665..108e425 100644 --- a/packages/agent/src/resources/database.ts +++ b/packages/agent/src/resources/database.ts @@ -19,6 +19,15 @@ interface DatabaseInputs { extensions?: string[] } +/** Response from the agent's database create endpoint. */ +export interface DatabaseCreateResult { + status: string + database: string + owner: string + host: string + port: number +} + const databaseProvider: pulumi.dynamic.ResourceProvider = { async create(inputs: DatabaseInputs) { const resp = await agentRequest(inputs.connection, 'PUT', `/v1/db/databases/${inputs.name}`, { @@ -29,7 +38,7 @@ const databaseProvider: pulumi.dynamic.ResourceProvider = { checkResponse(resp, [200, 201]) return { id: inputs.name, - outs: { ...inputs, createResult: resp.body }, + outs: { ...inputs, createResult: resp.body as DatabaseCreateResult }, } }, @@ -52,7 +61,7 @@ const databaseProvider: pulumi.dynamic.ResourceProvider = { limits: news.limits, }) checkResponse(resp, [200]) - return { outs: { ...news, updateResult: resp.body } } + return { outs: { ...news, createResult: resp.body as DatabaseCreateResult } } } // Otherwise full replace via PUT @@ -62,7 +71,7 @@ const databaseProvider: pulumi.dynamic.ResourceProvider = { extensions: news.extensions, }) checkResponse(resp, [200, 201]) - return { outs: { ...news, createResult: resp.body } } + return { outs: { ...news, createResult: resp.body as DatabaseCreateResult } } }, async delete(id, props: DatabaseInputs) { @@ -102,7 +111,7 @@ export interface DatabaseArgs { export class Database extends pulumi.dynamic.Resource { declare readonly name: pulumi.Output declare readonly owner: pulumi.Output<{ username: string; password: string }> - declare readonly createResult: pulumi.Output + declare readonly createResult: pulumi.Output constructor(name: string, args: DatabaseArgs, opts?: pulumi.CustomResourceOptions) { super(databaseProvider, name, { ...args, createResult: undefined }, opts) diff --git a/packages/agent/src/resources/ingress-routes.ts b/packages/agent/src/resources/ingress-routes.ts index b95ecff..5697dc1 100644 --- a/packages/agent/src/resources/ingress-routes.ts +++ b/packages/agent/src/resources/ingress-routes.ts @@ -24,6 +24,14 @@ interface IngressRoutesInputs { routes: IngressRoute[] } +/** Response from the agent's ingress route update endpoint. */ +export interface IngressUpdateResult { + status: string + app: string + routes: number + policy_modifications: string[] +} + /** Transform TypeScript route to Go API format (camelCase → snake_case, domain → hosts). */ function toApiRoute(route: IngressRoute) { return { @@ -46,7 +54,7 @@ const ingressRoutesProvider: pulumi.dynamic.ResourceProvider = { checkResponse(resp, [200]) return { id: inputs.app, - outs: { ...inputs, updateResult: resp.body }, + outs: { ...inputs, updateResult: resp.body as IngressUpdateResult }, } }, @@ -63,7 +71,7 @@ const ingressRoutesProvider: pulumi.dynamic.ResourceProvider = { routes: news.routes.map(toApiRoute), }) checkResponse(resp, [200]) - return { outs: { ...news, updateResult: resp.body } } + return { outs: { ...news, updateResult: resp.body as IngressUpdateResult } } }, async delete(_id, props: IngressRoutesInputs) { @@ -113,7 +121,7 @@ export interface IngressRoutesArgs { export class IngressRoutes extends pulumi.dynamic.Resource { declare readonly app: pulumi.Output declare readonly routes: pulumi.Output - declare readonly updateResult: pulumi.Output + declare readonly updateResult: pulumi.Output constructor(name: string, args: IngressRoutesArgs, opts?: pulumi.CustomResourceOptions) { super(ingressRoutesProvider, name, { ...args, updateResult: undefined }, opts)