Compare commits
2 Commits
feature/ap
...
160b02b0b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160b02b0b6 | ||
|
|
dde0ad5250 |
@@ -51,8 +51,7 @@ type Config struct {
|
|||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file
|
SessionSecret string
|
||||||
CORSOrigins string
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -103,7 +102,6 @@ func setupViper(name string) {
|
|||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("SESSION_SECRET", "")
|
viper.SetDefault("SESSION_SECRET", "")
|
||||||
viper.SetDefault("CORS_ORIGINS", "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
||||||
@@ -138,7 +136,6 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
|||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
SessionSecret: viper.GetString("SESSION_SECRET"),
|
SessionSecret: viper.GetString("SESSION_SECRET"),
|
||||||
CORSOrigins: viper.GetString("CORS_ORIGINS"),
|
|
||||||
params: params,
|
params: params,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
@@ -480,20 +479,6 @@ func (c *Client) CloneRepo(
|
|||||||
return c.performClone(ctx, cfg)
|
return c.performClone(ctx, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes a Docker image by ID or tag.
|
|
||||||
// It returns nil if the image was successfully removed or does not exist.
|
|
||||||
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
|
|
||||||
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
|
|
||||||
Force: true,
|
|
||||||
PruneChildren: true,
|
|
||||||
})
|
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
|
||||||
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) performBuild(
|
func (c *Client) performBuild(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
opts BuildImageOptions,
|
opts BuildImageOptions,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|||||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
|
|||||||
@@ -499,8 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
|
_, _ = writer.Write([]byte(logs))
|
||||||
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +538,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
"logs": SanitizeLogs(logs),
|
"logs": logs,
|
||||||
"status": deployment.Status,
|
"status": deployment.Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +582,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
|
_, err := os.Stat(logPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
@@ -662,7 +661,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
"logs": SanitizeLogs(logs),
|
"logs": logs,
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes).
|
|
||||||
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`)
|
|
||||||
|
|
||||||
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
|
|
||||||
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
|
|
||||||
// are preserved. This ensures that attacker-controlled container output cannot
|
|
||||||
// inject terminal escape sequences or other dangerous control characters.
|
|
||||||
func SanitizeLogs(input string) string {
|
|
||||||
// Strip ANSI escape sequences
|
|
||||||
result := ansiEscapePattern.ReplaceAllString(input, "")
|
|
||||||
|
|
||||||
// Strip remaining non-printable characters (keep \n, \r, \t)
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(result))
|
|
||||||
|
|
||||||
for _, r := range result {
|
|
||||||
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "plain text unchanged",
|
|
||||||
input: "hello world\n",
|
|
||||||
expected: "hello world\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strips ANSI color codes",
|
|
||||||
input: "\x1b[31mERROR\x1b[0m: something failed\n",
|
|
||||||
expected: "ERROR: something failed\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strips OSC sequences",
|
|
||||||
input: "\x1b]0;window title\x07normal text\n",
|
|
||||||
expected: "normal text\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strips null bytes",
|
|
||||||
input: "hello\x00world\n",
|
|
||||||
expected: "helloworld\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strips bell characters",
|
|
||||||
input: "alert\x07here\n",
|
|
||||||
expected: "alerthere\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "preserves tabs",
|
|
||||||
input: "field1\tfield2\tfield3\n",
|
|
||||||
expected: "field1\tfield2\tfield3\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "preserves carriage returns",
|
|
||||||
input: "line1\r\nline2\r\n",
|
|
||||||
expected: "line1\r\nline2\r\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strips mixed escape sequences",
|
|
||||||
input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n",
|
|
||||||
expected: "2024-01-01 INFO starting\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string",
|
|
||||||
input: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only control characters",
|
|
||||||
input: "\x00\x01\x02\x03",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cursor movement sequences stripped",
|
|
||||||
input: "\x1b[2J\x1b[H\x1b[3Atext\n",
|
|
||||||
expected: "text\n",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got := handlers.SanitizeLogs(tt.input)
|
|
||||||
if got != tt.expected {
|
|
||||||
t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package middleware //nolint:testpackage // tests internal CORS behavior
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:gosec // test credentials
|
|
||||||
func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
|
||||||
return &Middleware{
|
|
||||||
log: slog.Default(),
|
|
||||||
params: &Params{
|
|
||||||
Config: &config.Config{
|
|
||||||
CORSOrigins: corsOrigins,
|
|
||||||
SessionSecret: "test-secret-32-bytes-long-enough",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newCORSTestMiddleware("")
|
|
||||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
req.Header.Set("Origin", "https://evil.com")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
|
|
||||||
"expected no CORS headers when no origins configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newCORSTestMiddleware("https://app.example.com,https://other.example.com")
|
|
||||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
assert.Equal(t, "https://app.example.com",
|
|
||||||
rec.Header().Get("Access-Control-Allow-Origin"))
|
|
||||||
assert.Equal(t, "true",
|
|
||||||
rec.Header().Get("Access-Control-Allow-Credentials"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newCORSTestMiddleware("https://app.example.com")
|
|
||||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
req.Header.Set("Origin", "https://evil.com")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
|
|
||||||
"expected no CORS headers for non-matching origin")
|
|
||||||
}
|
|
||||||
@@ -180,48 +180,17 @@ func realIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns CORS middleware.
|
// CORS returns CORS middleware.
|
||||||
// When UPAAS_CORS_ORIGINS is empty (default), no CORS headers are sent
|
|
||||||
// (same-origin only). When configured, only the specified origins are
|
|
||||||
// allowed and credentials (cookies) are permitted.
|
|
||||||
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
origins := parseCORSOrigins(m.params.Config.CORSOrigins)
|
|
||||||
|
|
||||||
// No origins configured — no CORS headers (same-origin policy).
|
|
||||||
if len(origins) == 0 {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: origins,
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCORSOrigins splits a comma-separated origin string into a slice,
|
|
||||||
// trimming whitespace. Returns nil if the input is empty.
|
|
||||||
func parseCORSOrigins(raw string) []string {
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
origins := make([]string, 0, len(parts))
|
|
||||||
|
|
||||||
for _, p := range parts {
|
|
||||||
if o := strings.TrimSpace(p); o != "" {
|
|
||||||
origins = append(origins, o)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return origins
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsAuth returns basic auth middleware for metrics endpoint.
|
// MetricsAuth returns basic auth middleware for metrics endpoint.
|
||||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
if m.params.Config.MetricsUsername == "" {
|
if m.params.Config.MetricsUsername == "" {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// tokenRandomBytes is the number of random bytes for token generation.
|
// tokenRandomBytes is the number of random bytes for token generation.
|
||||||
const tokenRandomBytes = 32
|
const tokenRandomBytes = 16
|
||||||
|
|
||||||
// tokenPrefix is prepended to generated API tokens.
|
// tokenPrefix is prepended to generated API tokens.
|
||||||
const tokenPrefix = "upaas_"
|
const tokenPrefix = "upaas_"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -473,7 +472,7 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
// Build phase with timeout
|
// Build phase with timeout
|
||||||
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
|
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "")
|
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
|
||||||
if cancelErr != nil {
|
if cancelErr != nil {
|
||||||
return cancelErr
|
return cancelErr
|
||||||
}
|
}
|
||||||
@@ -486,7 +485,7 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
// Deploy phase with timeout
|
// Deploy phase with timeout
|
||||||
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
|
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID)
|
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
|
||||||
if cancelErr != nil {
|
if cancelErr != nil {
|
||||||
return cancelErr
|
return cancelErr
|
||||||
}
|
}
|
||||||
@@ -662,77 +661,24 @@ func (svc *Service) cancelActiveDeploy(appID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
|
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
|
||||||
// and if so, marks the deployment as cancelled and cleans up orphan resources.
|
// and if so, marks the deployment as cancelled. Returns ErrDeployCancelled or nil.
|
||||||
// Returns ErrDeployCancelled or nil.
|
|
||||||
func (svc *Service) checkCancelled(
|
func (svc *Service) checkCancelled(
|
||||||
deployCtx context.Context,
|
deployCtx context.Context,
|
||||||
bgCtx context.Context,
|
bgCtx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
imageID string,
|
|
||||||
) error {
|
) error {
|
||||||
if !errors.Is(deployCtx.Err(), context.Canceled) {
|
if !errors.Is(deployCtx.Err(), context.Canceled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.log.Info("deployment cancelled", "app", app.Name)
|
svc.log.Info("deployment cancelled by newer deploy", "app", app.Name)
|
||||||
|
|
||||||
svc.cleanupCancelledDeploy(bgCtx, app, deployment, imageID)
|
|
||||||
|
|
||||||
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
|
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
|
||||||
|
|
||||||
return ErrDeployCancelled
|
return ErrDeployCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
|
||||||
func (svc *Service) cleanupCancelledDeploy(
|
|
||||||
ctx context.Context,
|
|
||||||
app *models.App,
|
|
||||||
deployment *models.Deployment,
|
|
||||||
imageID string,
|
|
||||||
) {
|
|
||||||
// Clean up the intermediate Docker image if one was built
|
|
||||||
if imageID != "" {
|
|
||||||
removeErr := svc.docker.RemoveImage(ctx, imageID)
|
|
||||||
if removeErr != nil {
|
|
||||||
svc.log.Error("failed to remove image from cancelled deploy",
|
|
||||||
"error", removeErr, "app", app.Name, "image", imageID)
|
|
||||||
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID+": "+removeErr.Error())
|
|
||||||
} else {
|
|
||||||
svc.log.Info("cleaned up image from cancelled deploy",
|
|
||||||
"app", app.Name, "image", imageID)
|
|
||||||
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the build directory for this deployment
|
|
||||||
buildDir := svc.GetBuildDir(app.Name)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(buildDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf("%d-", deployment.ID)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
|
||||||
dirPath := filepath.Join(buildDir, entry.Name())
|
|
||||||
|
|
||||||
removeErr := os.RemoveAll(dirPath)
|
|
||||||
if removeErr != nil {
|
|
||||||
svc.log.Error("failed to remove build dir from cancelled deploy",
|
|
||||||
"error", removeErr, "path", dirPath)
|
|
||||||
} else {
|
|
||||||
svc.log.Info("cleaned up build dir from cancelled deploy",
|
|
||||||
"app", app.Name, "path", dirPath)
|
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Cleaned up build directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) fetchWebhookEvent(
|
func (svc *Service) fetchWebhookEvent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookEventID *int64,
|
webhookEventID *int64,
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package deploy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
cfg := &config.Config{DataDir: tmpDir}
|
|
||||||
|
|
||||||
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
|
|
||||||
|
|
||||||
// Create a fake build directory matching the deployment pattern
|
|
||||||
appName := "test-app"
|
|
||||||
buildDir := svc.GetBuildDirExported(appName)
|
|
||||||
require.NoError(t, os.MkdirAll(buildDir, 0o750))
|
|
||||||
|
|
||||||
// Create deployment-specific dir: <deploymentID>-<random>
|
|
||||||
deployDir := filepath.Join(buildDir, "42-abc123")
|
|
||||||
require.NoError(t, os.MkdirAll(deployDir, 0o750))
|
|
||||||
|
|
||||||
// Create a file inside to verify full removal
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600))
|
|
||||||
|
|
||||||
// Also create a dir for a different deployment (should NOT be removed)
|
|
||||||
otherDir := filepath.Join(buildDir, "99-xyz789")
|
|
||||||
require.NoError(t, os.MkdirAll(otherDir, 0o750))
|
|
||||||
|
|
||||||
// Run cleanup for deployment 42
|
|
||||||
svc.CleanupCancelledDeploy(context.Background(), appName, 42, "")
|
|
||||||
|
|
||||||
// Deployment 42's dir should be gone
|
|
||||||
_, err := os.Stat(deployDir)
|
|
||||||
assert.True(t, os.IsNotExist(err), "deployment build dir should be removed")
|
|
||||||
|
|
||||||
// Deployment 99's dir should still exist
|
|
||||||
_, err = os.Stat(otherDir)
|
|
||||||
assert.NoError(t, err, "other deployment build dir should not be removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupCancelledDeploy_NoBuildDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
cfg := &config.Config{DataDir: tmpDir}
|
|
||||||
|
|
||||||
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
|
|
||||||
|
|
||||||
// Should not panic when build dir doesn't exist
|
|
||||||
svc.CleanupCancelledDeploy(context.Background(), "nonexistent-app", 1, "")
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,7 @@ package deploy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
|
||||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestService creates a Service with minimal dependencies for testing.
|
// NewTestService creates a Service with minimal dependencies for testing.
|
||||||
@@ -38,45 +31,3 @@ func (svc *Service) TryLockApp(appID string) bool {
|
|||||||
func (svc *Service) UnlockApp(appID string) {
|
func (svc *Service) UnlockApp(appID string) {
|
||||||
svc.unlockApp(appID)
|
svc.unlockApp(appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
|
|
||||||
func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient *docker.Client) *Service {
|
|
||||||
return &Service{
|
|
||||||
log: log,
|
|
||||||
config: cfg,
|
|
||||||
docker: dockerClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupCancelledDeploy exposes the build directory cleanup portion of
|
|
||||||
// cleanupCancelledDeploy for testing. It removes build directories matching
|
|
||||||
// the deployment ID prefix.
|
|
||||||
func (svc *Service) CleanupCancelledDeploy(
|
|
||||||
_ context.Context,
|
|
||||||
appName string,
|
|
||||||
deploymentID int64,
|
|
||||||
_ string,
|
|
||||||
) {
|
|
||||||
// We can't create real models.App/Deployment in tests easily,
|
|
||||||
// so we test the build dir cleanup portion directly.
|
|
||||||
buildDir := svc.GetBuildDir(appName)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(buildDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf("%d-", deploymentID)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
|
||||||
dirPath := filepath.Join(buildDir, entry.Name())
|
|
||||||
_ = os.RemoveAll(dirPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBuildDirExported exposes GetBuildDir for testing.
|
|
||||||
func (svc *Service) GetBuildDirExported(appName string) string {
|
|
||||||
return svc.GetBuildDir(appName)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ func (svc *Service) sendNtfy(
|
|||||||
request.Header.Set("Title", title)
|
request.Header.Set("Title", title)
|
||||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
||||||
|
|
||||||
resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input
|
resp, err := svc.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config
|
resp, err := svc.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send slack request: %w", err)
|
return fmt.Errorf("failed to send slack request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// KeyPair contains an SSH key pair.
|
// KeyPair contains an SSH key pair.
|
||||||
type KeyPair struct {
|
type KeyPair struct {
|
||||||
PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
|
PrivateKey string
|
||||||
PublicKey string
|
PublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user