Add deployment improvements and UI enhancements
- Clone specific commit SHA from webhook instead of just branch HEAD - Log webhook payload in deployment logs - Add build/deploy timing to ntfy and Slack notifications - Implement container rollback on deploy failure - Remove old container only after successful deployment - Show relative times in deployment history (hover for full date) - Update port mappings UI with labeled text inputs - Add footer with version info, license, and repo link - Format deploy key comment as upaas_DATE_appname
This commit is contained in:
parent
bc275f7b9c
commit
b3ac3c60c2
@ -10,6 +10,7 @@ linters:
|
|||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
- depguard # Dependency allow/block lists
|
||||||
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
|||||||
@ -158,6 +158,7 @@ Environment variables:
|
|||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `PORT` | HTTP listen port | 8080 |
|
| `PORT` | HTTP listen port | 8080 |
|
||||||
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
|
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
|
||||||
|
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | same as DATA_DIR |
|
||||||
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
||||||
| `DEBUG` | Enable debug logging | false |
|
| `DEBUG` | Enable debug logging | false |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
||||||
@ -170,10 +171,14 @@ Environment variables:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v upaas-data:/var/lib/upaas \
|
-v /path/on/host/upaas-data:/var/lib/upaas \
|
||||||
|
-e UPAAS_HOST_DATA_DIR=/path/on/host/upaas-data \
|
||||||
upaas
|
upaas
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
|
||||||
|
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.
|
||||||
|
|
||||||
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@ -45,6 +45,7 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
Debug bool
|
Debug bool
|
||||||
DataDir string
|
DataDir string
|
||||||
|
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
|
||||||
DockerHost string
|
DockerHost string
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
@ -116,11 +117,19 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
|||||||
// Config file not found is OK
|
// Config file not found is OK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataDir := viper.GetString("DATA_DIR")
|
||||||
|
hostDataDir := viper.GetString("HOST_DATA_DIR")
|
||||||
|
|
||||||
|
if hostDataDir == "" {
|
||||||
|
hostDataDir = dataDir
|
||||||
|
}
|
||||||
|
|
||||||
// Build config struct
|
// Build config struct
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
DataDir: dataDir,
|
||||||
|
HostDataDir: hostDataDir,
|
||||||
DockerHost: viper.GetString("DOCKER_HOST"),
|
DockerHost: viper.GetString("DOCKER_HOST"),
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
|
|||||||
@ -28,9 +28,16 @@ import (
|
|||||||
// sshKeyPermissions is the file permission for SSH private keys.
|
// sshKeyPermissions is the file permission for SSH private keys.
|
||||||
const sshKeyPermissions = 0o600
|
const sshKeyPermissions = 0o600
|
||||||
|
|
||||||
|
// workDirPermissions is the file permission for the work directory.
|
||||||
|
const workDirPermissions = 0o750
|
||||||
|
|
||||||
// stopTimeoutSeconds is the timeout for stopping containers.
|
// stopTimeoutSeconds is the timeout for stopping containers.
|
||||||
const stopTimeoutSeconds = 10
|
const stopTimeoutSeconds = 10
|
||||||
|
|
||||||
|
// gitImage is the Docker image used for git operations.
|
||||||
|
// alpine/git v2.47.2 - pulled 2025-12-30
|
||||||
|
const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
|
||||||
|
|
||||||
// ErrNotConnected is returned when Docker client is not connected.
|
// ErrNotConnected is returned when Docker client is not connected.
|
||||||
var ErrNotConnected = errors.New("docker client not connected")
|
var ErrNotConnected = errors.New("docker client not connected")
|
||||||
|
|
||||||
@ -86,6 +93,7 @@ type BuildImageOptions struct {
|
|||||||
ContextDir string
|
ContextDir string
|
||||||
DockerfilePath string
|
DockerfilePath string
|
||||||
Tags []string
|
Tags []string
|
||||||
|
LogWriter io.Writer // Optional writer for build output
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildImage builds a Docker image from a context directory.
|
// BuildImage builds a Docker image from a context directory.
|
||||||
@ -398,28 +406,49 @@ func (c *Client) FindContainerByAppID(
|
|||||||
type cloneConfig struct {
|
type cloneConfig struct {
|
||||||
repoURL string
|
repoURL string
|
||||||
branch string
|
branch string
|
||||||
|
commitSHA string // Optional: specific commit to checkout
|
||||||
sshPrivateKey string
|
sshPrivateKey string
|
||||||
destDir string
|
containerDir string // Path inside the upaas container (for file operations)
|
||||||
keyFile string
|
hostDir string // Path on the Docker host (for bind mounts)
|
||||||
|
keyFile string // Container path to SSH key file
|
||||||
|
hostKeyFile string // Host path to SSH key file
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneRepo clones a git repository using SSH.
|
// CloneResult contains the result of a git clone operation.
|
||||||
|
type CloneResult struct {
|
||||||
|
Output string // Combined stdout/stderr from git clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
|
||||||
|
// containerDir is the path inside the upaas container (for writing files).
|
||||||
|
// hostDir is the corresponding path on the Docker host (for bind mounts).
|
||||||
|
// If commitSHA is provided, that specific commit will be checked out.
|
||||||
func (c *Client) CloneRepo(
|
func (c *Client) CloneRepo(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
repoURL, branch, sshPrivateKey, destDir string,
|
repoURL, branch, commitSHA, sshPrivateKey, containerDir, hostDir string,
|
||||||
) error {
|
) (*CloneResult, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return ErrNotConnected
|
return nil, ErrNotConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("cloning repository", "url", repoURL, "branch", branch, "dest", destDir)
|
c.log.Info("cloning repository",
|
||||||
|
"url", repoURL,
|
||||||
|
"branch", branch,
|
||||||
|
"commit", commitSHA,
|
||||||
|
"containerDir", containerDir,
|
||||||
|
"hostDir", hostDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clone to 'work' subdirectory, SSH key stays in build directory root
|
||||||
cfg := &cloneConfig{
|
cfg := &cloneConfig{
|
||||||
repoURL: repoURL,
|
repoURL: repoURL,
|
||||||
branch: branch,
|
branch: branch,
|
||||||
|
commitSHA: commitSHA,
|
||||||
sshPrivateKey: sshPrivateKey,
|
sshPrivateKey: sshPrivateKey,
|
||||||
destDir: destDir,
|
containerDir: filepath.Join(containerDir, "work"),
|
||||||
keyFile: filepath.Join(destDir, ".deploy_key"),
|
hostDir: filepath.Join(hostDir, "work"),
|
||||||
|
keyFile: filepath.Join(containerDir, "deploy_key"),
|
||||||
|
hostKeyFile: filepath.Join(hostDir, "deploy_key"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.performClone(ctx, cfg)
|
return c.performClone(ctx, cfg)
|
||||||
@ -460,8 +489,13 @@ func (c *Client) performBuild(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Read build output (logs to stdout for now)
|
// Read build output - write to stdout and optional log writer
|
||||||
_, err = io.Copy(os.Stdout, resp.Body)
|
var output io.Writer = os.Stdout
|
||||||
|
if opts.LogWriter != nil {
|
||||||
|
output = io.MultiWriter(os.Stdout, opts.LogWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(output, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read build output: %w", err)
|
return "", fmt.Errorf("failed to read build output: %w", err)
|
||||||
}
|
}
|
||||||
@ -479,11 +513,17 @@ func (c *Client) performBuild(
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error {
|
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
|
||||||
// Write SSH key to temp file
|
// Create work directory for clone destination
|
||||||
err := os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
|
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write SSH key: %w", err)
|
return nil, fmt.Errorf("failed to create work dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SSH key to temp file
|
||||||
|
err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write SSH key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -495,7 +535,7 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error {
|
|||||||
|
|
||||||
containerID, err := c.createGitContainer(ctx, cfg)
|
containerID, err := c.createGitContainer(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -511,21 +551,37 @@ func (c *Client) createGitContainer(
|
|||||||
) (string, error) {
|
) (string, error) {
|
||||||
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
|
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
|
// Build the git command based on whether we have a specific commit SHA
|
||||||
|
var cmd []string
|
||||||
|
|
||||||
|
if cfg.commitSHA != "" {
|
||||||
|
// Clone without depth limit so we can checkout any commit, then checkout specific SHA
|
||||||
|
// Using sh -c to run multiple commands
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
"git clone --branch %s %s /repo && cd /repo && git checkout %s",
|
||||||
|
cfg.branch, cfg.repoURL, cfg.commitSHA,
|
||||||
|
)
|
||||||
|
cmd = []string{"sh", "-c", script}
|
||||||
|
} else {
|
||||||
|
// Shallow clone of branch HEAD
|
||||||
|
cmd = []string{"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
|
||||||
resp, err := c.docker.ContainerCreate(ctx,
|
resp, err := c.docker.ContainerCreate(ctx,
|
||||||
&container.Config{
|
&container.Config{
|
||||||
Image: "alpine/git:latest",
|
Image: gitImage,
|
||||||
Cmd: []string{
|
Entrypoint: []string{}, // Clear entrypoint when using sh -c
|
||||||
"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo",
|
Cmd: cmd,
|
||||||
},
|
|
||||||
Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
|
Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
|
||||||
WorkingDir: "/",
|
WorkingDir: "/",
|
||||||
},
|
},
|
||||||
&container.HostConfig{
|
&container.HostConfig{
|
||||||
Mounts: []mount.Mount{
|
Mounts: []mount.Mount{
|
||||||
{Type: mount.TypeBind, Source: cfg.destDir, Target: "/repo"},
|
{Type: mount.TypeBind, Source: cfg.hostDir, Target: "/repo"},
|
||||||
{
|
{
|
||||||
Type: mount.TypeBind,
|
Type: mount.TypeBind,
|
||||||
Source: cfg.keyFile,
|
Source: cfg.hostKeyFile,
|
||||||
Target: "/keys/deploy_key",
|
Target: "/keys/deploy_key",
|
||||||
ReadOnly: true,
|
ReadOnly: true,
|
||||||
},
|
},
|
||||||
@ -542,31 +598,32 @@ func (c *Client) createGitContainer(
|
|||||||
return resp.ID, nil
|
return resp.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) runGitClone(ctx context.Context, containerID string) error {
|
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
|
||||||
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start git container: %w", err)
|
return nil, fmt.Errorf("failed to start git container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
return fmt.Errorf("error waiting for git container: %w", err)
|
return nil, fmt.Errorf("error waiting for git container: %w", err)
|
||||||
case status := <-statusCh:
|
case status := <-statusCh:
|
||||||
if status.StatusCode != 0 {
|
// Always capture logs for the result
|
||||||
logs, _ := c.ContainerLogs(ctx, containerID, "100")
|
logs, _ := c.ContainerLogs(ctx, containerID, "100")
|
||||||
|
|
||||||
return fmt.Errorf(
|
if status.StatusCode != 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
"%w with status %d: %s",
|
"%w with status %d: %s",
|
||||||
ErrGitCloneFailed,
|
ErrGitCloneFailed,
|
||||||
status.StatusCode,
|
status.StatusCode,
|
||||||
logs,
|
logs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return &CloneResult{Output: logs}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) connect(ctx context.Context) error {
|
func (c *Client) connect(ctx context.Context) error {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -27,7 +28,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
|||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
data := map[string]any{}
|
data := h.addGlobals(map[string]any{})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -127,22 +128,28 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|||||||
recentDeploymentsLimit,
|
recentDeploymentsLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
host := request.Host
|
// Get latest deployment for build logs pane
|
||||||
webhookURL := "https://" + host + "/webhook/" + application.WebhookSecret
|
var latestDeployment *models.Deployment
|
||||||
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, host)
|
if len(deployments) > 0 {
|
||||||
|
latestDeployment = deployments[0]
|
||||||
data := map[string]any{
|
|
||||||
"App": application,
|
|
||||||
"EnvVars": envVars,
|
|
||||||
"Labels": labels,
|
|
||||||
"Volumes": volumes,
|
|
||||||
"Ports": ports,
|
|
||||||
"Deployments": deployments,
|
|
||||||
"WebhookURL": webhookURL,
|
|
||||||
"DeployKey": deployKey,
|
|
||||||
"Success": request.URL.Query().Get("success"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
|
||||||
|
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
|
||||||
|
|
||||||
|
data := h.addGlobals(map[string]any{
|
||||||
|
"App": application,
|
||||||
|
"EnvVars": envVars,
|
||||||
|
"Labels": labels,
|
||||||
|
"Volumes": volumes,
|
||||||
|
"Ports": ports,
|
||||||
|
"Deployments": deployments,
|
||||||
|
"LatestDeployment": latestDeployment,
|
||||||
|
"WebhookURL": webhookURL,
|
||||||
|
"DeployKey": deployKey,
|
||||||
|
"Success": request.URL.Query().Get("success"),
|
||||||
|
})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("template execution failed", "error", err)
|
h.log.Error("template execution failed", "error", err)
|
||||||
@ -172,9 +179,9 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"App": application,
|
"App": application,
|
||||||
}
|
})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -325,10 +332,10 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
|||||||
deploymentsHistoryLimit,
|
deploymentsHistoryLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"App": application,
|
"App": application,
|
||||||
"Deployments": deployments,
|
"Deployments": deployments,
|
||||||
}
|
})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -388,6 +395,148 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleDeploymentLogsAPI returns JSON with deployment logs.
|
||||||
|
func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
deploymentIDStr := chi.URLParam(request, "deploymentID")
|
||||||
|
|
||||||
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
||||||
|
if findErr != nil || application == nil {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
|
||||||
|
if deployErr != nil || deployment == nil || deployment.AppID != appID {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
logs := ""
|
||||||
|
if deployment.Logs.Valid {
|
||||||
|
logs = deployment.Logs.String
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]any{
|
||||||
|
"logs": logs,
|
||||||
|
"status": deployment.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containerLogsAPITail is the default number of log lines for the container logs API.
|
||||||
|
const containerLogsAPITail = "100"
|
||||||
|
|
||||||
|
// HandleContainerLogsAPI returns JSON with container logs.
|
||||||
|
func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
|
||||||
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
||||||
|
if findErr != nil || application == nil {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
||||||
|
if containerErr != nil || containerInfo == nil {
|
||||||
|
response := map[string]any{
|
||||||
|
"logs": "No container running\n",
|
||||||
|
"status": "stopped",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, logsErr := h.docker.ContainerLogs(
|
||||||
|
request.Context(),
|
||||||
|
containerInfo.ID,
|
||||||
|
containerLogsAPITail,
|
||||||
|
)
|
||||||
|
if logsErr != nil {
|
||||||
|
h.log.Error("failed to get container logs",
|
||||||
|
"error", logsErr,
|
||||||
|
"app", application.Name,
|
||||||
|
"container", containerInfo.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
response := map[string]any{
|
||||||
|
"logs": "Failed to fetch container logs\n",
|
||||||
|
"status": "error",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "stopped"
|
||||||
|
if containerInfo.Running {
|
||||||
|
status = "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]any{
|
||||||
|
"logs": logs,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAppStatusAPI returns JSON with app status and latest deployment info.
|
||||||
|
func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
|
||||||
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
||||||
|
if findErr != nil || application == nil {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Get latest deployment
|
||||||
|
deployments, _ := application.GetDeployments(request.Context(), 1)
|
||||||
|
|
||||||
|
var latestDeploymentID int64
|
||||||
|
|
||||||
|
var latestDeploymentStatus string
|
||||||
|
|
||||||
|
if len(deployments) > 0 {
|
||||||
|
latestDeploymentID = deployments[0].ID
|
||||||
|
latestDeploymentStatus = string(deployments[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]any{
|
||||||
|
"status": string(application.Status),
|
||||||
|
"latestDeploymentID": latestDeploymentID,
|
||||||
|
"latestDeploymentStatus": latestDeploymentStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// containerAction represents a container operation type.
|
// containerAction represents a container operation type.
|
||||||
type containerAction string
|
type containerAction string
|
||||||
|
|
||||||
@ -778,8 +927,8 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatDeployKey formats an SSH public key with a descriptive comment.
|
// formatDeployKey formats an SSH public key with a descriptive comment.
|
||||||
// Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com.
|
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
||||||
func formatDeployKey(pubKey string, createdAt time.Time, host string) string {
|
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
||||||
const minKeyParts = 2
|
const minKeyParts = 2
|
||||||
|
|
||||||
parts := strings.Fields(pubKey)
|
parts := strings.Fields(pubKey)
|
||||||
@ -787,7 +936,7 @@ func formatDeployKey(pubKey string, createdAt time.Time, host string) string {
|
|||||||
return pubKey
|
return pubKey
|
||||||
}
|
}
|
||||||
|
|
||||||
comment := "upaas-" + createdAt.Format("2006-01-02") + "-" + host
|
comment := "upaas_" + createdAt.Format("2006-01-02") + "_" + appName
|
||||||
|
|
||||||
return parts[0] + " " + parts[1] + " " + comment
|
return parts[0] + " " + parts[1] + " " + comment
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
|||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
data := map[string]any{}
|
data := h.addGlobals(map[string]any{})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -36,9 +36,9 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
|
|||||||
username := request.FormValue("username")
|
username := request.FormValue("username")
|
||||||
password := request.FormValue("password")
|
password := request.FormValue("password")
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Username": username,
|
"Username": username,
|
||||||
}
|
})
|
||||||
|
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
data["Error"] = "Username and password are required"
|
data["Error"] = "Username and password are required"
|
||||||
|
|||||||
@ -20,9 +20,9 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Apps": apps,
|
"Apps": apps,
|
||||||
}
|
})
|
||||||
|
|
||||||
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
|
|||||||
@ -45,6 +45,7 @@ type Handlers struct {
|
|||||||
deploy *deploy.Service
|
deploy *deploy.Service
|
||||||
webhook *webhook.Service
|
webhook *webhook.Service
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
|
globals *globals.Globals
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@ -59,9 +60,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
deploy: params.Deploy,
|
deploy: params.Deploy,
|
||||||
webhook: params.Webhook,
|
webhook: params.Webhook,
|
||||||
docker: params.Docker,
|
docker: params.Docker,
|
||||||
|
globals: params.Globals,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addGlobals adds version info to template data map.
|
||||||
|
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
|
||||||
|
data["Version"] = h.globals.Version
|
||||||
|
data["Appname"] = h.globals.Appname
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) respondJSON(
|
func (h *Handlers) respondJSON(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
|||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
data := map[string]any{}
|
data := h.addGlobals(map[string]any{})
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -51,16 +51,16 @@ func validateSetupForm(formData setupFormData) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderSetupError renders the setup page with an error message.
|
// renderSetupError renders the setup page with an error message.
|
||||||
func renderSetupError(
|
func (h *Handlers) renderSetupError(
|
||||||
tmpl *templates.TemplateExecutor,
|
tmpl *templates.TemplateExecutor,
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
username string,
|
username string,
|
||||||
errorMsg string,
|
errorMsg string,
|
||||||
) {
|
) {
|
||||||
data := map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Username": username,
|
"Username": username,
|
||||||
"Error": errorMsg,
|
"Error": errorMsg,
|
||||||
}
|
})
|
||||||
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if validationErr := validateSetupForm(formData); validationErr != "" {
|
if validationErr := validateSetupForm(formData); validationErr != "" {
|
||||||
renderSetupError(tmpl, writer, formData.username, validationErr)
|
h.renderSetupError(tmpl, writer, formData.username, validationErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -95,7 +95,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
h.log.Error("failed to create user", "error", createErr)
|
h.log.Error("failed to create user", "error", createErr)
|
||||||
renderSetupError(tmpl, writer, formData.username, "Failed to create user")
|
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
|||||||
sessionErr := h.auth.CreateSession(writer, request, user)
|
sessionErr := h.auth.CreateSession(writer, request, user)
|
||||||
if sessionErr != nil {
|
if sessionErr != nil {
|
||||||
h.log.Error("failed to create session", "error", sessionErr)
|
h.log.Error("failed to create session", "error", sessionErr)
|
||||||
renderSetupError(
|
h.renderSetupError(
|
||||||
tmpl,
|
tmpl,
|
||||||
writer,
|
writer,
|
||||||
formData.username,
|
formData.username,
|
||||||
|
|||||||
@ -21,6 +21,14 @@ const (
|
|||||||
DeploymentStatusFailed DeploymentStatus = "failed"
|
DeploymentStatusFailed DeploymentStatus = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Display constants.
|
||||||
|
const (
|
||||||
|
// secondsPerMinute is used for duration formatting.
|
||||||
|
secondsPerMinute = 60
|
||||||
|
// shortCommitLength is the number of characters to show for commit SHA.
|
||||||
|
shortCommitLength = 12
|
||||||
|
)
|
||||||
|
|
||||||
// Deployment represents a deployment attempt for an app.
|
// Deployment represents a deployment attempt for an app.
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
db *database.Database
|
db *database.Database
|
||||||
@ -90,6 +98,61 @@ func (d *Deployment) MarkFinished(
|
|||||||
return d.Save(ctx)
|
return d.Save(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duration returns the duration of the deployment as a formatted string.
|
||||||
|
// Returns empty string if deployment is not finished.
|
||||||
|
func (d *Deployment) Duration() string {
|
||||||
|
if !d.FinishedAt.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := d.FinishedAt.Time.Sub(d.StartedAt)
|
||||||
|
|
||||||
|
// Format as minutes and seconds
|
||||||
|
minutes := int(duration.Minutes())
|
||||||
|
seconds := int(duration.Seconds()) % secondsPerMinute
|
||||||
|
|
||||||
|
if minutes > 0 {
|
||||||
|
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%ds", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortCommit returns a truncated commit SHA for display.
|
||||||
|
// Returns "-" if no commit SHA is set.
|
||||||
|
func (d *Deployment) ShortCommit() string {
|
||||||
|
if !d.CommitSHA.Valid || d.CommitSHA.String == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
sha := d.CommitSHA.String
|
||||||
|
if len(sha) > shortCommitLength {
|
||||||
|
return sha[:shortCommitLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishedAtISO returns the finished time in ISO format for JavaScript parsing.
|
||||||
|
// Falls back to started time if not finished yet.
|
||||||
|
func (d *Deployment) FinishedAtISO() string {
|
||||||
|
if d.FinishedAt.Valid {
|
||||||
|
return d.FinishedAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.StartedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishedAtFormatted returns the finished time formatted for display.
|
||||||
|
// Falls back to started time if not finished yet.
|
||||||
|
func (d *Deployment) FinishedAtFormatted() string {
|
||||||
|
if d.FinishedAt.Valid {
|
||||||
|
return d.FinishedAt.Time.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.StartedAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Deployment) insert(ctx context.Context) error {
|
func (d *Deployment) insert(ctx context.Context) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO deployments (
|
INSERT INTO deployments (
|
||||||
|
|||||||
@ -65,7 +65,10 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
|
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
|
||||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
||||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
||||||
|
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
|
||||||
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
||||||
|
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
|
||||||
|
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
||||||
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
|
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
|
||||||
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
||||||
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@ -28,14 +31,151 @@ const (
|
|||||||
upaasLabelCount = 1
|
upaasLabelCount = 1
|
||||||
// buildsDirPermissions is the permission mode for the builds directory.
|
// buildsDirPermissions is the permission mode for the builds directory.
|
||||||
buildsDirPermissions = 0o750
|
buildsDirPermissions = 0o750
|
||||||
|
// buildTimeout is the maximum duration for the build phase.
|
||||||
|
buildTimeout = 30 * time.Minute
|
||||||
|
// deployTimeout is the maximum duration for the deploy phase (container swap).
|
||||||
|
deployTimeout = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for deployment failures.
|
// Sentinel errors for deployment failures.
|
||||||
var (
|
var (
|
||||||
// ErrContainerUnhealthy indicates the container failed health check.
|
// ErrContainerUnhealthy indicates the container failed health check.
|
||||||
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
|
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
|
||||||
|
// ErrDeploymentInProgress indicates another deployment is already running.
|
||||||
|
ErrDeploymentInProgress = errors.New("deployment already in progress for this app")
|
||||||
|
// ErrBuildTimeout indicates the build phase exceeded the timeout.
|
||||||
|
ErrBuildTimeout = errors.New("build timeout exceeded")
|
||||||
|
// ErrDeployTimeout indicates the deploy phase exceeded the timeout.
|
||||||
|
ErrDeployTimeout = errors.New("deploy timeout exceeded")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// logFlushInterval is how often to flush buffered logs to the database.
|
||||||
|
const logFlushInterval = time.Second
|
||||||
|
|
||||||
|
// dockerLogMessage represents a Docker build log message.
|
||||||
|
type dockerLogMessage struct {
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deploymentLogWriter implements io.Writer and periodically flushes to deployment logs.
|
||||||
|
// It parses Docker JSON log format and extracts the stream content.
|
||||||
|
type deploymentLogWriter struct {
|
||||||
|
deployment *models.Deployment
|
||||||
|
buffer bytes.Buffer
|
||||||
|
lineBuffer bytes.Buffer // buffer for incomplete lines
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter {
|
||||||
|
w := &deploymentLogWriter{
|
||||||
|
deployment: deployment,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
flushCtx: ctx,
|
||||||
|
}
|
||||||
|
go w.runFlushLoop()
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements io.Writer.
|
||||||
|
// It parses Docker JSON log format and extracts the stream content.
|
||||||
|
func (w *deploymentLogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
// Add incoming data to line buffer
|
||||||
|
w.lineBuffer.Write(p)
|
||||||
|
|
||||||
|
// Process complete lines
|
||||||
|
data := w.lineBuffer.Bytes()
|
||||||
|
lastNewline := bytes.LastIndexByte(data, '\n')
|
||||||
|
|
||||||
|
if lastNewline == -1 {
|
||||||
|
// No complete lines yet
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all complete lines
|
||||||
|
completeData := data[:lastNewline+1]
|
||||||
|
remaining := data[lastNewline+1:]
|
||||||
|
|
||||||
|
for line := range bytes.SplitSeq(completeData, []byte{'\n'}) {
|
||||||
|
w.processLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep any remaining incomplete line data
|
||||||
|
w.lineBuffer.Reset()
|
||||||
|
|
||||||
|
if len(remaining) > 0 {
|
||||||
|
w.lineBuffer.Write(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the flush loop and performs a final flush.
|
||||||
|
func (w *deploymentLogWriter) Close() {
|
||||||
|
close(w.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *deploymentLogWriter) runFlushLoop() {
|
||||||
|
ticker := time.NewTicker(logFlushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
w.doFlush()
|
||||||
|
case <-w.done:
|
||||||
|
w.doFlush()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *deploymentLogWriter) doFlush() {
|
||||||
|
w.mu.Lock()
|
||||||
|
data := w.buffer.String()
|
||||||
|
w.buffer.Reset()
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if data != "" {
|
||||||
|
_ = w.deployment.AppendLog(w.flushCtx, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processLine parses a JSON log line and extracts the stream content.
|
||||||
|
func (w *deploymentLogWriter) processLine(line []byte) {
|
||||||
|
if len(line) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg dockerLogMessage
|
||||||
|
|
||||||
|
err := json.Unmarshal(line, &msg)
|
||||||
|
if err != nil {
|
||||||
|
// Not valid JSON, write as-is
|
||||||
|
w.buffer.Write(line)
|
||||||
|
w.buffer.WriteByte('\n')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Error != "" {
|
||||||
|
w.buffer.WriteString("ERROR: ")
|
||||||
|
w.buffer.WriteString(msg.Error)
|
||||||
|
w.buffer.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Stream != "" {
|
||||||
|
w.buffer.WriteString(msg.Stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ServiceParams contains dependencies for Service.
|
// ServiceParams contains dependencies for Service.
|
||||||
type ServiceParams struct {
|
type ServiceParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
@ -49,24 +189,35 @@ type ServiceParams struct {
|
|||||||
|
|
||||||
// Service provides deployment functionality.
|
// Service provides deployment functionality.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
db *database.Database
|
db *database.Database
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
notify *notify.Service
|
notify *notify.Service
|
||||||
config *config.Config
|
config *config.Config
|
||||||
params *ServiceParams
|
params *ServiceParams
|
||||||
|
appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new deploy Service.
|
// New creates a new deploy Service.
|
||||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||||
return &Service{
|
svc := &Service{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
docker: params.Docker,
|
docker: params.Docker,
|
||||||
notify: params.Notify,
|
notify: params.Notify,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
if lc != nil {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
return svc.cleanupStuckDeployments(ctx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBuildDir returns the build directory path for an app.
|
// GetBuildDir returns the build directory path for an app.
|
||||||
@ -80,11 +231,24 @@ func (svc *Service) Deploy(
|
|||||||
app *models.App,
|
app *models.App,
|
||||||
webhookEventID *int64,
|
webhookEventID *int64,
|
||||||
) error {
|
) error {
|
||||||
deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID)
|
// Try to acquire per-app deployment lock
|
||||||
|
if !svc.tryLockApp(app.ID) {
|
||||||
|
svc.log.Warn("deployment already in progress", "app", app.Name)
|
||||||
|
|
||||||
|
return ErrDeploymentInProgress
|
||||||
|
}
|
||||||
|
defer svc.unlockApp(app.ID)
|
||||||
|
|
||||||
|
// Fetch webhook event and create deployment record
|
||||||
|
webhookEvent := svc.fetchWebhookEvent(ctx, webhookEventID)
|
||||||
|
|
||||||
|
deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID, webhookEvent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.logWebhookPayload(ctx, deployment, webhookEvent)
|
||||||
|
|
||||||
err = svc.updateAppStatusBuilding(ctx, app)
|
err = svc.updateAppStatusBuilding(ctx, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -92,21 +256,16 @@ func (svc *Service) Deploy(
|
|||||||
|
|
||||||
svc.notify.NotifyBuildStart(ctx, app, deployment)
|
svc.notify.NotifyBuildStart(ctx, app, deployment)
|
||||||
|
|
||||||
imageID, err := svc.buildImage(ctx, app, deployment)
|
// Build phase with timeout
|
||||||
|
imageID, err := svc.buildImageWithTimeout(ctx, app, deployment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.notify.NotifyBuildSuccess(ctx, app, deployment)
|
svc.notify.NotifyBuildSuccess(ctx, app, deployment)
|
||||||
|
|
||||||
err = svc.updateDeploymentDeploying(ctx, deployment)
|
// Deploy phase with timeout
|
||||||
if err != nil {
|
err = svc.deployContainerWithTimeout(ctx, app, deployment, imageID)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.removeOldContainer(ctx, app, deployment)
|
|
||||||
|
|
||||||
_, err = svc.createAndStartContainer(ctx, app, deployment, imageID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -123,10 +282,162 @@ func (svc *Service) Deploy(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildImageWithTimeout runs the build phase with a timeout.
|
||||||
|
func (svc *Service) buildImageWithTimeout(
|
||||||
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
) (string, error) {
|
||||||
|
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
imageID, err := svc.buildImage(buildCtx, app, deployment)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(buildCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
timeoutErr := fmt.Errorf("%w after %v", ErrBuildTimeout, buildTimeout)
|
||||||
|
svc.failDeployment(ctx, app, deployment, timeoutErr)
|
||||||
|
|
||||||
|
return "", timeoutErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployContainerWithTimeout runs the deploy phase with a timeout.
|
||||||
|
// It stops the old container, starts the new one, and handles rollback on failure.
|
||||||
|
func (svc *Service) deployContainerWithTimeout(
|
||||||
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
imageID string,
|
||||||
|
) error {
|
||||||
|
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := svc.updateDeploymentDeploying(deployCtx, deployment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop old container (but don't remove yet - keep for potential rollback)
|
||||||
|
oldContainerID := svc.stopOldContainer(deployCtx, app, deployment)
|
||||||
|
|
||||||
|
// Try to create and start the new container
|
||||||
|
_, err = svc.createAndStartContainer(deployCtx, app, deployment, imageID)
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: restart the old container if we have one
|
||||||
|
if oldContainerID != "" {
|
||||||
|
svc.rollbackContainer(ctx, oldContainerID, deployment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(deployCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
timeoutErr := fmt.Errorf("%w after %v", ErrDeployTimeout, deployTimeout)
|
||||||
|
svc.failDeployment(ctx, app, deployment, timeoutErr)
|
||||||
|
|
||||||
|
return timeoutErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: remove the old container
|
||||||
|
if oldContainerID != "" {
|
||||||
|
svc.removeContainer(ctx, oldContainerID, deployment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupStuckDeployments marks any deployments stuck in building/deploying as failed.
|
||||||
|
func (svc *Service) cleanupStuckDeployments(ctx context.Context) error {
|
||||||
|
query := `
|
||||||
|
UPDATE deployments
|
||||||
|
SET status = ?, finished_at = ?, logs = COALESCE(logs, '') || ?
|
||||||
|
WHERE status IN (?, ?)
|
||||||
|
`
|
||||||
|
msg := "\n[System] Deployment marked as failed: process was interrupted\n"
|
||||||
|
|
||||||
|
_, err := svc.db.DB().ExecContext(
|
||||||
|
ctx,
|
||||||
|
query,
|
||||||
|
models.DeploymentStatusFailed,
|
||||||
|
time.Now(),
|
||||||
|
msg,
|
||||||
|
models.DeploymentStatusBuilding,
|
||||||
|
models.DeploymentStatusDeploying,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Error("failed to cleanup stuck deployments", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to cleanup stuck deployments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.log.Info("cleaned up stuck deployments")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) getAppLock(appID string) *sync.Mutex {
|
||||||
|
lock, _ := svc.appLocks.LoadOrStore(appID, &sync.Mutex{})
|
||||||
|
|
||||||
|
mu, ok := lock.(*sync.Mutex)
|
||||||
|
if !ok {
|
||||||
|
// This should never happen, but handle it gracefully
|
||||||
|
newMu := &sync.Mutex{}
|
||||||
|
svc.appLocks.Store(appID, newMu)
|
||||||
|
|
||||||
|
return newMu
|
||||||
|
}
|
||||||
|
|
||||||
|
return mu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) tryLockApp(appID string) bool {
|
||||||
|
return svc.getAppLock(appID).TryLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) unlockApp(appID string) {
|
||||||
|
svc.getAppLock(appID).Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) fetchWebhookEvent(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookEventID *int64,
|
||||||
|
) *models.WebhookEvent {
|
||||||
|
if webhookEventID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := models.FindWebhookEvent(ctx, svc.db, *webhookEventID)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Warn("failed to fetch webhook event", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) logWebhookPayload(
|
||||||
|
ctx context.Context,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
webhookEvent *models.WebhookEvent,
|
||||||
|
) {
|
||||||
|
if webhookEvent == nil || !webhookEvent.Payload.Valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = deployment.AppendLog(ctx, "Webhook received:\n"+webhookEvent.Payload.String+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) createDeploymentRecord(
|
func (svc *Service) createDeploymentRecord(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
webhookEventID *int64,
|
webhookEventID *int64,
|
||||||
|
webhookEvent *models.WebhookEvent,
|
||||||
) (*models.Deployment, error) {
|
) (*models.Deployment, error) {
|
||||||
deployment := models.NewDeployment(svc.db)
|
deployment := models.NewDeployment(svc.db)
|
||||||
deployment.AppID = app.ID
|
deployment.AppID = app.ID
|
||||||
@ -138,6 +449,11 @@ func (svc *Service) createDeploymentRecord(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set commit SHA from webhook event
|
||||||
|
if webhookEvent != nil && webhookEvent.CommitSHA.Valid {
|
||||||
|
deployment.CommitSHA = webhookEvent.CommitSHA
|
||||||
|
}
|
||||||
|
|
||||||
deployment.Status = models.DeploymentStatusBuilding
|
deployment.Status = models.DeploymentStatusBuilding
|
||||||
|
|
||||||
saveErr := deployment.Save(ctx)
|
saveErr := deployment.Save(ctx)
|
||||||
@ -167,7 +483,7 @@ func (svc *Service) buildImage(
|
|||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
tempDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -176,10 +492,17 @@ func (svc *Service) buildImage(
|
|||||||
|
|
||||||
imageTag := "upaas/" + app.Name + ":latest"
|
imageTag := "upaas/" + app.Name + ":latest"
|
||||||
|
|
||||||
|
// Create log writer that flushes build output to deployment logs every second
|
||||||
|
logWriter := newDeploymentLogWriter(ctx, deployment)
|
||||||
|
defer logWriter.Close()
|
||||||
|
|
||||||
|
// BuildImage creates a tar archive from the local filesystem,
|
||||||
|
// so it needs the container path where files exist, not the host path.
|
||||||
imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{
|
imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{
|
||||||
ContextDir: tempDir,
|
ContextDir: workDir,
|
||||||
DockerfilePath: app.DockerfilePath,
|
DockerfilePath: app.DockerfilePath,
|
||||||
Tags: []string{imageTag},
|
Tags: []string{imageTag},
|
||||||
|
LogWriter: logWriter,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
|
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
|
||||||
@ -206,7 +529,9 @@ func (svc *Service) cloneRepository(
|
|||||||
) (string, func(), error) {
|
) (string, func(), error) {
|
||||||
// Use a subdirectory of DataDir for builds since it's mounted from the host
|
// Use a subdirectory of DataDir for builds since it's mounted from the host
|
||||||
// and accessible to Docker for bind mounts (unlike /tmp inside the container).
|
// and accessible to Docker for bind mounts (unlike /tmp inside the container).
|
||||||
// Structure: builds/<appname>/<deployment-id>-<random>
|
// Structure: builds/<appname>/<deployment-id>-<random>/
|
||||||
|
// deploy_key <- SSH key for cloning
|
||||||
|
// work/ <- cloned repository
|
||||||
appBuildsDir := filepath.Join(svc.config.DataDir, "builds", app.Name)
|
appBuildsDir := filepath.Join(svc.config.DataDir, "builds", app.Name)
|
||||||
|
|
||||||
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
|
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
|
||||||
@ -216,16 +541,37 @@ func (svc *Service) cloneRepository(
|
|||||||
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
|
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
|
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
|
||||||
|
|
||||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup := func() { _ = os.RemoveAll(tempDir) }
|
cleanup := func() { _ = os.RemoveAll(buildDir) }
|
||||||
|
|
||||||
cloneErr := svc.docker.CloneRepo(ctx, app.RepoURL, app.Branch, app.SSHPrivateKey, tempDir)
|
// Calculate the host path for Docker bind mounts.
|
||||||
|
// When upaas runs in a container, DataDir is the container path but Docker
|
||||||
|
// needs the host path. HostDataDir provides the host-side equivalent.
|
||||||
|
// CloneRepo needs both: container path for writing files, host path for Docker mounts.
|
||||||
|
hostBuildDir := svc.containerToHostPath(buildDir)
|
||||||
|
|
||||||
|
// Get commit SHA from deployment if available
|
||||||
|
var commitSHA string
|
||||||
|
|
||||||
|
if deployment.CommitSHA.Valid {
|
||||||
|
commitSHA = deployment.CommitSHA.String
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneResult, cloneErr := svc.docker.CloneRepo(
|
||||||
|
ctx,
|
||||||
|
app.RepoURL,
|
||||||
|
app.Branch,
|
||||||
|
commitSHA,
|
||||||
|
app.SSHPrivateKey,
|
||||||
|
buildDir,
|
||||||
|
hostBuildDir,
|
||||||
|
)
|
||||||
if cloneErr != nil {
|
if cloneErr != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
|
||||||
@ -233,9 +579,38 @@ func (svc *Service) cloneRepository(
|
|||||||
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Repository cloned successfully")
|
// Log clone success with git output
|
||||||
|
if commitSHA != "" {
|
||||||
|
_ = deployment.AppendLog(ctx, "Repository cloned at commit "+commitSHA)
|
||||||
|
} else {
|
||||||
|
_ = deployment.AppendLog(ctx, "Repository cloned (branch: "+app.Branch+")")
|
||||||
|
}
|
||||||
|
|
||||||
return tempDir, cleanup, nil
|
if cloneResult != nil && cloneResult.Output != "" {
|
||||||
|
_ = deployment.AppendLog(ctx, cloneResult.Output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the 'work' subdirectory where the repo was cloned
|
||||||
|
workDir := filepath.Join(buildDir, "work")
|
||||||
|
|
||||||
|
return workDir, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// containerToHostPath converts a container-local path to the equivalent host path.
|
||||||
|
// This is needed when upaas runs inside a container but needs to pass paths to Docker.
|
||||||
|
func (svc *Service) containerToHostPath(containerPath string) string {
|
||||||
|
if svc.config.HostDataDir == svc.config.DataDir {
|
||||||
|
return containerPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path from DataDir
|
||||||
|
relPath, err := filepath.Rel(svc.config.DataDir, containerPath)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to original path if we can't compute relative path
|
||||||
|
return containerPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(svc.config.HostDataDir, relPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) updateDeploymentDeploying(
|
func (svc *Service) updateDeploymentDeploying(
|
||||||
@ -252,24 +627,68 @@ func (svc *Service) updateDeploymentDeploying(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) removeOldContainer(
|
// stopOldContainer stops the old container but keeps it for potential rollback.
|
||||||
|
// Returns the container ID if found, empty string otherwise.
|
||||||
|
func (svc *Service) stopOldContainer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) string {
|
||||||
containerInfo, err := svc.docker.FindContainerByAppID(ctx, app.ID)
|
containerInfo, err := svc.docker.FindContainerByAppID(ctx, app.ID)
|
||||||
if err != nil || containerInfo == nil {
|
if err != nil || containerInfo == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.log.Info("stopping old container", "id", containerInfo.ID)
|
||||||
|
|
||||||
|
if containerInfo.Running {
|
||||||
|
stopErr := svc.docker.StopContainer(ctx, containerInfo.ID)
|
||||||
|
if stopErr != nil {
|
||||||
|
svc.log.Warn("failed to stop old container", "error", stopErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = deployment.AppendLog(ctx, "Old container stopped: "+containerInfo.ID[:12])
|
||||||
|
|
||||||
|
return containerInfo.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackContainer restarts the old container after a failed deployment.
|
||||||
|
func (svc *Service) rollbackContainer(
|
||||||
|
ctx context.Context,
|
||||||
|
containerID string,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
) {
|
||||||
|
svc.log.Info("rolling back to old container", "id", containerID)
|
||||||
|
_ = deployment.AppendLog(ctx, "Rolling back to previous container: "+containerID[:12])
|
||||||
|
|
||||||
|
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||||
|
if startErr != nil {
|
||||||
|
svc.log.Error("failed to restart old container during rollback", "error", startErr)
|
||||||
|
_ = deployment.AppendLog(ctx, "ERROR: Failed to rollback: "+startErr.Error())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.log.Info("removing old container", "id", containerInfo.ID)
|
_ = deployment.AppendLog(ctx, "Rollback successful - previous container restarted")
|
||||||
|
}
|
||||||
|
|
||||||
removeErr := svc.docker.RemoveContainer(ctx, containerInfo.ID, true)
|
// removeContainer removes a container after successful deployment.
|
||||||
|
func (svc *Service) removeContainer(
|
||||||
|
ctx context.Context,
|
||||||
|
containerID string,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
) {
|
||||||
|
svc.log.Info("removing old container", "id", containerID)
|
||||||
|
|
||||||
|
removeErr := svc.docker.RemoveContainer(ctx, containerID, true)
|
||||||
if removeErr != nil {
|
if removeErr != nil {
|
||||||
svc.log.Warn("failed to remove old container", "error", removeErr)
|
svc.log.Warn("failed to remove old container", "error", removeErr)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Old container removed")
|
_ = deployment.AppendLog(ctx, "Old container removed: "+containerID[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) createAndStartContainer(
|
func (svc *Service) createAndStartContainer(
|
||||||
|
|||||||
@ -27,6 +27,12 @@ const (
|
|||||||
httpStatusClientError = 400
|
httpStatusClientError = 400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Display constants.
|
||||||
|
const (
|
||||||
|
shortCommitLength = 12
|
||||||
|
secondsPerMinute = 60
|
||||||
|
)
|
||||||
|
|
||||||
// Sentinel errors for notification failures.
|
// Sentinel errors for notification failures.
|
||||||
var (
|
var (
|
||||||
// ErrNtfyFailed indicates the ntfy notification request failed.
|
// ErrNtfyFailed indicates the ntfy notification request failed.
|
||||||
@ -64,10 +70,16 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
|||||||
func (svc *Service) NotifyBuildStart(
|
func (svc *Service) NotifyBuildStart(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
_ *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) {
|
||||||
title := "Build started: " + app.Name
|
title := "Build started: " + app.Name
|
||||||
message := "Building from branch " + app.Branch
|
message := "Building from branch " + app.Branch
|
||||||
|
|
||||||
|
if deployment.CommitSHA.Valid {
|
||||||
|
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
|
||||||
|
message += " at " + shortSHA
|
||||||
|
}
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, "info")
|
svc.sendNotifications(ctx, app, title, message, "info")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,10 +87,12 @@ func (svc *Service) NotifyBuildStart(
|
|||||||
func (svc *Service) NotifyBuildSuccess(
|
func (svc *Service) NotifyBuildSuccess(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
_ *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) {
|
||||||
|
duration := time.Since(deployment.StartedAt)
|
||||||
title := "Build success: " + app.Name
|
title := "Build success: " + app.Name
|
||||||
message := "Image built successfully from branch " + app.Branch
|
message := "Image built successfully in " + formatDuration(duration)
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, "success")
|
svc.sendNotifications(ctx, app, title, message, "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,11 +100,13 @@ func (svc *Service) NotifyBuildSuccess(
|
|||||||
func (svc *Service) NotifyBuildFailed(
|
func (svc *Service) NotifyBuildFailed(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
_ *models.Deployment,
|
deployment *models.Deployment,
|
||||||
buildErr error,
|
buildErr error,
|
||||||
) {
|
) {
|
||||||
|
duration := time.Since(deployment.StartedAt)
|
||||||
title := "Build failed: " + app.Name
|
title := "Build failed: " + app.Name
|
||||||
message := "Build failed: " + buildErr.Error()
|
message := "Build failed after " + formatDuration(duration) + ": " + buildErr.Error()
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, "error")
|
svc.sendNotifications(ctx, app, title, message, "error")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,10 +114,17 @@ func (svc *Service) NotifyBuildFailed(
|
|||||||
func (svc *Service) NotifyDeploySuccess(
|
func (svc *Service) NotifyDeploySuccess(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
_ *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) {
|
||||||
|
duration := time.Since(deployment.StartedAt)
|
||||||
title := "Deploy success: " + app.Name
|
title := "Deploy success: " + app.Name
|
||||||
message := "Successfully deployed from branch " + app.Branch
|
message := "Successfully deployed in " + formatDuration(duration)
|
||||||
|
|
||||||
|
if deployment.CommitSHA.Valid {
|
||||||
|
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
|
||||||
|
message += " (commit " + shortSHA + ")"
|
||||||
|
}
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, "success")
|
svc.sendNotifications(ctx, app, title, message, "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,14 +132,37 @@ func (svc *Service) NotifyDeploySuccess(
|
|||||||
func (svc *Service) NotifyDeployFailed(
|
func (svc *Service) NotifyDeployFailed(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
_ *models.Deployment,
|
deployment *models.Deployment,
|
||||||
deployErr error,
|
deployErr error,
|
||||||
) {
|
) {
|
||||||
|
duration := time.Since(deployment.StartedAt)
|
||||||
title := "Deploy failed: " + app.Name
|
title := "Deploy failed: " + app.Name
|
||||||
message := "Deployment failed: " + deployErr.Error()
|
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, "error")
|
svc.sendNotifications(ctx, app, title, message, "error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatDuration formats a duration for display.
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := int(d.Minutes())
|
||||||
|
seconds := int(d.Seconds()) % secondsPerMinute
|
||||||
|
|
||||||
|
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// minInt returns the smaller of two integers.
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) sendNotifications(
|
func (svc *Service) sendNotifications(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
@ -153,7 +199,7 @@ func (svc *Service) sendNotifications(
|
|||||||
// even if the parent context is cancelled.
|
// even if the parent context is cancelled.
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message)
|
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message, priority)
|
||||||
if slackErr != nil {
|
if slackErr != nil {
|
||||||
svc.log.Error(
|
svc.log.Error(
|
||||||
"failed to send slack notification",
|
"failed to send slack notification",
|
||||||
@ -213,6 +259,19 @@ func (svc *Service) ntfyPriority(priority string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *Service) slackColor(priority string) string {
|
||||||
|
switch priority {
|
||||||
|
case "error":
|
||||||
|
return "#dc3545" // red
|
||||||
|
case "success":
|
||||||
|
return "#28a745" // green
|
||||||
|
case "info":
|
||||||
|
return "#17a2b8" // blue
|
||||||
|
default:
|
||||||
|
return "#6c757d" // gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SlackPayload represents a Slack webhook payload.
|
// SlackPayload represents a Slack webhook payload.
|
||||||
type SlackPayload struct {
|
type SlackPayload struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@ -228,7 +287,7 @@ type SlackAttachment struct {
|
|||||||
|
|
||||||
func (svc *Service) sendSlack(
|
func (svc *Service) sendSlack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookURL, title, message string,
|
webhookURL, title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
svc.log.Debug(
|
svc.log.Debug(
|
||||||
"sending slack notification",
|
"sending slack notification",
|
||||||
@ -239,7 +298,7 @@ func (svc *Service) sendSlack(
|
|||||||
payload := SlackPayload{
|
payload := SlackPayload{
|
||||||
Attachments: []SlackAttachment{
|
Attachments: []SlackAttachment{
|
||||||
{
|
{
|
||||||
Color: "#36a64f",
|
Color: svc.slackColor(priority),
|
||||||
Title: title,
|
Title: title,
|
||||||
Text: message,
|
Text: message,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -23,24 +23,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
|
||||||
{{if eq .App.Status "running"}}
|
<span id="app-status" class="{{if eq .App.Status "running"}}badge-success{{else if eq .App.Status "building"}}badge-warning{{else if eq .App.Status "error"}}badge-error{{else}}badge-neutral{{end}}">{{.App.Status}}</span>
|
||||||
<span class="badge-success">Running</span>
|
|
||||||
{{else if eq .App.Status "building"}}
|
|
||||||
<span class="badge-warning">Building</span>
|
|
||||||
{{else if eq .App.Status "error"}}
|
|
||||||
<span class="badge-error">Error</span>
|
|
||||||
{{else if eq .App.Status "stopped"}}
|
|
||||||
<span class="badge-neutral">Stopped</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="badge-neutral">{{.App.Status}}</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}} @ {{.App.Branch}}</p>
|
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
|
<form id="deploy-form" method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
|
||||||
<button type="submit" class="btn-success">Deploy Now</button>
|
<button id="deploy-btn" type="submit" class="btn-success" {{if or (eq .App.Status "building") (eq .App.Status "deploying")}}disabled{{end}}>
|
||||||
|
<span id="deploy-btn-text">{{if or (eq .App.Status "building") (eq .App.Status "deploying")}}Deploying...{{else}}Deploy Now{{end}}</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -252,19 +244,35 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
|
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<input type="number" name="host_port" placeholder="Host port" required min="1" max="65535" class="input font-mono text-sm">
|
<label class="block text-xs text-gray-500 mb-1">Host (external)</label>
|
||||||
|
<input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 w-full">
|
<div class="flex-1 w-full">
|
||||||
<input type="number" name="container_port" placeholder="Container port" required min="1" max="65535" class="input font-mono text-sm">
|
<label class="block text-xs text-gray-500 mb-1">Container (internal)</label>
|
||||||
|
<input type="text" name="container_port" placeholder="80" required pattern="[0-9]+" class="input font-mono text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Protocol</label>
|
||||||
|
<select name="protocol" class="input text-sm">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<select name="protocol" class="input text-sm">
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn-primary">Add</button>
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Container Logs -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="section-title">Container Logs</h2>
|
||||||
|
<span id="container-status" class="badge-neutral text-xs">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div id="container-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||||
|
<pre id="container-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">Loading container logs...</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recent Deployments -->
|
<!-- Recent Deployments -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@ -276,7 +284,8 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead class="table-header">
|
<thead class="table-header">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Started</th>
|
<th>Finished</th>
|
||||||
|
<th>Duration</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Commit</th>
|
<th>Commit</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -284,7 +293,10 @@
|
|||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .Deployments}}
|
{{range .Deployments}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-gray-500">{{.StartedAt.Format "2006-01-02 15:04:05"}}</td>
|
<td class="text-gray-500">
|
||||||
|
<span class="relative-time" data-time="{{.FinishedAtISO}}" title="{{.FinishedAtFormatted}}">{{.FinishedAtFormatted}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-gray-500">{{if .Duration}}{{.Duration}}{{else}}-{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Status "success"}}
|
{{if eq .Status "success"}}
|
||||||
<span class="badge-success">Success</span>
|
<span class="badge-success">Success</span>
|
||||||
@ -298,9 +310,7 @@
|
|||||||
<span class="badge-neutral">{{.Status}}</span>
|
<span class="badge-neutral">{{.Status}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-gray-500 text-xs">
|
<td class="font-mono text-gray-500 text-xs">{{.ShortCommit}}</td>
|
||||||
{{if .CommitSHA.Valid}}{{slice .CommitSHA.String 0 12}}{{else}}-{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -311,6 +321,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Deployment Build Logs -->
|
||||||
|
<div id="build-logs-section" class="card p-6 mb-6" {{if not .LatestDeployment}}style="display: none;"{{end}}>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="section-title">Last Deployment Build Logs</h2>
|
||||||
|
<span id="build-status" class="badge-neutral text-xs">{{if .LatestDeployment}}{{.LatestDeployment.Status}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div id="build-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||||
|
<pre id="build-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">{{if .LatestDeployment}}Loading build logs...{{else}}No deployments yet{{end}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
|
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
|
||||||
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
|
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
|
||||||
@ -320,4 +341,159 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Relative time formatting
|
||||||
|
function formatRelativeTime(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffMin < 60) return diffMin + (diffMin === 1 ? ' minute ago' : ' minutes ago');
|
||||||
|
if (diffHour < 24) return diffHour + (diffHour === 1 ? ' hour ago' : ' hours ago');
|
||||||
|
if (diffDay < 7) return diffDay + (diffDay === 1 ? ' day ago' : ' days ago');
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelativeTimes() {
|
||||||
|
document.querySelectorAll('.relative-time').forEach(el => {
|
||||||
|
const time = el.getAttribute('data-time');
|
||||||
|
if (time) {
|
||||||
|
el.textContent = formatRelativeTime(time);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update relative times on load and every minute
|
||||||
|
updateRelativeTimes();
|
||||||
|
setInterval(updateRelativeTimes, 60000);
|
||||||
|
|
||||||
|
const appId = "{{.App.ID}}";
|
||||||
|
const containerLogsEl = document.getElementById('container-logs');
|
||||||
|
const containerLogsWrapper = document.getElementById('container-logs-wrapper');
|
||||||
|
const containerStatusEl = document.getElementById('container-status');
|
||||||
|
const buildLogsEl = document.getElementById('build-logs');
|
||||||
|
const buildLogsWrapper = document.getElementById('build-logs-wrapper');
|
||||||
|
const buildStatusEl = document.getElementById('build-status');
|
||||||
|
const buildLogsSection = document.getElementById('build-logs-section');
|
||||||
|
const appStatusEl = document.getElementById('app-status');
|
||||||
|
const deployBtn = document.getElementById('deploy-btn');
|
||||||
|
const deployBtnText = document.getElementById('deploy-btn-text');
|
||||||
|
|
||||||
|
let currentDeploymentId = {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}};
|
||||||
|
|
||||||
|
function updateAppStatusBadge(status) {
|
||||||
|
appStatusEl.className = '';
|
||||||
|
if (status === 'running') {
|
||||||
|
appStatusEl.className = 'badge-success';
|
||||||
|
} else if (status === 'building' || status === 'deploying') {
|
||||||
|
appStatusEl.className = 'badge-warning';
|
||||||
|
} else if (status === 'error') {
|
||||||
|
appStatusEl.className = 'badge-error';
|
||||||
|
} else {
|
||||||
|
appStatusEl.className = 'badge-neutral';
|
||||||
|
}
|
||||||
|
appStatusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeployButton(status) {
|
||||||
|
const isDeploying = (status === 'building' || status === 'deploying');
|
||||||
|
deployBtn.disabled = isDeploying;
|
||||||
|
deployBtnText.textContent = isDeploying ? 'Deploying...' : 'Deploy Now';
|
||||||
|
if (isDeploying) {
|
||||||
|
deployBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
} else {
|
||||||
|
deployBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusBadge(el, status) {
|
||||||
|
if (!el) return;
|
||||||
|
el.className = '';
|
||||||
|
if (status === 'running' || status === 'success') {
|
||||||
|
el.className = 'badge-success text-xs';
|
||||||
|
} else if (status === 'building' || status === 'deploying') {
|
||||||
|
el.className = 'badge-warning text-xs';
|
||||||
|
} else if (status === 'failed' || status === 'error') {
|
||||||
|
el.className = 'badge-error text-xs';
|
||||||
|
} else {
|
||||||
|
el.className = 'badge-neutral text-xs';
|
||||||
|
}
|
||||||
|
el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(el) {
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAppStatus() {
|
||||||
|
fetch('/apps/' + appId + '/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
updateAppStatusBadge(data.status);
|
||||||
|
updateDeployButton(data.status);
|
||||||
|
|
||||||
|
// Check if there's a new deployment
|
||||||
|
if (data.latestDeploymentID && data.latestDeploymentID !== currentDeploymentId) {
|
||||||
|
currentDeploymentId = data.latestDeploymentID;
|
||||||
|
// Show build logs section if hidden
|
||||||
|
if (buildLogsSection) {
|
||||||
|
buildLogsSection.style.display = '';
|
||||||
|
}
|
||||||
|
// Immediately fetch new build logs
|
||||||
|
fetchBuildLogs();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to fetch app status:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchContainerLogs() {
|
||||||
|
fetch('/apps/' + appId + '/container-logs')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
containerLogsEl.textContent = data.logs || 'No logs available';
|
||||||
|
updateStatusBadge(containerStatusEl, data.status);
|
||||||
|
scrollToBottom(containerLogsWrapper);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
containerLogsEl.textContent = 'Failed to fetch logs: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBuildLogs() {
|
||||||
|
if (!currentDeploymentId || !buildLogsEl) return;
|
||||||
|
|
||||||
|
fetch('/apps/' + appId + '/deployments/' + currentDeploymentId + '/logs')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
buildLogsEl.textContent = data.logs || 'No build logs available';
|
||||||
|
updateStatusBadge(buildStatusEl, data.status);
|
||||||
|
scrollToBottom(buildLogsWrapper);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
buildLogsEl.textContent = 'Failed to fetch logs: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchAppStatus();
|
||||||
|
fetchContainerLogs();
|
||||||
|
fetchBuildLogs();
|
||||||
|
|
||||||
|
// Refresh every second
|
||||||
|
setInterval(fetchAppStatus, 1000);
|
||||||
|
setInterval(fetchContainerLogs, 1000);
|
||||||
|
setInterval(fetchBuildLogs, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -8,8 +8,11 @@
|
|||||||
<title>{{block "title" .}}µPaaS{{end}}</title>
|
<title>{{block "title" .}}µPaaS{{end}}</title>
|
||||||
<link rel="stylesheet" href="/s/css/tailwind.css">
|
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
{{block "content" .}}{{end}}
|
<div class="flex-grow">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
{{template "footer" .}}
|
||||||
<script src="/s/js/app.js"></script>
|
<script src="/s/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -18,10 +21,10 @@
|
|||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav class="app-bar">
|
<nav class="app-bar">
|
||||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">
|
<div class="flex items-center gap-3">
|
||||||
µPaaS
|
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">µPaaS</a>
|
||||||
</a>
|
<span class="text-sm text-gray-500">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span>
|
||||||
<span class="text-sm text-gray-500 ml-2">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/apps/new" class="btn-primary">
|
<a href="/apps/new" class="btn-primary">
|
||||||
New App
|
New App
|
||||||
@ -34,6 +37,22 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "footer"}}
|
||||||
|
<footer class="bg-white border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-4">
|
||||||
|
<div class="text-center text-xs text-gray-500">
|
||||||
|
<span class="font-medium">µPaaS</span>
|
||||||
|
<span class="mx-2">|</span>
|
||||||
|
<span>MIT License</span>
|
||||||
|
<span class="mx-2">|</span>
|
||||||
|
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
|
||||||
|
<span class="mx-2">|</span>
|
||||||
|
<a href="https://git.eeqj.de/sneak/upaas" class="text-primary-600 hover:text-primary-800">git.eeqj.de/sneak/upaas</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "alert-error"}}
|
{{define "alert-error"}}
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<div class="alert-error" data-auto-dismiss="8000">
|
<div class="alert-error" data-auto-dismiss="8000">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user