23 Commits

Author SHA1 Message Date
clawbot
14525523cb chore: update module path to sneak.berlin/go/upaas (fixes #143)
All checks were successful
Check / check (pull_request) Successful in 2m18s
2026-02-26 06:00:17 -08:00
578c6ec842 Merge pull request 'tidy' (#148) from fix/tidy into main
All checks were successful
Check / check (push) Successful in 2m24s
Reviewed-on: #148
2026-02-26 13:55:28 +01:00
1c2bf80d7d tidy
All checks were successful
Check / check (pull_request) Successful in 2m32s
2026-02-26 19:52:09 +07:00
019ba7fe1f Merge pull request 'Fix dashboard CSRFField crash (closes #146)' (#147) from fix/dashboard-csrf-field into main
All checks were successful
Check / check (push) Successful in 2m24s
Reviewed-on: #147
2026-02-26 12:07:42 +01:00
user
c22a2877d5 fix: pass CSRFField to dashboard template (closes #146)
All checks were successful
Check / check (pull_request) Successful in 2m30s
2026-02-26 02:56:27 -08:00
user
43cde0eefd test: add failing test for dashboard CSRFField (refs #146) 2026-02-26 02:56:00 -08:00
b1c6b93d8e Merge pull request 'fix: simplify CI to docker build only (closes #130)' (#131) from fix/ci-docker-build-only into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #131
2026-02-26 11:53:14 +01:00
1875792ebe Merge branch 'main' into fix/ci-docker-build-only
All checks were successful
Check / check (pull_request) Successful in 2m47s
2026-02-26 11:53:03 +01:00
7bbaa1d08a Merge pull request 'Fix 1.0 audit bugs (closes #120, closes #121, closes #122, closes #123, closes #124, closes #125)' (#126) from fix/audit-bugs-120-125 into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #126
2026-02-26 11:52:54 +01:00
user
43a0cbac70 fix: use pre-built golangci-lint binary instead of go install
All checks were successful
Check / check (pull_request) Successful in 13m22s
go install fails in alpine Docker builder because the linker (ld) is not
available. Download the official pre-built binary with SHA256 verification
instead. Supports both amd64 and arm64 architectures.

Fixes #126
2026-02-26 02:17:54 -08:00
clawbot
fb866af4e5 simplify CI to docker build only (refs #130)
Some checks failed
Check / check (pull_request) Failing after 4s
The Dockerfile already runs make check, so the CI action only needs
to run docker build. Remove go setup, linter installation, and
direct make check invocation from the workflow.
2026-02-26 02:11:15 -08:00
user
91d6da0796 fix: move inline comments above FROM lines (fixes docker build)
All checks were successful
Check / check (pull_request) Successful in 11m20s
Docker does not support inline comments on FROM lines. Move the
human-readable image tag comments to their own line above each FROM.

Fixes broken docker build on PR #126 and main.
2026-02-26 02:06:11 -08:00
clawbot
57e0735afa docs: expand Important note — HOST_DATA_DIR must be absolute path
All checks were successful
Check / check (pull_request) Successful in 11m48s
Explain why relative paths break container builds and add usage example.
Addresses sneak's review feedback on PR #126.
2026-02-26 02:01:13 -08:00
2eeead7e64 docs: clarify UPAAS_DATA_DIR default is for local dev only
The ./data default comes from Go code and works for local development.
For Docker deployments, an absolute path should be used.
Updated config table to make this distinction clear.
2026-02-26 02:01:13 -08:00
user
76fe014e9a docs: remove relative path default for HOST_DATA_DIR in docker-compose example
Users must set HOST_DATA_DIR to an explicit absolute path. Removed
the :-./data fallback from both the volume mount and environment
variable in the docker-compose example.
2026-02-26 02:01:13 -08:00
user
f36732eaf5 refactor: remove internal/domain package, move types to correct packages
- ImageID + ContainerID → internal/docker/types.go
- UnparsedURL → internal/service/webhook/types.go
- Delete internal/domain/ entirely
- Update all imports throughout the codebase
2026-02-26 02:01:12 -08:00
user
3a1b1e3cd4 refactor: add String() methods to domain types, replace string() casts 2026-02-26 02:01:12 -08:00
594537e6f5 rework: address review feedback on PR #126
Changes per sneak's review:
- Delete docker-compose.yml, add example stanza to README
- Define custom domain types: ImageID, ContainerID, UnparsedURL
- Use custom types in all function signatures throughout codebase
- Restore imageID parameter (as domain.ImageID) in deploy pipeline
- buildContainerOptions now takes ImageID directly instead of
  constructing image tag from deploymentID
- Fix pre-existing JS formatting (prettier)

make check passes with zero failures.
2026-02-26 02:01:12 -08:00
a6c76232bf fix: assign commit error to err so deferred rollback triggers (closes #125)
When Commit() failed, the error was stored in commitErr instead of err,
so the deferred rollback (which checks err) was skipped.
2026-02-26 02:00:49 -08:00
46574f8cf1 fix: rename GetBuildDir param from appID to appName (closes #123)
The parameter is always called with app.Name, not an ID. Rename to match
actual usage and prevent confusion.
2026-02-26 02:00:49 -08:00
074903619d fix: add 1MB size limit on deployment logs with truncation (closes #122)
Cap AppendLog at 1MB, truncating oldest lines when exceeded. Prevents
unbounded SQLite database growth from long-running builds.
2026-02-26 02:00:49 -08:00
6cf6e89db4 fix: use renderTemplate in all error paths of HandleAppCreate/HandleAppUpdate (closes #121)
Replace direct tmpl.ExecuteTemplate calls with h.renderTemplate to ensure
buffered rendering and prevent partial HTML responses on template errors.
2026-02-26 02:00:49 -08:00
5c20b0b23d fix: use bind mount with HOST_DATA_DIR in docker-compose.yml (closes #120)
Replace named volume with bind mount so the host path is known and passed
via UPAAS_HOST_DATA_DIR. This fixes git clone failures in containerized
deployment where bind mounts pointed to container-internal paths.
2026-02-26 02:00:49 -08:00
57 changed files with 3381 additions and 254 deletions

View File

@@ -10,7 +10,7 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
- name: Build (runs make check via Dockerfile)
- name: Build (runs make check inside Dockerfile)
run: docker build .

View File

@@ -1,10 +1,23 @@
# Build stage
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder # golang:1.25-alpine
# golang:1.25-alpine
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint v2
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
# Install golangci-lint v2 (pre-built binary — go install fails in alpine due to missing linker)
RUN set -e; \
GOLANGCI_VERSION="2.10.1"; \
case "$(uname -m)" in \
x86_64) ARCH="amd64"; SHA256="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99" ;; \
aarch64) ARCH="arm64"; SHA256="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8" ;; \
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; \
esac; \
wget -q -O /tmp/golangci-lint.tar.gz \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}.tar.gz"; \
echo "${SHA256} /tmp/golangci-lint.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/golangci-lint.tar.gz -C /usr/local/bin --strip-components=1 "golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}/golangci-lint"; \
rm /tmp/golangci-lint.tar.gz; \
golangci-lint version
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
WORKDIR /src
@@ -20,7 +33,8 @@ RUN make check
RUN make build
# Runtime stage
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 # alpine:3.19
# alpine:3.19
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli

View File

@@ -157,8 +157,8 @@ Environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `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_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
| `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
@@ -176,8 +176,35 @@ docker run -d \
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.
### Docker Compose
```yaml
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
```
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
container builds because the Docker daemon resolves paths relative to the host, not the container.
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.

View File

@@ -4,20 +4,20 @@ package main
import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/server"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/server"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
_ "github.com/joho/godotenv/autoload"
)

View File

@@ -1,20 +0,0 @@
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- upaas-data:/var/lib/upaas
# environment:
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
volumes:
upaas-data:

4
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/upaas
module sneak.berlin/go/upaas
go 1.25
@@ -19,6 +19,7 @@ require (
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.46.0
golang.org/x/time v0.12.0
)
require (
@@ -74,7 +75,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect

View File

@@ -13,8 +13,8 @@ import (
"github.com/spf13/viper"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
)
// defaultPort is the default HTTP server port.

View File

@@ -14,8 +14,8 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/logger"
)
// dataDirPermissions is the file permission for the data directory.

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
func TestHashWebhookSecret(t *testing.T) {

View File

@@ -113,9 +113,9 @@ func (d *Database) applyMigration(ctx context.Context, filename string) error {
return fmt.Errorf("failed to record migration: %w", err)
}
commitErr := transaction.Commit()
if commitErr != nil {
return fmt.Errorf("failed to commit migration: %w", commitErr)
err = transaction.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration: %w", err)
}
return nil

View File

@@ -5,8 +5,8 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/logger"
)
// NewTestDatabase creates an in-memory Database for testing.

View File

@@ -14,7 +14,7 @@ import (
"strconv"
"strings"
"github.com/docker/docker/api/types"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
@@ -25,8 +25,9 @@ import (
"github.com/docker/go-connections/nat"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/logger"
)
// sshKeyPermissions is the file permission for SSH private keys.
@@ -116,7 +117,7 @@ type BuildImageOptions struct {
func (c *Client) BuildImage(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
) (ImageID, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@@ -188,7 +189,7 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
func (c *Client) CreateContainer(
ctx context.Context,
opts CreateContainerOptions,
) (string, error) {
) (ContainerID, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@@ -241,18 +242,18 @@ func (c *Client) CreateContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
return resp.ID, nil
return ContainerID(resp.ID), nil
}
// StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
@@ -261,7 +262,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID string) error {
}
// StopContainer stops a container.
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error {
if c.docker == nil {
return ErrNotConnected
}
@@ -270,7 +271,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error {
timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
err := c.docker.ContainerStop(ctx, containerID.String(), container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
@@ -281,7 +282,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error {
// RemoveContainer removes a container.
func (c *Client) RemoveContainer(
ctx context.Context,
containerID string,
containerID ContainerID,
force bool,
) error {
if c.docker == nil {
@@ -290,7 +291,7 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
err := c.docker.ContainerRemove(ctx, containerID.String(), container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
@@ -301,7 +302,7 @@ func (c *Client) RemoveContainer(
// ContainerLogs returns the logs for a container.
func (c *Client) ContainerLogs(
ctx context.Context,
containerID string,
containerID ContainerID,
tail string,
) (string, error) {
if c.docker == nil {
@@ -314,7 +315,7 @@ func (c *Client) ContainerLogs(
Tail: tail,
}
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
reader, err := c.docker.ContainerLogs(ctx, containerID.String(), opts)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
@@ -337,13 +338,13 @@ func (c *Client) ContainerLogs(
// IsContainerRunning checks if a container is running.
func (c *Client) IsContainerRunning(
ctx context.Context,
containerID string,
containerID ContainerID,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -354,13 +355,13 @@ func (c *Client) IsContainerRunning(
// IsContainerHealthy checks if a container is healthy.
func (c *Client) IsContainerHealthy(
ctx context.Context,
containerID string,
containerID ContainerID,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -378,7 +379,7 @@ const LabelUpaasID = "upaas.id"
// ContainerInfo contains basic information about a container.
type ContainerInfo struct {
ID string
ID ContainerID
Running bool
}
@@ -413,7 +414,7 @@ func (c *Client) FindContainerByAppID(
ctr := containers[0]
return &ContainerInfo{
ID: ctr.ID,
ID: ContainerID(ctr.ID),
Running: ctr.State == "running",
}, nil
}
@@ -482,8 +483,8 @@ func (c *Client) CloneRepo(
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
_, err := c.docker.ImageRemove(ctx, imageID.String(), image.RemoveOptions{
Force: true,
PruneChildren: true,
})
@@ -497,7 +498,7 @@ func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
func (c *Client) performBuild(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
) (ImageID, error) {
// Create tar archive of build context
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil {
@@ -512,7 +513,7 @@ func (c *Client) performBuild(
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
@@ -542,7 +543,7 @@ func (c *Client) performBuild(
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
}
return inspect.ID, nil
return ImageID(inspect.ID), nil
}
return "", nil
@@ -603,22 +604,22 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
}
}()
containerID, err := c.createGitContainer(ctx, cfg)
gitContainerID, err := c.createGitContainer(ctx, cfg)
if err != nil {
return nil, err
}
defer func() {
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
_ = c.docker.ContainerRemove(ctx, gitContainerID.String(), container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, containerID)
return c.runGitClone(ctx, gitContainerID)
}
func (c *Client) createGitContainer(
ctx context.Context,
cfg *cloneConfig,
) (string, error) {
) (ContainerID, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
// Build the git command using environment variables to avoid shell injection.
@@ -675,16 +676,16 @@ func (c *Client) createGitContainer(
return "", fmt.Errorf("failed to create git container: %w", err)
}
return resp.ID, nil
return ContainerID(resp.ID), nil
}
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
if err != nil {
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.String(), container.WaitConditionNotRunning)
select {
case err := <-errCh:

13
internal/docker/types.go Normal file
View File

@@ -0,0 +1,13 @@
package docker
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// String implements the fmt.Stringer interface.
func (id ImageID) String() string { return string(id) }
// ContainerID is a Docker container identifier.
type ContainerID string
// String implements the fmt.Stringer interface.
func (id ContainerID) String() string { return string(id) }

View File

@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/models"
)
// apiAppResponse is the JSON representation of an app.

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/app"
)
// apiRouter builds a chi router with the API routes using session auth middleware.

View File

@@ -15,9 +15,9 @@ import (
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates"
)
const (
@@ -72,7 +72,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
nameErr := validateAppName(name)
if nameErr != nil {
data["Error"] = "Invalid app name: " + nameErr.Error()
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
h.renderTemplate(writer, tmpl, "app_new.html", data)
return
}
@@ -228,7 +228,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return
}
@@ -239,7 +239,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return
}

View File

@@ -3,7 +3,7 @@ package handlers
import (
"net/http"
"git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/templates"
)
// HandleLoginGET returns the login page handler.

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
// AppStats holds deployment statistics for an app.

View File

@@ -10,16 +10,16 @@ import (
"github.com/gorilla/csrf"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
"git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/templates"
)
// Params contains dependencies for Handlers.

View File

@@ -15,21 +15,21 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
)
type testContext struct {
@@ -404,6 +404,25 @@ func TestHandleDashboard(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications")
})
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code,
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
})
}
func TestHandleAppNew(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/handlers"
)
func TestValidateRepoURL(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/handlers"
)
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests

View File

@@ -3,7 +3,7 @@ package handlers
import (
"net/http"
"git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/templates"
)
const (

View File

@@ -3,7 +3,7 @@ package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/handlers"
)
func TestSanitizeTail(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/models"
)
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).

View File

@@ -8,10 +8,10 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
)
// Params contains dependencies for Healthcheck.

View File

@@ -7,7 +7,7 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/globals"
)
// Params contains dependencies for Logger.

View File

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/config"
)
//nolint:gosec // test credentials

View File

@@ -18,10 +18,10 @@ import (
"go.uber.org/fx"
"golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth"
)
// corsMaxAge is the maximum age for CORS preflight responses in seconds.

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/config"
)
func newTestMiddleware(t *testing.T) *Middleware {

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// appColumns is the standard column list for app queries.

View File

@@ -5,9 +5,10 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// DeploymentStatus represents the status of a deployment.
@@ -76,7 +77,11 @@ func (d *Deployment) Reload(ctx context.Context) error {
return d.scan(row)
}
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
const maxLogSize = 1 << 20
// AppendLog appends a log line to the deployment logs.
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string
@@ -84,7 +89,22 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error {
currentLogs = d.Logs.String
}
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
newLogs := currentLogs + line + "\n"
if len(newLogs) > maxLogSize {
// Keep the most recent logs that fit within the limit.
// Find a newline after the truncation point to avoid partial lines.
truncateAt := len(newLogs) - maxLogSize
idx := strings.Index(newLogs[truncateAt:], "\n")
if idx >= 0 {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
} else {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
}
}
d.Logs = sql.NullString{String: newLogs, Valid: true}
return d.Save(ctx)
}

View File

@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// EnvVar represents an environment variable for an app.

View File

@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// Label represents a Docker label for an app container.

View File

@@ -10,11 +10,11 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
)
// Test constants to satisfy goconst linter.

View File

@@ -6,7 +6,7 @@ import (
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// PortProtocol represents the protocol for a port mapping.

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// User represents a user in the system.

View File

@@ -6,7 +6,7 @@ import (
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// Volume represents a volume mount for an app container.

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/database"
)
// WebhookEvent represents a received webhook event.

View File

@@ -8,7 +8,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.eeqj.de/sneak/upaas/static"
"sneak.berlin/go/upaas/static"
)
// requestTimeout is the maximum duration for handling a request.

View File

@@ -12,11 +12,11 @@ import (
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware"
)
// Params contains dependencies for Server.

View File

@@ -14,10 +14,10 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/ssh"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/ssh"
)
// ServiceParams contains dependencies for Service.

View File

@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
func setupTestService(t *testing.T) (*app.Service, func()) {

View File

@@ -15,10 +15,10 @@ import (
"go.uber.org/fx"
"golang.org/x/crypto/argon2"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
)
const (

View File

@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth"
)
func setupTestService(t *testing.T) (*auth.Service, func()) {

View File

@@ -17,12 +17,12 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/notify"
)
// Time constants.
@@ -251,8 +251,8 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
}
// GetBuildDir returns the build directory path for an app.
func (svc *Service) GetBuildDir(appID string) string {
return filepath.Join(svc.config.DataDir, "builds", appID)
func (svc *Service) GetBuildDir(appName string) string {
return filepath.Join(svc.config.DataDir, "builds", appName)
}
// GetLogFilePath returns the path to the log file for a deployment.
@@ -417,7 +417,7 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, previousImageID)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID))
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
@@ -431,8 +431,8 @@ func (svc *Service) executeRollback(
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID.String())
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -514,7 +514,7 @@ func (svc *Service) buildImageWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, error) {
) (docker.ImageID, error) {
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
defer cancel()
@@ -539,7 +539,7 @@ func (svc *Service) deployContainerWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID docker.ImageID,
) error {
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
defer cancel()
@@ -667,7 +667,7 @@ func (svc *Service) checkCancelled(
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID docker.ImageID,
) error {
if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil
@@ -687,7 +687,7 @@ func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID docker.ImageID,
) {
// Clean up the intermediate Docker image if one was built
if imageID != "" {
@@ -695,11 +695,11 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID+": "+removeErr.Error())
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID.String()+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID.String())
}
}
@@ -816,7 +816,7 @@ func (svc *Service) buildImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, error) {
) (docker.ImageID, error) {
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
@@ -850,8 +850,8 @@ func (svc *Service) buildImage(
return "", fmt.Errorf("failed to build image: %w", err)
}
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
deployment.ImageID = sql.NullString{String: imageID.String(), Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID.String())
return imageID, nil
}
@@ -1009,15 +1009,15 @@ func (svc *Service) removeOldContainer(
svc.log.Warn("failed to remove old container", "error", removeErr)
}
_ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12]))
}
func (svc *Service) createAndStartContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
) (string, error) {
imageID docker.ImageID,
) (docker.ContainerID, error) {
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
if err != nil {
svc.failDeployment(ctx, app, deployment, err)
@@ -1038,8 +1038,8 @@ func (svc *Service) createAndStartContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID.String())
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -1062,7 +1062,7 @@ func (svc *Service) createAndStartContainer(
func (svc *Service) buildContainerOptions(
ctx context.Context,
app *models.App,
imageID string,
imageID docker.ImageID,
) (docker.CreateContainerOptions, error) {
envVars, err := app.GetEnvVars(ctx)
if err != nil {
@@ -1096,7 +1096,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: imageID,
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
@@ -1146,9 +1146,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
func (svc *Service) updateAppRunning(
ctx context.Context,
app *models.App,
imageID string,
imageID docker.ImageID,
) error {
app.ImageID = sql.NullString{String: imageID, Valid: true}
app.ImageID = sql.NullString{String: imageID.String(), Valid: true}
app.Status = models.AppStatusRunning
saveErr := app.Save(ctx)

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/deploy"
)
func TestCancelActiveDeploy_NoExisting(t *testing.T) {

View File

@@ -10,8 +10,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/service/deploy"
)
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {

View File

@@ -6,9 +6,10 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy"
)
func TestBuildContainerOptionsUsesImageID(t *testing.T) {
@@ -27,14 +28,14 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
const expectedImageID = "sha256:abc123def456"
const expectedImageID = docker.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.Image != expectedImageID {
if opts.Image != expectedImageID.String() {
t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image)
}

View File

@@ -8,9 +8,9 @@ import (
"path/filepath"
"strings"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
)
// NewTestService creates a Service with minimal dependencies for testing.
@@ -86,7 +86,7 @@ func (svc *Service) GetBuildDirExported(appName string) string {
func (svc *Service) BuildContainerOptionsExported(
ctx context.Context,
app *models.App,
imageID string,
imageID docker.ImageID,
) (docker.CreateContainerOptions, error) {
return svc.buildContainerOptions(ctx, app, imageID)
}

View File

@@ -15,8 +15,8 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
)
// HTTP client timeout.

View File

@@ -0,0 +1,10 @@
package webhook
// UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
// compare URLs from external payloads).
type UnparsedURL string
// String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) }

View File

@@ -10,10 +10,11 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy"
)
// ServiceParams contains dependencies for Service.
@@ -50,12 +51,12 @@ type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
@@ -63,7 +64,7 @@ type GiteaPushPayload struct {
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL string `json:"url"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
@@ -104,7 +105,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -168,7 +169,7 @@ func extractBranch(ref string) string {
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) string {
func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
@@ -178,7 +179,7 @@ func extractCommitURL(payload GiteaPushPayload) string {
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return payload.Repository.HTMLURL + "/commit/" + payload.After
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""

View File

@@ -12,15 +12,15 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
)
type testDeps struct {

View File

@@ -4,9 +4,9 @@ import (
"strings"
"testing"
"git.eeqj.de/sneak/upaas/internal/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/ssh"
)
func TestGenerateKeyPair(t *testing.T) {

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -69,7 +69,7 @@
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
{{ .CSRFField }}
{{ $.CSRFField }}
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form>
</div>