456 lines
15 KiB
Go
456 lines
15 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
)
|
|
|
|
// User represents a tenant who can create and manage dev pods.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Quota Quota `json:"quota"`
|
|
}
|
|
|
|
// Quota defines resource limits for a user.
|
|
type Quota struct {
|
|
MaxConcurrentPods int `json:"max_concurrent_pods"`
|
|
MaxCPUPerPod int `json:"max_cpu_per_pod"`
|
|
MaxRAMGBPerPod int `json:"max_ram_gb_per_pod"`
|
|
MonthlyPodHours int `json:"monthly_pod_hours"`
|
|
MonthlyAIRequests int `json:"monthly_ai_requests"`
|
|
}
|
|
|
|
// DefaultQuota returns the default quota for new users.
|
|
func DefaultQuota() Quota {
|
|
return Quota{
|
|
MaxConcurrentPods: 3,
|
|
MaxCPUPerPod: 8,
|
|
MaxRAMGBPerPod: 16,
|
|
MonthlyPodHours: 500,
|
|
MonthlyAIRequests: 10000,
|
|
}
|
|
}
|
|
|
|
// ApplyOverrides applies non-nil fields from an UpdateQuotasRequest onto q.
|
|
func (q *Quota) ApplyOverrides(o UpdateQuotasRequest) {
|
|
if o.MaxConcurrentPods != nil {
|
|
q.MaxConcurrentPods = *o.MaxConcurrentPods
|
|
}
|
|
if o.MaxCPUPerPod != nil {
|
|
q.MaxCPUPerPod = *o.MaxCPUPerPod
|
|
}
|
|
if o.MaxRAMGBPerPod != nil {
|
|
q.MaxRAMGBPerPod = *o.MaxRAMGBPerPod
|
|
}
|
|
if o.MonthlyPodHours != nil {
|
|
q.MonthlyPodHours = *o.MonthlyPodHours
|
|
}
|
|
if o.MonthlyAIRequests != nil {
|
|
q.MonthlyAIRequests = *o.MonthlyAIRequests
|
|
}
|
|
}
|
|
|
|
// Validate checks that all quota values are positive.
|
|
func (q *Quota) Validate() error {
|
|
if q.MaxConcurrentPods < 1 {
|
|
return fmt.Errorf("max_concurrent_pods must be at least 1")
|
|
}
|
|
if q.MaxCPUPerPod < 1 {
|
|
return fmt.Errorf("max_cpu_per_pod must be at least 1")
|
|
}
|
|
if q.MaxRAMGBPerPod < 1 {
|
|
return fmt.Errorf("max_ram_gb_per_pod must be at least 1")
|
|
}
|
|
if q.MonthlyPodHours < 1 {
|
|
return fmt.Errorf("monthly_pod_hours must be at least 1")
|
|
}
|
|
if q.MonthlyAIRequests < 0 {
|
|
return fmt.Errorf("monthly_ai_requests must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// APIKey represents an authentication key for API access.
|
|
type APIKey struct {
|
|
KeyHash string `json:"-"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
|
}
|
|
|
|
// Role constants for API keys.
|
|
const (
|
|
RoleAdmin = "admin"
|
|
RoleUser = "user"
|
|
)
|
|
|
|
// APIKeyPrefix is the prefix for generated API keys.
|
|
const APIKeyPrefix = "dpk_"
|
|
|
|
// Pod represents a running dev pod.
|
|
type Pod struct {
|
|
User string `json:"user"`
|
|
Name string `json:"name"`
|
|
Tools string `json:"tools"`
|
|
CPUReq string `json:"cpu_req"`
|
|
CPULimit string `json:"cpu_limit"`
|
|
MemReq string `json:"mem_req"`
|
|
MemLimit string `json:"mem_limit"`
|
|
Task string `json:"task,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Age string `json:"age,omitempty"`
|
|
CPUUsage string `json:"cpu_usage,omitempty"`
|
|
MemUsage string `json:"mem_usage,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
// UsageRecord tracks resource usage events.
|
|
type UsageRecord struct {
|
|
ID int64 `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
PodName string `json:"pod_name"`
|
|
EventType string `json:"event_type"`
|
|
Value float64 `json:"value"`
|
|
RecordedAt time.Time `json:"recorded_at"`
|
|
}
|
|
|
|
// Event type constants for usage records.
|
|
const (
|
|
EventPodStart = "pod_start"
|
|
EventPodStop = "pod_stop"
|
|
EventCPUSample = "cpu_sample"
|
|
EventMemSample = "mem_sample"
|
|
EventAIRequest = "ai_request"
|
|
)
|
|
|
|
// UsageSummary holds aggregated usage for a billing period.
|
|
type UsageSummary struct {
|
|
PodHours float64 `json:"pod_hours"`
|
|
CPUHours float64 `json:"cpu_hours"`
|
|
AIRequests int64 `json:"ai_requests"`
|
|
BudgetUsedPct float64 `json:"budget_used_pct"`
|
|
}
|
|
|
|
// DailyUsage holds usage data for a single day.
|
|
type DailyUsage struct {
|
|
Date string `json:"date"`
|
|
PodHours float64 `json:"pod_hours"`
|
|
CPUHours float64 `json:"cpu_hours"`
|
|
AIRequests int64 `json:"ai_requests"`
|
|
}
|
|
|
|
// UserUsageSummary holds usage data for a single user.
|
|
type UserUsageSummary struct {
|
|
UserID string `json:"user_id"`
|
|
Usage UsageSummary `json:"usage"`
|
|
}
|
|
|
|
// CreatePodRequest is the request body for creating a pod.
|
|
type CreatePodRequest struct {
|
|
User string `json:"user"`
|
|
Pod string `json:"pod"`
|
|
Tools string `json:"tools"`
|
|
CPUReq string `json:"cpu_req"`
|
|
CPULimit string `json:"cpu_limit"`
|
|
MemReq string `json:"mem_req"`
|
|
MemLimit string `json:"mem_limit"`
|
|
Task string `json:"task"`
|
|
TailscaleKey string `json:"tailscale_key,omitempty"`
|
|
}
|
|
|
|
// Validate checks that a CreatePodRequest has required fields and valid values.
|
|
func (r *CreatePodRequest) Validate() error {
|
|
if r.User == "" {
|
|
return fmt.Errorf("user is required")
|
|
}
|
|
if r.Pod == "" {
|
|
return fmt.Errorf("pod name is required")
|
|
}
|
|
if !isValidName(r.User) {
|
|
return fmt.Errorf("user must be lowercase alphanumeric with hyphens, 1-63 chars")
|
|
}
|
|
if len(NamespaceName(r.User)) > 63 {
|
|
return fmt.Errorf("user name too long: derived namespace %q exceeds 63-char k8s limit (max user name: 59 chars)", NamespaceName(r.User))
|
|
}
|
|
if !isValidName(r.Pod) {
|
|
return fmt.Errorf("pod name must be lowercase alphanumeric with hyphens, 1-63 chars")
|
|
}
|
|
if len(ServiceName(r.Pod)) > 63 {
|
|
return fmt.Errorf("pod name too long: derived service name %q exceeds 63-char k8s limit (max pod name: 51 chars)", ServiceName(r.Pod))
|
|
}
|
|
for _, pair := range []struct{ field, value string }{
|
|
{"cpu_req", r.CPUReq},
|
|
{"cpu_limit", r.CPULimit},
|
|
{"mem_req", r.MemReq},
|
|
{"mem_limit", r.MemLimit},
|
|
} {
|
|
if pair.value != "" {
|
|
if _, err := resource.ParseQuantity(pair.value); err != nil {
|
|
return fmt.Errorf("%s is not a valid resource quantity: %s", pair.field, pair.value)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateUserRequest is the request body for creating a user.
|
|
// Quotas uses pointer fields so that zero values (e.g. monthly_ai_requests: 0)
|
|
// can be distinguished from omitted fields.
|
|
type CreateUserRequest struct {
|
|
User string `json:"user"`
|
|
Quotas *UpdateQuotasRequest `json:"quotas,omitempty"`
|
|
}
|
|
|
|
// Validate checks that a CreateUserRequest has required fields.
|
|
func (r *CreateUserRequest) Validate() error {
|
|
if r.User == "" {
|
|
return fmt.Errorf("user is required")
|
|
}
|
|
if !isValidName(r.User) {
|
|
return fmt.Errorf("user must be lowercase alphanumeric with hyphens, 1-63 chars")
|
|
}
|
|
if len(NamespaceName(r.User)) > 63 {
|
|
return fmt.Errorf("user name too long: derived namespace %q exceeds 63-char k8s limit (max user name: 59 chars)", NamespaceName(r.User))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateQuotasRequest is the request body for updating user quotas.
|
|
type UpdateQuotasRequest struct {
|
|
MaxConcurrentPods *int `json:"max_concurrent_pods,omitempty"`
|
|
MaxCPUPerPod *int `json:"max_cpu_per_pod,omitempty"`
|
|
MaxRAMGBPerPod *int `json:"max_ram_gb_per_pod,omitempty"`
|
|
MonthlyPodHours *int `json:"monthly_pod_hours,omitempty"`
|
|
MonthlyAIRequests *int `json:"monthly_ai_requests,omitempty"`
|
|
}
|
|
|
|
// UpdatePodRequest is the request body for updating pod resources (delete + recreate).
|
|
type UpdatePodRequest struct {
|
|
Tools *string `json:"tools,omitempty"`
|
|
CPUReq *string `json:"cpu_req,omitempty"`
|
|
CPULimit *string `json:"cpu_limit,omitempty"`
|
|
MemReq *string `json:"mem_req,omitempty"`
|
|
MemLimit *string `json:"mem_limit,omitempty"`
|
|
Task *string `json:"task,omitempty"`
|
|
}
|
|
|
|
// Validate checks that resource quantities in UpdatePodRequest are valid.
|
|
func (r *UpdatePodRequest) Validate() error {
|
|
for _, pair := range []struct{ field string; value *string }{
|
|
{"cpu_req", r.CPUReq},
|
|
{"cpu_limit", r.CPULimit},
|
|
{"mem_req", r.MemReq},
|
|
{"mem_limit", r.MemLimit},
|
|
} {
|
|
if pair.value != nil {
|
|
if _, err := resource.ParseQuantity(*pair.value); err != nil {
|
|
return fmt.Errorf("%s is not a valid resource quantity: %s", pair.field, *pair.value)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks that quota values are positive when provided.
|
|
func (r *UpdateQuotasRequest) Validate() error {
|
|
if r.MaxConcurrentPods != nil && *r.MaxConcurrentPods < 1 {
|
|
return fmt.Errorf("max_concurrent_pods must be at least 1")
|
|
}
|
|
if r.MaxCPUPerPod != nil && *r.MaxCPUPerPod < 1 {
|
|
return fmt.Errorf("max_cpu_per_pod must be at least 1")
|
|
}
|
|
if r.MaxRAMGBPerPod != nil && *r.MaxRAMGBPerPod < 1 {
|
|
return fmt.Errorf("max_ram_gb_per_pod must be at least 1")
|
|
}
|
|
if r.MonthlyPodHours != nil && *r.MonthlyPodHours < 1 {
|
|
return fmt.Errorf("monthly_pod_hours must be at least 1")
|
|
}
|
|
if r.MonthlyAIRequests != nil && *r.MonthlyAIRequests < 0 {
|
|
return fmt.Errorf("monthly_ai_requests must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isValidName checks if a string is a valid k8s-compatible name:
|
|
// lowercase alphanumeric and hyphens, 1-63 chars, must start and end with alphanumeric.
|
|
func isValidName(name string) bool {
|
|
if len(name) == 0 || len(name) > 63 {
|
|
return false
|
|
}
|
|
for i, c := range name {
|
|
isAlphaNum := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
|
isHyphen := c == '-'
|
|
if !isAlphaNum && !isHyphen {
|
|
return false
|
|
}
|
|
if (i == 0 || i == len(name)-1) && isHyphen {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NamespaceName returns the k8s namespace for a user.
|
|
func NamespaceName(user string) string {
|
|
return "dev-" + user
|
|
}
|
|
|
|
// PodFullName returns the full k8s pod name.
|
|
func PodFullName(podName string) string {
|
|
return "dev-pod-" + podName
|
|
}
|
|
|
|
// ServiceName returns the k8s service name for a pod.
|
|
func ServiceName(podName string) string {
|
|
return "dev-pod-" + podName + "-svc"
|
|
}
|
|
|
|
// PodURL returns the external URL for a dev pod.
|
|
func PodURL(domain, user, podName string) string {
|
|
return fmt.Sprintf("https://%s/@%s/%s/", domain, user, podName)
|
|
}
|
|
|
|
// ClusterStatus holds overall cluster status including per-node details.
|
|
type ClusterStatus struct {
|
|
Nodes []NodeStatus `json:"nodes"`
|
|
Total ResourceSummary `json:"total"`
|
|
}
|
|
|
|
// NodeStatus describes a single cluster node's capacity and usage.
|
|
type NodeStatus struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
CPUCapacity string `json:"cpu_capacity"`
|
|
CPUAllocatable string `json:"cpu_allocatable"`
|
|
MemCapacity string `json:"mem_capacity"`
|
|
MemAllocatable string `json:"mem_allocatable"`
|
|
CPUUsage string `json:"cpu_usage,omitempty"`
|
|
MemUsage string `json:"mem_usage,omitempty"`
|
|
}
|
|
|
|
// ResourceSummary holds aggregated resource totals for the cluster.
|
|
type ResourceSummary struct {
|
|
CPUCapacity string `json:"cpu_capacity"`
|
|
CPUAllocatable string `json:"cpu_allocatable"`
|
|
MemCapacity string `json:"mem_capacity"`
|
|
MemAllocatable string `json:"mem_allocatable"`
|
|
}
|
|
|
|
// CacheStat describes a cache service PVC in the dev-infra namespace.
|
|
type CacheStat struct {
|
|
Name string `json:"name"`
|
|
PVCName string `json:"pvc_name"`
|
|
Capacity string `json:"capacity"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// RunnerStatus represents the lifecycle state of a runner.
|
|
type RunnerStatus string
|
|
|
|
const (
|
|
RunnerStatusReceived RunnerStatus = "received"
|
|
RunnerStatusPodCreating RunnerStatus = "pod_creating"
|
|
RunnerStatusRunnerRegistered RunnerStatus = "runner_registered"
|
|
RunnerStatusJobClaimed RunnerStatus = "job_claimed"
|
|
RunnerStatusCompleted RunnerStatus = "completed"
|
|
RunnerStatusFailed RunnerStatus = "failed"
|
|
RunnerStatusCleanupPending RunnerStatus = "cleanup_pending"
|
|
)
|
|
|
|
var validRunnerTransitions = map[RunnerStatus][]RunnerStatus{
|
|
RunnerStatusReceived: {RunnerStatusPodCreating, RunnerStatusFailed},
|
|
RunnerStatusPodCreating: {RunnerStatusRunnerRegistered, RunnerStatusFailed},
|
|
RunnerStatusRunnerRegistered: {RunnerStatusJobClaimed, RunnerStatusFailed},
|
|
RunnerStatusJobClaimed: {RunnerStatusCompleted, RunnerStatusFailed},
|
|
RunnerStatusCompleted: {RunnerStatusCleanupPending},
|
|
RunnerStatusFailed: {RunnerStatusCleanupPending},
|
|
}
|
|
|
|
// CanTransitionTo checks if a status transition is valid.
|
|
func (s RunnerStatus) CanTransitionTo(next RunnerStatus) bool {
|
|
for _, valid := range validRunnerTransitions[s] {
|
|
if valid == next {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsTerminal returns true if the runner is in a terminal state.
|
|
func (s RunnerStatus) IsTerminal() bool {
|
|
return s == RunnerStatusCompleted || s == RunnerStatusFailed
|
|
}
|
|
|
|
// Runner represents an ephemeral builder pod managed by the API.
|
|
type Runner struct {
|
|
ID string `json:"id"`
|
|
User string `json:"user"`
|
|
RepoURL string `json:"repo_url"`
|
|
Branch string `json:"branch"`
|
|
Tools string `json:"tools"`
|
|
Task string `json:"task"`
|
|
Status RunnerStatus `json:"status"`
|
|
ForgejoRunnerID string `json:"forgejo_runner_id,omitempty"`
|
|
WebhookDeliveryID string `json:"webhook_delivery_id,omitempty"`
|
|
PodName string `json:"pod_name,omitempty"`
|
|
CPUReq string `json:"cpu_req"`
|
|
MemReq string `json:"mem_req"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
// CreateRunnerRequest is the request body for creating a runner.
|
|
type CreateRunnerRequest struct {
|
|
User string `json:"user"`
|
|
Repo string `json:"repo"`
|
|
Branch string `json:"branch"`
|
|
Tools string `json:"tools"`
|
|
Task string `json:"task"`
|
|
CPUReq string `json:"cpu_req"`
|
|
MemReq string `json:"mem_req"`
|
|
WebhookDeliveryID string `json:"webhook_delivery_id"`
|
|
}
|
|
|
|
// Validate checks that a CreateRunnerRequest has required fields.
|
|
func (r *CreateRunnerRequest) Validate() error {
|
|
if r.User == "" {
|
|
return fmt.Errorf("user is required")
|
|
}
|
|
if r.Repo == "" {
|
|
return fmt.Errorf("repo is required")
|
|
}
|
|
if !isValidName(r.User) {
|
|
return fmt.Errorf("user must be lowercase alphanumeric with hyphens, 1-63 chars")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateRunnerStatusRequest is the request body for updating runner status.
|
|
type UpdateRunnerStatusRequest struct {
|
|
Status RunnerStatus `json:"status"`
|
|
ForgejoRunnerID string `json:"forgejo_runner_id,omitempty"`
|
|
}
|
|
|
|
// Validate checks the status update request.
|
|
func (r *UpdateRunnerStatusRequest) Validate() error {
|
|
switch r.Status {
|
|
case RunnerStatusPodCreating, RunnerStatusRunnerRegistered, RunnerStatusJobClaimed,
|
|
RunnerStatusCompleted, RunnerStatusFailed, RunnerStatusCleanupPending:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid status: %s", r.Status)
|
|
}
|
|
}
|
|
|
|
// RunnerPodName returns the k8s pod name for a runner.
|
|
func RunnerPodName(runnerID string) string {
|
|
return "dev-pod-" + runnerID
|
|
}
|
|
|