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:
2025-12-30 15:05:26 +07:00
parent bc275f7b9c
commit b3ac3c60c2
15 changed files with 1111 additions and 141 deletions

View File

@@ -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 {