diff --git a/.golangci.yml b/.golangci.yml index d425c0d..34a8e31 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,7 @@ linters: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - depguard # Dependency allow/block lists + - godot # Requires comments to end with periods - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go diff --git a/README.md b/README.md index 465e59e..2583fee 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Environment variables: |----------|-------------|---------| | `PORT` | HTTP listen port | 8080 | | `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 | | `DEBUG` | Enable debug logging | false | | `SENTRY_DSN` | Sentry error reporting DSN | "" | @@ -170,10 +171,14 @@ Environment variables: docker run -d \ -p 8080:8080 \ -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 ``` +**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`. ## License diff --git a/internal/config/config.go b/internal/config/config.go index f442fdd..6d8f6f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Port int Debug bool DataDir string + HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container) DockerHost string SentryDSN string MaintenanceMode bool @@ -116,11 +117,19 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) { // Config file not found is OK } + dataDir := viper.GetString("DATA_DIR") + hostDataDir := viper.GetString("HOST_DATA_DIR") + + if hostDataDir == "" { + hostDataDir = dataDir + } + // Build config struct cfg := &Config{ Port: viper.GetInt("PORT"), Debug: viper.GetBool("DEBUG"), - DataDir: viper.GetString("DATA_DIR"), + DataDir: dataDir, + HostDataDir: hostDataDir, DockerHost: viper.GetString("DOCKER_HOST"), SentryDSN: viper.GetString("SENTRY_DSN"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), diff --git a/internal/docker/client.go b/internal/docker/client.go index ed7fa45..711962e 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -28,9 +28,16 @@ import ( // sshKeyPermissions is the file permission for SSH private keys. const sshKeyPermissions = 0o600 +// workDirPermissions is the file permission for the work directory. +const workDirPermissions = 0o750 + // stopTimeoutSeconds is the timeout for stopping containers. 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. var ErrNotConnected = errors.New("docker client not connected") @@ -86,6 +93,7 @@ type BuildImageOptions struct { ContextDir string DockerfilePath string Tags []string + LogWriter io.Writer // Optional writer for build output } // BuildImage builds a Docker image from a context directory. @@ -398,28 +406,49 @@ func (c *Client) FindContainerByAppID( type cloneConfig struct { repoURL string branch string + commitSHA string // Optional: specific commit to checkout sshPrivateKey string - destDir string - keyFile string + containerDir string // Path inside the upaas container (for file operations) + 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( ctx context.Context, - repoURL, branch, sshPrivateKey, destDir string, -) error { + repoURL, branch, commitSHA, sshPrivateKey, containerDir, hostDir string, +) (*CloneResult, error) { 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{ repoURL: repoURL, branch: branch, + commitSHA: commitSHA, sshPrivateKey: sshPrivateKey, - destDir: destDir, - keyFile: filepath.Join(destDir, ".deploy_key"), + containerDir: filepath.Join(containerDir, "work"), + hostDir: filepath.Join(hostDir, "work"), + keyFile: filepath.Join(containerDir, "deploy_key"), + hostKeyFile: filepath.Join(hostDir, "deploy_key"), } return c.performClone(ctx, cfg) @@ -460,8 +489,13 @@ func (c *Client) performBuild( } }() - // Read build output (logs to stdout for now) - _, err = io.Copy(os.Stdout, resp.Body) + // Read build output - write to stdout and optional log writer + 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 { return "", fmt.Errorf("failed to read build output: %w", err) } @@ -479,11 +513,17 @@ func (c *Client) performBuild( return "", nil } -func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error { - // Write SSH key to temp file - err := os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions) +func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) { + // Create work directory for clone destination + err := os.MkdirAll(cfg.containerDir, workDirPermissions) 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() { @@ -495,7 +535,7 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error { containerID, err := c.createGitContainer(ctx, cfg) if err != nil { - return err + return nil, err } defer func() { @@ -511,21 +551,37 @@ func (c *Client) createGitContainer( ) (string, error) { 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, &container.Config{ - Image: "alpine/git:latest", - Cmd: []string{ - "clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo", - }, + Image: gitImage, + Entrypoint: []string{}, // Clear entrypoint when using sh -c + Cmd: cmd, Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd}, WorkingDir: "/", }, &container.HostConfig{ Mounts: []mount.Mount{ - {Type: mount.TypeBind, Source: cfg.destDir, Target: "/repo"}, + {Type: mount.TypeBind, Source: cfg.hostDir, Target: "/repo"}, { Type: mount.TypeBind, - Source: cfg.keyFile, + Source: cfg.hostKeyFile, Target: "/keys/deploy_key", ReadOnly: true, }, @@ -542,31 +598,32 @@ func (c *Client) createGitContainer( 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{}) 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) select { 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: - if status.StatusCode != 0 { - logs, _ := c.ContainerLogs(ctx, containerID, "100") + // Always capture logs for the result + logs, _ := c.ContainerLogs(ctx, containerID, "100") - return fmt.Errorf( + if status.StatusCode != 0 { + return nil, fmt.Errorf( "%w with status %d: %s", ErrGitCloneFailed, status.StatusCode, logs, ) } - } - return nil + return &CloneResult{Output: logs}, nil + } } func (c *Client) connect(ctx context.Context) error { diff --git a/internal/handlers/app.go b/internal/handlers/app.go index ad2b919..9ca117f 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -3,6 +3,7 @@ package handlers import ( "context" "database/sql" + "encoding/json" "net/http" "strconv" "strings" @@ -27,7 +28,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc { tmpl := templates.GetParsed() 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) if err != nil { @@ -127,22 +128,28 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { recentDeploymentsLimit, ) - host := request.Host - webhookURL := "https://" + host + "/webhook/" + application.WebhookSecret - deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, host) - - 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"), + // Get latest deployment for build logs pane + var latestDeployment *models.Deployment + if len(deployments) > 0 { + latestDeployment = deployments[0] } + 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) if err != nil { h.log.Error("template execution failed", "error", err) @@ -172,9 +179,9 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc { return } - data := map[string]any{ + data := h.addGlobals(map[string]any{ "App": application, - } + }) err := tmpl.ExecuteTemplate(writer, "app_edit.html", data) if err != nil { @@ -325,10 +332,10 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc { deploymentsHistoryLimit, ) - data := map[string]any{ + data := h.addGlobals(map[string]any{ "App": application, "Deployments": deployments, - } + }) err := tmpl.ExecuteTemplate(writer, "deployments.html", data) 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. type containerAction string @@ -778,8 +927,8 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc { } // formatDeployKey formats an SSH public key with a descriptive comment. -// Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com. -func formatDeployKey(pubKey string, createdAt time.Time, host string) string { +// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp +func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { const minKeyParts = 2 parts := strings.Fields(pubKey) @@ -787,7 +936,7 @@ func formatDeployKey(pubKey string, createdAt time.Time, host string) string { return pubKey } - comment := "upaas-" + createdAt.Format("2006-01-02") + "-" + host + comment := "upaas_" + createdAt.Format("2006-01-02") + "_" + appName return parts[0] + " " + parts[1] + " " + comment } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index faa4520..e3dd5c0 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -11,7 +11,7 @@ func (h *Handlers) HandleLoginGET() http.HandlerFunc { tmpl := templates.GetParsed() return func(writer http.ResponseWriter, _ *http.Request) { - data := map[string]any{} + data := h.addGlobals(map[string]any{}) err := tmpl.ExecuteTemplate(writer, "login.html", data) if err != nil { @@ -36,9 +36,9 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc { username := request.FormValue("username") password := request.FormValue("password") - data := map[string]any{ + data := h.addGlobals(map[string]any{ "Username": username, - } + }) if username == "" || password == "" { data["Error"] = "Username and password are required" diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index cce9777..e2204c0 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -20,9 +20,9 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc { return } - data := map[string]any{ + data := h.addGlobals(map[string]any{ "Apps": apps, - } + }) execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data) if execErr != nil { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f3c1989..b23fb44 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -45,6 +45,7 @@ type Handlers struct { deploy *deploy.Service webhook *webhook.Service docker *docker.Client + globals *globals.Globals } // New creates a new Handlers instance. @@ -59,9 +60,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) { deploy: params.Deploy, webhook: params.Webhook, docker: params.Docker, + globals: params.Globals, }, 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( writer http.ResponseWriter, _ *http.Request, diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 0202585..0286799 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -16,7 +16,7 @@ func (h *Handlers) HandleSetupGET() http.HandlerFunc { tmpl := templates.GetParsed() return func(writer http.ResponseWriter, _ *http.Request) { - data := map[string]any{} + data := h.addGlobals(map[string]any{}) err := tmpl.ExecuteTemplate(writer, "setup.html", data) if err != nil { @@ -51,16 +51,16 @@ func validateSetupForm(formData setupFormData) string { } // renderSetupError renders the setup page with an error message. -func renderSetupError( +func (h *Handlers) renderSetupError( tmpl *templates.TemplateExecutor, writer http.ResponseWriter, username string, errorMsg string, ) { - data := map[string]any{ + data := h.addGlobals(map[string]any{ "Username": username, "Error": errorMsg, - } + }) _ = tmpl.ExecuteTemplate(writer, "setup.html", data) } @@ -83,7 +83,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { } if validationErr := validateSetupForm(formData); validationErr != "" { - renderSetupError(tmpl, writer, formData.username, validationErr) + h.renderSetupError(tmpl, writer, formData.username, validationErr) return } @@ -95,7 +95,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { ) if createErr != nil { 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 } @@ -103,7 +103,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { sessionErr := h.auth.CreateSession(writer, request, user) if sessionErr != nil { h.log.Error("failed to create session", "error", sessionErr) - renderSetupError( + h.renderSetupError( tmpl, writer, formData.username, diff --git a/internal/models/deployment.go b/internal/models/deployment.go index 13fbdc5..855563c 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -21,6 +21,14 @@ const ( 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. type Deployment struct { db *database.Database @@ -90,6 +98,61 @@ func (d *Deployment) MarkFinished( 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 { query := ` INSERT INTO deployments ( diff --git a/internal/server/routes.go b/internal/server/routes.go index 1e63563..b8fb039 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -65,7 +65,10 @@ func (s *Server) SetupRoutes() { r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) 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}/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}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 1acf02d..1e41c8a 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -2,13 +2,16 @@ package deploy import ( + "bytes" "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" + "sync" "time" "go.uber.org/fx" @@ -28,14 +31,151 @@ const ( upaasLabelCount = 1 // buildsDirPermissions is the permission mode for the builds directory. 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. var ( // ErrContainerUnhealthy indicates the container failed health check. 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. type ServiceParams struct { fx.In @@ -49,24 +189,35 @@ type ServiceParams struct { // Service provides deployment functionality. type Service struct { - log *slog.Logger - db *database.Database - docker *docker.Client - notify *notify.Service - config *config.Config - params *ServiceParams + log *slog.Logger + db *database.Database + docker *docker.Client + notify *notify.Service + config *config.Config + params *ServiceParams + appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks } // New creates a new deploy Service. -func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { - return &Service{ +func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) { + svc := &Service{ log: params.Logger.Get(), db: params.Database, docker: params.Docker, notify: params.Notify, config: params.Config, 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. @@ -80,11 +231,24 @@ func (svc *Service) Deploy( app *models.App, webhookEventID *int64, ) 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 { return err } + svc.logWebhookPayload(ctx, deployment, webhookEvent) + err = svc.updateAppStatusBuilding(ctx, app) if err != nil { return err @@ -92,21 +256,16 @@ func (svc *Service) Deploy( 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 { return err } svc.notify.NotifyBuildSuccess(ctx, app, deployment) - err = svc.updateDeploymentDeploying(ctx, deployment) - if err != nil { - return err - } - - svc.removeOldContainer(ctx, app, deployment) - - _, err = svc.createAndStartContainer(ctx, app, deployment, imageID) + // Deploy phase with timeout + err = svc.deployContainerWithTimeout(ctx, app, deployment, imageID) if err != nil { return err } @@ -123,10 +282,162 @@ func (svc *Service) Deploy( 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( ctx context.Context, app *models.App, webhookEventID *int64, + webhookEvent *models.WebhookEvent, ) (*models.Deployment, error) { deployment := models.NewDeployment(svc.db) 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 saveErr := deployment.Save(ctx) @@ -167,7 +483,7 @@ func (svc *Service) buildImage( app *models.App, deployment *models.Deployment, ) (string, error) { - tempDir, cleanup, err := svc.cloneRepository(ctx, app, deployment) + workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment) if err != nil { return "", err } @@ -176,10 +492,17 @@ func (svc *Service) buildImage( 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{ - ContextDir: tempDir, + ContextDir: workDir, DockerfilePath: app.DockerfilePath, Tags: []string{imageTag}, + LogWriter: logWriter, }) if err != nil { svc.notify.NotifyBuildFailed(ctx, app, deployment, err) @@ -206,7 +529,9 @@ func (svc *Service) cloneRepository( ) (string, func(), error) { // 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). - // Structure: builds//- + // Structure: builds//-/ + // deploy_key <- SSH key for cloning + // work/ <- cloned repository appBuildsDir := filepath.Join(svc.config.DataDir, "builds", app.Name) err := os.MkdirAll(appBuildsDir, buildsDirPermissions) @@ -216,16 +541,37 @@ func (svc *Service) cloneRepository( 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 { 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) } - 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 { cleanup() 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) } - _ = 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( @@ -252,24 +627,68 @@ func (svc *Service) updateDeploymentDeploying( 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, app *models.App, deployment *models.Deployment, -) { +) string { containerInfo, err := svc.docker.FindContainerByAppID(ctx, app.ID) 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 } - 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 { 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( diff --git a/internal/service/notify/notify.go b/internal/service/notify/notify.go index 8488b8c..83e24ff 100644 --- a/internal/service/notify/notify.go +++ b/internal/service/notify/notify.go @@ -27,6 +27,12 @@ const ( httpStatusClientError = 400 ) +// Display constants. +const ( + shortCommitLength = 12 + secondsPerMinute = 60 +) + // Sentinel errors for notification failures. var ( // ErrNtfyFailed indicates the ntfy notification request failed. @@ -64,10 +70,16 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { func (svc *Service) NotifyBuildStart( ctx context.Context, app *models.App, - _ *models.Deployment, + deployment *models.Deployment, ) { title := "Build started: " + app.Name 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") } @@ -75,10 +87,12 @@ func (svc *Service) NotifyBuildStart( func (svc *Service) NotifyBuildSuccess( ctx context.Context, app *models.App, - _ *models.Deployment, + deployment *models.Deployment, ) { + duration := time.Since(deployment.StartedAt) 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") } @@ -86,11 +100,13 @@ func (svc *Service) NotifyBuildSuccess( func (svc *Service) NotifyBuildFailed( ctx context.Context, app *models.App, - _ *models.Deployment, + deployment *models.Deployment, buildErr error, ) { + duration := time.Since(deployment.StartedAt) 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") } @@ -98,10 +114,17 @@ func (svc *Service) NotifyBuildFailed( func (svc *Service) NotifyDeploySuccess( ctx context.Context, app *models.App, - _ *models.Deployment, + deployment *models.Deployment, ) { + duration := time.Since(deployment.StartedAt) 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") } @@ -109,14 +132,37 @@ func (svc *Service) NotifyDeploySuccess( func (svc *Service) NotifyDeployFailed( ctx context.Context, app *models.App, - _ *models.Deployment, + deployment *models.Deployment, deployErr error, ) { + duration := time.Since(deployment.StartedAt) 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") } +// 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( ctx context.Context, app *models.App, @@ -153,7 +199,7 @@ func (svc *Service) sendNotifications( // even if the parent context is cancelled. notifyCtx := context.WithoutCancel(ctx) - slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message) + slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message, priority) if slackErr != nil { svc.log.Error( "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. type SlackPayload struct { Text string `json:"text"` @@ -228,7 +287,7 @@ type SlackAttachment struct { func (svc *Service) sendSlack( ctx context.Context, - webhookURL, title, message string, + webhookURL, title, message, priority string, ) error { svc.log.Debug( "sending slack notification", @@ -239,7 +298,7 @@ func (svc *Service) sendSlack( payload := SlackPayload{ Attachments: []SlackAttachment{ { - Color: "#36a64f", + Color: svc.slackColor(priority), Title: title, Text: message, }, diff --git a/templates/app_detail.html b/templates/app_detail.html index 8fffe4e..124a0dc 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -23,24 +23,16 @@

