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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user