mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(service): variables editor with masked-secret preservation
PUT /services/{id}/variables replaced every row, and GET masks secrets as
******** — echoing the fetched list back would have overwritten real
secrets with asterisks. The backend now keeps the stored value when a
submitted secret still carries the mask.
Service detail gains a Variables section: add/edit/remove rows, secret
toggle, bulk .env paste (comments, export prefix, quoted values), and
inline validation for empty/invalid/duplicate keys. Parser and validator
live in variable-utils.ts with unit coverage.
Generated OpenAPI types drive the new updateServiceVariables client call.
ROADMAP: Variables UX marked done; per-environment values are already
structural — each environment owns its service instances.
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
e6e76c1084
commit
1d5b99e1ba
6 files changed
+354
-13
No files matched your search
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// maskedSecretValue is the placeholder returned for secret values; on update
|
||||
// it means "keep the stored value" rather than overwriting with asterisks.
|
||||
const maskedSecretValue = "********"
|
||||
|
||||
type EnvironmentVariable struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
||||
@@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = "********"
|
||||
v.Value = maskedSecretValue
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
@@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot stored secrets so a client echoing back the masked placeholder
|
||||
// does not destroy the real value.
|
||||
storedSecrets := map[string]string{}
|
||||
secretRows, err := db.(*database.DB).Query(
|
||||
`SELECT key, value FROM environment_variables WHERE service_id = $1 AND is_secret = TRUE`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
||||
return
|
||||
}
|
||||
for secretRows.Next() {
|
||||
var k, v string
|
||||
if err := secretRows.Scan(&k, &v); err == nil {
|
||||
storedSecrets[k] = v
|
||||
}
|
||||
}
|
||||
secretRows.Close()
|
||||
|
||||
tx, err := db.(*database.DB).Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
@@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
|
||||
now := time.Now()
|
||||
for _, v := range req.Variables {
|
||||
value := v.Value
|
||||
if v.IsSecret && value == maskedSecretValue {
|
||||
if stored, ok := storedSecrets[v.Key]; ok {
|
||||
value = stored
|
||||
}
|
||||
}
|
||||
varID := uuid.New()
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
|
||||
varID, serviceID, v.Key, value, v.IsSecret, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
@@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = "********"
|
||||
v.Value = maskedSecretValue
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user