mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT, IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message". - Idempotency middleware on authed/admin groups: POST requests with an Idempotency-Key header replay the first response for 24h (Redis-backed, caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay header marks replays. - Shared pagination helpers: ?cursor= (opaque, preferred) alongside legacy ?page= on projects and deployments lists; pagination meta includes next_cursor; invalid cursors fail soft to page 1. - CLI --idempotency-key flag for safe scripted retries. - OpenAPI updated: error envelope, cursor params, idempotency contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
485e41a817
commit
18f78f00a3
15 files changed
+454
-56
No files matched your search
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -74,14 +73,21 @@ func handleGetDeployments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
offset, limit := pageWindow(c, 50, 100)
|
||||
|
||||
var total int
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
|
||||
).Scan(&total)
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
serviceID,
|
||||
LIMIT $2 OFFSET $3`,
|
||||
serviceID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
@@ -104,7 +110,10 @@ func handleGetDeployments(c *gin.Context) {
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
})
|
||||
}
|
||||
|
||||
// RecentDeployment is a deployment row joined with its service and project names.
|
||||
@@ -132,11 +141,19 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
userID := optionalUserUUID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
limit := 10
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
offset, limit := pageWindow(c, 10, 50)
|
||||
|
||||
var total int
|
||||
if err := db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*)
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
|
||||
userID, isAdmin,
|
||||
).Scan(&total); err != nil {
|
||||
total = 0
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
@@ -148,8 +165,8 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $3`,
|
||||
userID, isAdmin, limit,
|
||||
LIMIT $3 OFFSET $4`,
|
||||
userID, isAdmin, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
@@ -168,7 +185,10 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Stable machine-readable error codes for agent clients. The "error" field
|
||||
// stays human-readable; "code" is the contract the CLI/MCP rely on.
|
||||
const (
|
||||
CodeUnauthorized = "UNAUTHENTICATED"
|
||||
CodeInvalidToken = "INVALID_TOKEN"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
CodeAdminRequired = "ADMIN_REQUIRED"
|
||||
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
|
||||
CodeNotFound = "NOT_FOUND"
|
||||
CodeValidation = "VALIDATION"
|
||||
CodeConflict = "CONFLICT"
|
||||
CodeInternal = "INTERNAL"
|
||||
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
|
||||
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
|
||||
)
|
||||
|
||||
// respondError emits the standard error envelope. Existing handlers return
|
||||
// bare {"error": msg} — this adds the stable code for new and
|
||||
// agent-facing paths.
|
||||
func respondError(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{"error": message, "code": code})
|
||||
c.Abort()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// pageWindow parses pagination from either ?page=&limit= (legacy) or
|
||||
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
|
||||
// cheap to implement over the existing offset queries while the public
|
||||
// contract stays cursor-shaped for clients.
|
||||
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
|
||||
limit = defaultLimit
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if cur := c.Query("cursor"); cur != "" {
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
|
||||
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
|
||||
return n, limit
|
||||
}
|
||||
}
|
||||
return 0, limit // invalid cursor → first page, fail soft
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return (page - 1) * limit, limit
|
||||
}
|
||||
|
||||
// paginationMeta renders the standard envelope: legacy page fields plus
|
||||
// next_cursor when another page exists.
|
||||
func paginationMeta(offset, limit, total int) gin.H {
|
||||
page := offset/limit + 1
|
||||
meta := gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + limit - 1) / limit,
|
||||
}
|
||||
if offset+limit < total {
|
||||
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ctxWithQuery(q string) *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPageWindowDefaults(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
|
||||
if offset != 0 || limit != 10 {
|
||||
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowLegacyPage(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
|
||||
if offset != 40 || limit != 20 {
|
||||
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowCursor(t *testing.T) {
|
||||
// cursor for offset 50 produced by paginationMeta
|
||||
meta := paginationMeta(0, 50, 200)
|
||||
cursor := meta["next_cursor"].(string)
|
||||
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
|
||||
if offset != 50 || limit != 50 {
|
||||
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
|
||||
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
|
||||
if offset != 0 {
|
||||
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
|
||||
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
|
||||
t.Fatal("no next page should omit next_cursor")
|
||||
}
|
||||
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
|
||||
t.Fatal("expected next_cursor when more pages exist")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset, limit := pageWindow(c, 10, 100)
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit > 100 || limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
searchParam := sql.NullString{}
|
||||
if search != "" {
|
||||
searchParam = sql.NullString{String: search, Valid: true}
|
||||
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
totalCount := int(total)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": projects,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": totalCount,
|
||||
"pages": (totalCount + limit - 1) / limit,
|
||||
},
|
||||
"projects": projects,
|
||||
"pagination": paginationMeta(offset, limit, int(total)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// enforce ownership; platform admins additionally pass every check.
|
||||
authed := v1.Group("/")
|
||||
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||
authed.Use(middleware.Idempotency())
|
||||
{
|
||||
authed.GET("/user/profile", handleGetProfile)
|
||||
authed.PUT("/user/profile", handleUpdateProfile)
|
||||
@@ -357,6 +358,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Admin routes — platform-wide controls and cross-user management.
|
||||
admin := v1.Group("/")
|
||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||
admin.Use(middleware.Idempotency())
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
admin.GET("/admin/overview", handleAdminOverview)
|
||||
|
||||
@@ -72,36 +72,36 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if req.Scope == "" {
|
||||
req.Scope = "write"
|
||||
}
|
||||
if !userTokenScopes[req.Scope] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
// A token can never outrank its owner: non-admin users cannot mint
|
||||
// admin-scope tokens even for themselves.
|
||||
if req.Scope == "admin" && !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
token, prefix, hash, err := generateUserToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,13 +140,13 @@ func handleListUserTokens(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,12 +167,12 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
tokenUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Exit codes — stable for scripting and agent use.
|
||||
@@ -109,6 +111,9 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
|
||||
req.Header.Set("Idempotency-Key", k)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
hc := c.HTTPClient
|
||||
@@ -134,9 +139,13 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||
msg := strings.TrimSpace(string(data))
|
||||
var envelope struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||
msg = envelope.Error
|
||||
if envelope.Code != "" {
|
||||
msg = envelope.Code + ": " + msg
|
||||
}
|
||||
}
|
||||
return nil, &APIError{
|
||||
Status: resp.StatusCode,
|
||||
|
||||
@@ -55,12 +55,14 @@ func init() {
|
||||
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
|
||||
pf.Bool("json", false, "machine-readable JSON output")
|
||||
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
|
||||
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
|
||||
|
||||
viper.BindPFlag("api-url", pf.Lookup("api-url"))
|
||||
viper.BindPFlag("token", pf.Lookup("token"))
|
||||
viper.BindPFlag("profile", pf.Lookup("profile"))
|
||||
viper.BindPFlag("json", pf.Lookup("json"))
|
||||
viper.BindPFlag("yes", pf.Lookup("yes"))
|
||||
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
|
||||
|
||||
rootCmd.AddCommand(commands.AuthCmd)
|
||||
rootCmd.AddCommand(commands.TokensCmd)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
||||
// so agents can safely retry mutations. The first request wins; concurrent
|
||||
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
||||
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
||||
func Idempotency() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
key := c.GetHeader("Idempotency-Key")
|
||||
if key == "" || len(key) > 128 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
dbValue, exists := c.Get("redis")
|
||||
if !exists {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb, ok := dbValue.(*database.Redis)
|
||||
if !ok || rdb == nil || rdb.Client == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Scope by caller identity so a stolen key can't replay across users.
|
||||
actor := c.GetString("user_id")
|
||||
if actor == "" {
|
||||
actor = c.GetHeader("Authorization")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
||||
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
||||
switch {
|
||||
case err == nil:
|
||||
if stored == "processing" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var rec idemRecord
|
||||
if json.Unmarshal([]byte(stored), &rec) == nil {
|
||||
c.Header("X-Idempotent-Replay", "true")
|
||||
c.Data(rec.Status, rec.ContentType, rec.Body)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
case err != redis.Nil:
|
||||
// Redis hiccup — degrade to pass-through rather than failing the request.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Claim the key for the duration of the request.
|
||||
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
||||
c.Writer = recorder
|
||||
c.Next()
|
||||
|
||||
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
||||
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
||||
rec := idemRecord{
|
||||
Status: recorder.status,
|
||||
ContentType: recorder.Header().Get("Content-Type"),
|
||||
Body: recorder.body.Bytes(),
|
||||
}
|
||||
if rec.ContentType == "" {
|
||||
rec.ContentType = "application/json; charset=utf-8"
|
||||
}
|
||||
if raw, merr := json.Marshal(rec); merr == nil {
|
||||
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
||||
}
|
||||
} else {
|
||||
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type idemRecord struct {
|
||||
Status int `json:"s"`
|
||||
ContentType string `json:"ct"`
|
||||
Body []byte `json:"b"`
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
gin.ResponseWriter
|
||||
status int
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
r.body.Write(b)
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
// Key present but no redis in context — must not block the request.
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc-123")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencySkipsNonPOST(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("GET should bypass idempotency, called=%d", called)
|
||||
}
|
||||
}
|
||||
@@ -212,15 +212,15 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if tokenErr != "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if hasToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
@@ -245,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,10 +392,12 @@ export interface paths {
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Page number for pagination */
|
||||
/** @description Page number for pagination (legacy — prefer cursor) */
|
||||
page?: number;
|
||||
/** @description Number of items per page */
|
||||
limit?: number;
|
||||
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||
cursor?: string;
|
||||
/** @description Search term to filter projects */
|
||||
search?: string;
|
||||
};
|
||||
@@ -982,7 +984,11 @@ export interface paths {
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||
cursor?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Service ID */
|
||||
@@ -1056,6 +1062,8 @@ export interface paths {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||
cursor?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -36,6 +36,12 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"title": "🚀 Zero-Interaction Docker Deployment System",
|
||||
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di
Line truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|delete|start|stop|restart|redeploy\ndeploy <service-id> trigger a deployment\ndeployments list|get|logs|rollback\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n"
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
"section": "guides",
|
||||
|
||||
Reference in new issue
Block a user