{{.App.Name}}

- {{if eq .App.Status "running"}} - Running - {{else if eq .App.Status "building"}} - Building - {{else if eq .App.Status "error"}} - Error - {{else if eq .App.Status "stopped"}} - Stopped - {{else}} - {{.App.Status}} - {{end}} + {{.App.Status}}
-

{{.App.RepoURL}} @ {{.App.Branch}}

+

{{.App.RepoURL}}@{{.App.Branch}}

Edit -
- + +
@@ -252,19 +244,35 @@ {{end}}
- + +
- + + +
+
+ +
-
+ +
+
+

Container Logs

+ Loading... +
+
+
Loading container logs...
+
+
+
@@ -276,7 +284,8 @@ - + + @@ -284,7 +293,10 @@ {{range .Deployments}} - + + - + {{end}} @@ -311,6 +321,17 @@ {{end}} + +
+
+

Last Deployment Build Logs

+ {{if .LatestDeployment}}{{.LatestDeployment.Status}}{{end}} +
+
+
{{if .LatestDeployment}}Loading build logs...{{else}}No deployments yet{{end}}
+
+
+

Danger Zone

@@ -320,4 +341,159 @@
+ + {{end}} diff --git a/templates/base.html b/templates/base.html index cf600f0..f08d00d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,8 +8,11 @@ {{block "title" .}}µPaaS{{end}} - - {{block "content" .}}{{end}} + +
+ {{block "content" .}}{{end}} +
+ {{template "footer" .}} @@ -18,10 +21,10 @@ {{define "nav"}} {{end}} +{{define "footer"}} + +{{end}} + {{define "alert-error"}} {{if .Error}}
StartedFinishedDuration Status Commit
{{.StartedAt.Format "2006-01-02 15:04:05"}} + {{.FinishedAtFormatted}} + {{if .Duration}}{{.Duration}}{{else}}-{{end}} {{if eq .Status "success"}} Success @@ -298,9 +310,7 @@ {{.Status}} {{end}} - {{if .CommitSHA.Valid}}{{slice .CommitSHA.String 0 12}}{{else}}-{{end}} - {{.ShortCommit}}