Compare commits
18 Commits
feature/de
...
schema-con
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6542badf | |||
| 38a744b489 | |||
| 11314629b6 | |||
| bc3ee2bfc5 | |||
|
|
e09cf11c06 | ||
|
|
8d68a31366 | ||
| b83e68fafd | |||
| f743837d49 | |||
|
|
9ac1d25788 | ||
| 0c8dcc2eb1 | |||
| d0375555af | |||
| e9d284698a | |||
| 96a91b09ca | |||
| 046cccf31f | |||
|
|
0536f57ec2 | ||
|
|
2be6a748b7 | ||
| e31666ab5c | |||
| 6696db957d |
180
BUGS.md
Normal file
180
BUGS.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Bugs in µPaaS
|
||||
|
||||
## 1. Potential Race Condition in Log Writing
|
||||
|
||||
### Description
|
||||
In the deployment service, when a deployment fails, the `failDeployment` function calls `writeLogsToFile` which may be called concurrently with the async log writer's flush operations. This could lead to partial or corrupted log files.
|
||||
|
||||
### Location
|
||||
`internal/service/deploy/deploy.go:1169` in `failDeployment` function
|
||||
|
||||
### Proposed Fix
|
||||
1. Add synchronization to ensure only one log write operation occurs at a time
|
||||
2. Modify the `deploymentLogWriter` to track completion status and prevent concurrent writes
|
||||
3. Add a wait mechanism in `failDeployment` to ensure any ongoing flush operations complete before writing logs to file
|
||||
|
||||
```go
|
||||
// Add a mutex to deploymentLogWriter
|
||||
type deploymentLogWriter struct {
|
||||
// existing fields...
|
||||
mu sync.Mutex
|
||||
writeMu sync.Mutex // Add this for file writing synchronization
|
||||
done chan struct{}
|
||||
flushed sync.WaitGroup
|
||||
}
|
||||
|
||||
// In writeLogsToFile, ensure exclusive access
|
||||
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
|
||||
svc.writeMu.Lock() // Add this mutex to Service struct
|
||||
defer svc.writeMu.Unlock()
|
||||
// existing code...
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Incomplete Error Handling in Container Operations
|
||||
|
||||
### Description
|
||||
In the Docker client's `performClone` function, if `createGitContainer` fails, the SSH key file created earlier is not cleaned up, causing a potential security risk.
|
||||
|
||||
### Location
|
||||
`internal/docker/client.go:597` in `performClone` function
|
||||
|
||||
### Proposed Fix
|
||||
Add proper cleanup using `defer` immediately after creating the SSH key file:
|
||||
|
||||
```go
|
||||
// After writing SSH key file (line 578)
|
||||
keyFileCreated := false
|
||||
err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write SSH key: %w", err)
|
||||
}
|
||||
keyFileCreated = true
|
||||
|
||||
defer func() {
|
||||
if keyFileCreated {
|
||||
removeErr := os.Remove(cfg.keyFile)
|
||||
if removeErr != nil {
|
||||
c.log.Error("failed to remove SSH key file", "error", removeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## 3. Missing Context Cancellation Check During Build
|
||||
|
||||
### Description
|
||||
In the deployment service's `streamBuildOutput` function, long-running Docker build operations may not properly respond to context cancellation, causing deployments to hang even when cancelled.
|
||||
|
||||
### Location
|
||||
`internal/docker/client.go:542` in `streamBuildOutput` function
|
||||
|
||||
### Proposed Fix
|
||||
Add context checking in the scanner loop:
|
||||
|
||||
```go
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Bytes()
|
||||
// existing code...
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Inconsistent Container Removal in Error Cases
|
||||
|
||||
### Description
|
||||
When deployment fails during container creation, the already-created container is not removed, leading to orphaned containers that consume resources.
|
||||
|
||||
### Location
|
||||
`internal/service/deploy/deploy.go:969` in `createAndStartContainer` function
|
||||
|
||||
### Proposed Fix
|
||||
Add cleanup of created container on start failure:
|
||||
|
||||
```go
|
||||
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
|
||||
if err != nil {
|
||||
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create container: %w", err))
|
||||
return "", fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
|
||||
// Add cleanup defer for error cases
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// If we have a container ID but returning an error, clean it up
|
||||
_ = svc.docker.RemoveContainer(context.Background(), containerID, true)
|
||||
}
|
||||
}()
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to start container: %w", startErr))
|
||||
err = startErr // Set err so defer cleanup runs
|
||||
return "", fmt.Errorf("failed to start container: %w", startErr)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Potential Data Race in Active Deployments Tracking
|
||||
|
||||
### Description
|
||||
The `activeDeploys` sync.Map in the deployment service may have race conditions when multiple concurrent deployments try to access the same app's deployment state.
|
||||
|
||||
### Location
|
||||
`internal/service/deploy/deploy.go:226` and related functions
|
||||
|
||||
### Proposed Fix
|
||||
Add proper locking around active deploy operations:
|
||||
|
||||
```go
|
||||
// Add a mutex for active deploy operations
|
||||
type Service struct {
|
||||
// existing fields...
|
||||
activeDeployMu sync.Mutex
|
||||
}
|
||||
|
||||
// In Deploy function
|
||||
func (svc *Service) Deploy(ctx context.Context, app *models.App, webhookEventID *int64, cancelExisting bool) error {
|
||||
svc.activeDeployMu.Lock()
|
||||
if cancelExisting {
|
||||
svc.cancelActiveDeploy(app.ID)
|
||||
}
|
||||
|
||||
// Try to acquire per-app deployment lock
|
||||
if !svc.tryLockApp(app.ID) {
|
||||
svc.activeDeployMu.Unlock()
|
||||
svc.log.Warn("deployment already in progress", "app", app.Name)
|
||||
return ErrDeploymentInProgress
|
||||
}
|
||||
svc.activeDeployMu.Unlock()
|
||||
|
||||
defer svc.unlockApp(app.ID)
|
||||
// rest of function...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Incomplete Error Propagation in Git Clone
|
||||
|
||||
### Description
|
||||
In the Docker client's `runGitClone` function, if `ContainerLogs` fails, the error is silently ignored, which could hide important debugging information.
|
||||
|
||||
### Location
|
||||
`internal/docker/client.go:679` in `runGitClone` function
|
||||
|
||||
### Proposed Fix
|
||||
Handle the ContainerLogs error properly:
|
||||
|
||||
```go
|
||||
// Always capture logs for the result
|
||||
logs, logErr := c.ContainerLogs(ctx, containerID, "100")
|
||||
if logErr != nil {
|
||||
c.log.Warn("failed to get git clone logs", "error", logErr)
|
||||
logs = "Failed to retrieve logs: " + logErr.Error()
|
||||
}
|
||||
```
|
||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Repository Rules
|
||||
|
||||
Last Updated 2026-01-08
|
||||
|
||||
These rules MUST be followed at all times, it is very important.
|
||||
|
||||
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
||||
commit should contain one change. After each change, make a commit with a
|
||||
good one-line summary.
|
||||
|
||||
* NEVER modify the linter config without asking first.
|
||||
|
||||
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||
without asking first. In almost all cases, the code should be changed,
|
||||
NOT the tests. If you think the test needs to be changed, make your case
|
||||
for that and ask for permission to proceed, then stop. You need explicit
|
||||
user approval to modify existing tests. (You do not need user approval
|
||||
for writing NEW tests.)
|
||||
|
||||
* When linting, assume the linter config is CORRECT, and that each item
|
||||
output by the linter is something that legitimately needs fixing in the
|
||||
code.
|
||||
|
||||
* When running tests, use `make test`.
|
||||
|
||||
* Before commits, run `make check`. This runs `make lint` and `make test`
|
||||
and `make check-fmt`. Any issues discovered MUST be resolved before
|
||||
committing unless explicitly told otherwise.
|
||||
|
||||
* When fixing a bug, write a failing test for the bug FIRST. Add
|
||||
appropriate logging to the test to ensure it is written correctly. Commit
|
||||
that. Then go about fixing the bug until the test passes (without
|
||||
modifying the test further). Then commit that.
|
||||
|
||||
* When adding a new feature, do the same - implement a test first (TDD). It
|
||||
doesn't have to be super complex. Commit the test, then commit the
|
||||
feature.
|
||||
|
||||
* When adding a new feature, use a feature branch. When the feature is
|
||||
completely finished and the code is up to standards (passes `make check`)
|
||||
then and only then can the feature branch be merged into `main` and the
|
||||
branch deleted.
|
||||
|
||||
* Write godoc documentation comments for all exported types and functions as
|
||||
you go along.
|
||||
|
||||
* ALWAYS be consistent in naming. If you name something one thing in one
|
||||
place, name it the EXACT SAME THING in another place.
|
||||
|
||||
* Be descriptive and specific in naming. `wl` is bad;
|
||||
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
||||
`MaxConnectionsPerHost` is good.
|
||||
|
||||
* This is not prototype or teaching code - this is designed for production.
|
||||
Any security issues (such as denial of service) or other web
|
||||
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
||||
|
||||
* As this is production code, no stubbing of implementations unless
|
||||
specifically instructed. We need working implementations.
|
||||
|
||||
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
||||
the vendor directory, NEVER commit compiled binaries. If these
|
||||
directories or files exist, add them to .gitignore (and commit the
|
||||
.gitignore) if they are not already in there. Keep the entire git
|
||||
repository (with history) small - under 20MiB, unless you specifically
|
||||
must commit larger files (e.g. test fixture example media files). Only
|
||||
OUR source code and immediately supporting files (such as test examples)
|
||||
goes into the repo/history.
|
||||
312
TODO.md
312
TODO.md
@@ -1,312 +0,0 @@
|
||||
# UPAAS Implementation Plan
|
||||
|
||||
## Feature Roadmap
|
||||
|
||||
### Core Infrastructure
|
||||
- [x] Uber fx dependency injection
|
||||
- [x] Chi router integration
|
||||
- [x] Structured logging (slog) with TTY detection
|
||||
- [x] Configuration via Viper (env vars, config files)
|
||||
- [x] SQLite database with embedded migrations
|
||||
- [x] Embedded templates (html/template)
|
||||
- [x] Embedded static assets (Tailwind CSS, JS)
|
||||
- [x] Server startup (`Server.Run()`)
|
||||
- [x] Graceful shutdown (`Server.Shutdown()`)
|
||||
- [x] Route wiring (`SetupRoutes()`)
|
||||
|
||||
### Authentication & Authorization
|
||||
- [x] Single admin user model
|
||||
- [x] Argon2id password hashing
|
||||
- [x] Initial setup flow (create admin on first run)
|
||||
- [x] Cookie-based session management (gorilla/sessions)
|
||||
- [x] Session middleware for protected routes
|
||||
- [x] Login/logout handlers
|
||||
- [ ] API token authentication (for JSON API)
|
||||
|
||||
### App Management
|
||||
- [x] Create apps with name, repo URL, branch, Dockerfile path
|
||||
- [x] Edit app configuration
|
||||
- [x] Delete apps (cascades to related entities)
|
||||
- [x] List all apps on dashboard
|
||||
- [x] View app details
|
||||
- [x] Per-app SSH keypair generation (Ed25519)
|
||||
- [x] Per-app webhook secret (UUID)
|
||||
|
||||
### Container Configuration
|
||||
- [x] Environment variables (add, delete per app)
|
||||
- [x] Docker labels (add, delete per app)
|
||||
- [x] Volume mounts (add, delete per app, with read-only option)
|
||||
- [x] Docker network configuration per app
|
||||
- [ ] Edit existing environment variables
|
||||
- [ ] Edit existing labels
|
||||
- [ ] Edit existing volume mounts
|
||||
- [ ] CPU/memory resource limits
|
||||
|
||||
### Deployment Pipeline
|
||||
- [x] Manual deploy trigger from UI
|
||||
- [x] Repository cloning via Docker git container
|
||||
- [x] SSH key authentication for private repos
|
||||
- [x] Docker image building with configurable Dockerfile
|
||||
- [x] Container creation with env vars, labels, volumes
|
||||
- [x] Old container removal before new deployment
|
||||
- [x] Deployment status tracking (building, deploying, success, failed)
|
||||
- [x] Deployment logs storage
|
||||
- [x] View deployment history per app
|
||||
- [x] Container logs viewing
|
||||
- [ ] Deployment rollback to previous image
|
||||
- [ ] Deployment cancellation
|
||||
|
||||
### Manual Container Controls
|
||||
- [x] Restart container
|
||||
- [x] Stop container
|
||||
- [x] Start stopped container
|
||||
|
||||
### Webhook Integration
|
||||
- [x] Gitea webhook endpoint (`/webhook/:secret`)
|
||||
- [x] Push event parsing
|
||||
- [x] Branch extraction from refs
|
||||
- [x] Branch matching (only deploy configured branch)
|
||||
- [x] Webhook event audit log
|
||||
- [x] Automatic deployment on matching webhook
|
||||
- [ ] Webhook event history UI
|
||||
- [ ] GitHub webhook support
|
||||
- [ ] GitLab webhook support
|
||||
|
||||
### Health Monitoring
|
||||
- [x] Health check endpoint (`/health`)
|
||||
- [x] Application uptime tracking
|
||||
- [x] Docker container health status checking
|
||||
- [x] Post-deployment health verification (60s delay)
|
||||
- [ ] Custom health check commands per app
|
||||
|
||||
### Notifications
|
||||
- [x] ntfy integration (HTTP POST)
|
||||
- [x] Slack-compatible webhook integration
|
||||
- [x] Build start/success/failure notifications
|
||||
- [x] Deploy success/failure notifications
|
||||
- [x] Priority mapping for notification urgency
|
||||
|
||||
### Observability
|
||||
- [x] Request logging middleware
|
||||
- [x] Request ID generation
|
||||
- [x] Sentry error reporting (optional)
|
||||
- [x] Prometheus metrics endpoint (optional, with basic auth)
|
||||
- [ ] Structured logging for all operations
|
||||
- [ ] Deployment count/duration metrics
|
||||
- [ ] Container health status metrics
|
||||
- [ ] Webhook event metrics
|
||||
- [ ] Audit log table for user actions
|
||||
|
||||
### API
|
||||
- [ ] JSON API (`/api/v1/*`)
|
||||
- [ ] List apps endpoint
|
||||
- [ ] Get app details endpoint
|
||||
- [ ] Create app endpoint
|
||||
- [ ] Delete app endpoint
|
||||
- [ ] Trigger deploy endpoint
|
||||
- [ ] List deployments endpoint
|
||||
- [ ] API documentation
|
||||
|
||||
### UI Features
|
||||
- [x] Server-rendered HTML templates
|
||||
- [x] Dashboard with app list
|
||||
- [x] App creation form
|
||||
- [x] App detail view with all configurations
|
||||
- [x] App edit form
|
||||
- [x] Deployment history page
|
||||
- [x] Login page
|
||||
- [x] Setup page
|
||||
- [ ] Container logs page
|
||||
- [ ] Webhook event history page
|
||||
- [ ] Settings page (webhook secret, SSH public key)
|
||||
- [ ] Real-time deployment log streaming (WebSocket/SSE)
|
||||
|
||||
### Future Considerations
|
||||
- [ ] Multi-user support with roles
|
||||
- [ ] Private Docker registry authentication
|
||||
- [ ] Scheduled deployments
|
||||
- [ ] Backup/restore of app configurations
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Critical (Application Cannot Start)
|
||||
|
||||
### 1.1 Server Startup Infrastructure
|
||||
- [x] Implement `Server.Run()` in `internal/server/server.go`
|
||||
- Start HTTP server with configured address/port
|
||||
- Handle TLS if configured
|
||||
- Block until shutdown signal received
|
||||
- [x] Implement `Server.Shutdown()` in `internal/server/server.go`
|
||||
- Graceful shutdown with context timeout
|
||||
- Close database connections
|
||||
- Stop running containers gracefully (optional)
|
||||
- [x] Implement `SetupRoutes()` in `internal/server/routes.go`
|
||||
- Wire up chi router with all handlers
|
||||
- Apply middleware (logging, auth, CORS, metrics)
|
||||
- Define public vs protected route groups
|
||||
- Serve static assets and templates
|
||||
|
||||
### 1.2 Route Configuration
|
||||
```
|
||||
Public Routes:
|
||||
GET /health
|
||||
GET /setup, POST /setup
|
||||
GET /login, POST /login
|
||||
POST /webhook/:secret
|
||||
|
||||
Protected Routes (require auth):
|
||||
GET /logout
|
||||
GET /dashboard
|
||||
GET /apps/new, POST /apps
|
||||
GET /apps/:id, POST /apps/:id, DELETE /apps/:id
|
||||
GET /apps/:id/edit, POST /apps/:id/edit
|
||||
GET /apps/:id/deployments
|
||||
GET /apps/:id/logs
|
||||
POST /apps/:id/env-vars, DELETE /apps/:id/env-vars/:id
|
||||
POST /apps/:id/labels, DELETE /apps/:id/labels/:id
|
||||
POST /apps/:id/volumes, DELETE /apps/:id/volumes/:id
|
||||
POST /apps/:id/deploy
|
||||
```
|
||||
|
||||
## Phase 2: High Priority (Core Functionality Gaps)
|
||||
|
||||
### 2.1 Container Logs
|
||||
- [x] Implement `HandleAppLogs()` in `internal/handlers/app.go`
|
||||
- Fetch logs via Docker API (`ContainerLogs`)
|
||||
- Support tail parameter (last N lines)
|
||||
- Stream logs with SSE or chunked response
|
||||
- [x] Add Docker client method `GetContainerLogs(containerID, tail int) (io.Reader, error)`
|
||||
|
||||
### 2.2 Manual Container Controls
|
||||
- [x] Add `POST /apps/:id/restart` endpoint
|
||||
- Stop and start container
|
||||
- Record restart in deployment log
|
||||
- [x] Add `POST /apps/:id/stop` endpoint
|
||||
- Stop container without deleting
|
||||
- Update app status
|
||||
- [x] Add `POST /apps/:id/start` endpoint
|
||||
- Start stopped container
|
||||
- Run health check
|
||||
|
||||
## Phase 3: Medium Priority (UX Improvements)
|
||||
|
||||
### 3.1 Edit Operations for Related Entities
|
||||
- [ ] Add `PUT /apps/:id/env-vars/:id` endpoint
|
||||
- Update existing environment variable value
|
||||
- Trigger container restart with new env
|
||||
- [ ] Add `PUT /apps/:id/labels/:id` endpoint
|
||||
- Update existing Docker label
|
||||
- [ ] Add `PUT /apps/:id/volumes/:id` endpoint
|
||||
- Update volume mount paths
|
||||
- Validate paths before saving
|
||||
|
||||
### 3.2 Deployment Rollback
|
||||
- [ ] Add `previous_image_id` column to apps table
|
||||
- Store last successful image ID before new deploy
|
||||
- [ ] Add `POST /apps/:id/rollback` endpoint
|
||||
- Stop current container
|
||||
- Start container with previous image
|
||||
- Create deployment record for rollback
|
||||
- [ ] Update deploy service to save previous image before building new one
|
||||
|
||||
### 3.3 Deployment Cancellation
|
||||
- [ ] Add cancellation context to deploy service
|
||||
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
|
||||
- [ ] Handle cleanup of partial builds/containers
|
||||
|
||||
## Phase 4: Lower Priority (Nice to Have)
|
||||
|
||||
### 4.1 JSON API
|
||||
- [ ] Add `/api/v1` route group with JSON responses
|
||||
- [ ] Implement API endpoints mirroring web routes:
|
||||
- `GET /api/v1/apps` - list apps
|
||||
- `POST /api/v1/apps` - create app
|
||||
- `GET /api/v1/apps/:id` - get app details
|
||||
- `DELETE /api/v1/apps/:id` - delete app
|
||||
- `POST /api/v1/apps/:id/deploy` - trigger deploy
|
||||
- `GET /api/v1/apps/:id/deployments` - list deployments
|
||||
- [ ] Add API token authentication (separate from session auth)
|
||||
- [ ] Document API in README
|
||||
|
||||
### 4.2 Resource Limits
|
||||
- [ ] Add `cpu_limit` and `memory_limit` columns to apps table
|
||||
- [ ] Add fields to app edit form
|
||||
- [ ] Pass limits to Docker container create
|
||||
|
||||
### 4.3 UI Improvements
|
||||
- [ ] Add webhook event history page
|
||||
- Show received webhooks per app
|
||||
- Display match/no-match status
|
||||
- [ ] Add settings page
|
||||
- View/regenerate webhook secret
|
||||
- View SSH public key
|
||||
- [ ] Add real-time deployment log streaming
|
||||
- WebSocket or SSE for live build output
|
||||
|
||||
### 4.4 Observability
|
||||
- [ ] Add structured logging for all operations
|
||||
- [ ] Add Prometheus metrics for:
|
||||
- Deployment count/duration
|
||||
- Container health status
|
||||
- Webhook events received
|
||||
- [ ] Add audit log table for user actions
|
||||
|
||||
## Phase 5: Future Considerations
|
||||
|
||||
- [ ] Multi-user support with roles
|
||||
- [ ] Private Docker registry authentication
|
||||
- [ ] Custom health check commands per app
|
||||
- [ ] Scheduled deployments
|
||||
- [ ] Backup/restore of app configurations
|
||||
- [ ] GitHub/GitLab webhook support (in addition to Gitea)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Server.Run() Example
|
||||
```go
|
||||
func (s *Server) Run() error {
|
||||
s.SetupRoutes()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.config.ListenAddr,
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-s.shutdownCh
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
```
|
||||
|
||||
### SetupRoutes() Structure
|
||||
```go
|
||||
func (s *Server) SetupRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(s.middleware.RequestID)
|
||||
r.Use(s.middleware.Logger)
|
||||
r.Use(s.middleware.Recoverer)
|
||||
|
||||
// Public routes
|
||||
r.Get("/health", s.handlers.HandleHealthCheck())
|
||||
r.Get("/login", s.handlers.HandleLoginPage())
|
||||
// ...
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.middleware.SessionAuth)
|
||||
r.Get("/dashboard", s.handlers.HandleDashboard())
|
||||
// ...
|
||||
})
|
||||
|
||||
s.router = r
|
||||
}
|
||||
```
|
||||
6
internal/database/migrations/001_init_migrations.sql
Normal file
6
internal/database/migrations/001_init_migrations.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Initialize migrations table for tracking applied migrations
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,7 +1,8 @@
|
||||
-- Initial schema for upaas
|
||||
-- Complete schema for upaas (consolidated)
|
||||
-- This represents the final state of all migrations applied
|
||||
|
||||
-- Users table (single admin user)
|
||||
CREATE TABLE users (
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
@@ -9,7 +10,7 @@ CREATE TABLE users (
|
||||
);
|
||||
|
||||
-- Apps table
|
||||
CREATE TABLE apps (
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
repo_url TEXT NOT NULL,
|
||||
@@ -18,18 +19,19 @@ CREATE TABLE apps (
|
||||
webhook_secret TEXT NOT NULL,
|
||||
ssh_private_key TEXT NOT NULL,
|
||||
ssh_public_key TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
image_id TEXT,
|
||||
previous_image_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
docker_network TEXT,
|
||||
ntfy_topic TEXT,
|
||||
slack_webhook TEXT,
|
||||
webhook_secret_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- App environment variables
|
||||
CREATE TABLE app_env_vars (
|
||||
CREATE TABLE IF NOT EXISTS app_env_vars (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
@@ -38,7 +40,7 @@ CREATE TABLE app_env_vars (
|
||||
);
|
||||
|
||||
-- App labels
|
||||
CREATE TABLE app_labels (
|
||||
CREATE TABLE IF NOT EXISTS app_labels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
@@ -47,7 +49,7 @@ CREATE TABLE app_labels (
|
||||
);
|
||||
|
||||
-- App volume mounts
|
||||
CREATE TABLE app_volumes (
|
||||
CREATE TABLE IF NOT EXISTS app_volumes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
host_path TEXT NOT NULL,
|
||||
@@ -55,13 +57,24 @@ CREATE TABLE app_volumes (
|
||||
readonly INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- App port mappings
|
||||
CREATE TABLE IF NOT EXISTS app_ports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
host_port INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
|
||||
UNIQUE(host_port, protocol)
|
||||
);
|
||||
|
||||
-- Webhook events log
|
||||
CREATE TABLE webhook_events (
|
||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
commit_url TEXT,
|
||||
payload TEXT,
|
||||
matched INTEGER NOT NULL,
|
||||
processed INTEGER DEFAULT 0,
|
||||
@@ -69,13 +82,13 @@ CREATE TABLE webhook_events (
|
||||
);
|
||||
|
||||
-- Deployments log
|
||||
CREATE TABLE deployments (
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
webhook_event_id INTEGER REFERENCES webhook_events(id),
|
||||
commit_sha TEXT,
|
||||
commit_url TEXT,
|
||||
image_id TEXT,
|
||||
container_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
logs TEXT,
|
||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -83,12 +96,14 @@ CREATE TABLE deployments (
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_apps_status ON apps(status);
|
||||
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
|
||||
CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id);
|
||||
CREATE INDEX idx_app_labels_app_id ON app_labels(app_id);
|
||||
CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id);
|
||||
CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id);
|
||||
CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at);
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
CREATE INDEX idx_deployments_started_at ON deployments(started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret ON apps(webhook_secret);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret_hash ON apps(webhook_secret_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_env_vars_app_id ON app_env_vars(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_labels_app_id ON app_labels(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_volumes_app_id ON app_volumes(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_ports_app_id ON app_ports(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_app_id ON webhook_events(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_app_id ON deployments(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_started_at ON deployments(started_at);
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Remove container_id from apps table
|
||||
-- Container is now looked up via Docker label (upaas.id) instead of stored in database
|
||||
|
||||
-- SQLite doesn't support DROP COLUMN before version 3.35.0 (2021-03-12)
|
||||
-- Use table rebuild for broader compatibility
|
||||
|
||||
-- Create new table without container_id
|
||||
CREATE TABLE apps_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
repo_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
dockerfile_path TEXT DEFAULT 'Dockerfile',
|
||||
webhook_secret TEXT NOT NULL,
|
||||
ssh_private_key TEXT NOT NULL,
|
||||
ssh_public_key TEXT NOT NULL,
|
||||
image_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
docker_network TEXT,
|
||||
ntfy_topic TEXT,
|
||||
slack_webhook TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Copy data (excluding container_id)
|
||||
INSERT INTO apps_new (
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE apps;
|
||||
ALTER TABLE apps_new RENAME TO apps;
|
||||
|
||||
-- Recreate indexes
|
||||
CREATE INDEX idx_apps_status ON apps(status);
|
||||
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Add port mappings for apps
|
||||
|
||||
CREATE TABLE app_ports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
host_port INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
|
||||
UNIQUE(host_port, protocol)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_app_ports_app_id ON app_ports(app_id);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add commit_url column to webhook_events and deployments tables
|
||||
ALTER TABLE webhook_events ADD COLUMN commit_url TEXT;
|
||||
ALTER TABLE deployments ADD COLUMN commit_url TEXT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add webhook_secret_hash column for constant-time secret lookup
|
||||
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';
|
||||
377
internal/handlers/api.go
Normal file
377
internal/handlers/api.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
// apiAppResponse is the JSON representation of an app.
|
||||
type apiAppResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RepoURL string `json:"repoUrl"`
|
||||
Branch string `json:"branch"`
|
||||
DockerfilePath string `json:"dockerfilePath"`
|
||||
Status string `json:"status"`
|
||||
WebhookSecret string `json:"webhookSecret"`
|
||||
SSHPublicKey string `json:"sshPublicKey"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// apiDeploymentResponse is the JSON representation of a deployment.
|
||||
type apiDeploymentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AppID string `json:"appId"`
|
||||
CommitSHA string `json:"commitSha,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
func appToAPI(a *models.App) apiAppResponse {
|
||||
return apiAppResponse{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
RepoURL: a.RepoURL,
|
||||
Branch: a.Branch,
|
||||
DockerfilePath: a.DockerfilePath,
|
||||
Status: string(a.Status),
|
||||
WebhookSecret: a.WebhookSecret,
|
||||
SSHPublicKey: a.SSHPublicKey,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
||||
resp := apiDeploymentResponse{
|
||||
ID: d.ID,
|
||||
AppID: d.AppID,
|
||||
Status: string(d.Status),
|
||||
Duration: d.Duration(),
|
||||
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if d.CommitSHA.Valid {
|
||||
resp.CommitSHA = d.CommitSHA.String
|
||||
}
|
||||
|
||||
if d.FinishedAt.Valid {
|
||||
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||
// and sets a session cookie.
|
||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
var req loginRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "username and password are required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
||||
if authErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid credentials"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionErr := h.auth.CreateSession(writer, request, user)
|
||||
if sessionErr != nil {
|
||||
h.log.Error("api: failed to create session", "error", sessionErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to create session"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, loginResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIListApps returns a handler that lists all apps as JSON.
|
||||
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
apps, err := h.appService.ListApps(request.Context())
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to list apps"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]apiAppResponse, 0, len(apps))
|
||||
for _, a := range apps {
|
||||
result = append(result, appToAPI(a))
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, result, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIGetApp returns a handler that gets a single app by ID.
|
||||
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPICreateApp returns a handler that creates a new app.
|
||||
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
||||
type createRequest struct {
|
||||
Name string `json:"name"`
|
||||
RepoURL string `json:"repoUrl"`
|
||||
Branch string `json:"branch"`
|
||||
DockerfilePath string `json:"dockerfilePath"`
|
||||
DockerNetwork string `json:"dockerNetwork"`
|
||||
NtfyTopic string `json:"ntfyTopic"`
|
||||
SlackWebhook string `json:"slackWebhook"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
var req createRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.RepoURL == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "name and repo_url are required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
nameErr := validateAppName(req.Name)
|
||||
if nameErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid app name: " + nameErr.Error()},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
||||
Name: req.Name,
|
||||
RepoURL: req.RepoURL,
|
||||
Branch: req.Branch,
|
||||
DockerfilePath: req.DockerfilePath,
|
||||
DockerNetwork: req.DockerNetwork,
|
||||
NtfyTopic: req.NtfyTopic,
|
||||
SlackWebhook: req.SlackWebhook,
|
||||
})
|
||||
if createErr != nil {
|
||||
h.log.Error("api: failed to create app", "error", createErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to create app"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDeleteApp returns a handler that deletes an app.
|
||||
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := h.appService.DeleteApp(request.Context(), application)
|
||||
if deleteErr != nil {
|
||||
h.log.Error("api: failed to delete app", "error", deleteErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to delete app"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"status": "deleted"}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// deploymentsPageLimit is the default number of deployments per page.
|
||||
const deploymentsPageLimit = 20
|
||||
|
||||
// HandleAPIListDeployments returns a handler that lists deployments for an app.
|
||||
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil || application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
limit := deploymentsPageLimit
|
||||
|
||||
if l := request.URL.Query().Get("limit"); l != "" {
|
||||
parsed, parseErr := strconv.Atoi(l)
|
||||
if parseErr == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
deployments, deployErr := application.GetDeployments(
|
||||
request.Context(), limit,
|
||||
)
|
||||
if deployErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to list deployments"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]apiDeploymentResponse, 0, len(deployments))
|
||||
for _, d := range deployments {
|
||||
result = append(result, deploymentToAPI(d))
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, result, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
|
||||
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil || application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
|
||||
if deployErr != nil {
|
||||
h.log.Error("api: failed to trigger deploy", "error", deployErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": deployErr.Error()},
|
||||
http.StatusConflict)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"status": "deploying"}, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
||||
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
||||
type whoAmIResponse struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, whoAmIResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
299
internal/handlers/api_test.go
Normal file
299
internal/handlers/api_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// apiRouter builds a chi router with the API routes using session auth middleware.
|
||||
func apiRouter(tc *testContext) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Route("/api/v1", func(apiR chi.Router) {
|
||||
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
|
||||
|
||||
apiR.Group(func(apiR chi.Router) {
|
||||
apiR.Use(tc.middleware.APISessionAuth())
|
||||
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
||||
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
||||
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
|
||||
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
|
||||
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
|
||||
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
|
||||
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// setupAPITest creates a test context with a user and returns session cookies.
|
||||
func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
||||
t.Helper()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
|
||||
// Create a user.
|
||||
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Login via the API to get session cookies.
|
||||
r := apiRouter(tc)
|
||||
|
||||
loginBody := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
cookies := rr.Result().Cookies()
|
||||
require.NotEmpty(t, cookies, "login should return session cookies")
|
||||
|
||||
return tc, cookies
|
||||
}
|
||||
|
||||
// apiRequest makes an authenticated API request using session cookies.
|
||||
func apiRequest(
|
||||
t *testing.T,
|
||||
tc *testContext,
|
||||
cookies []*http.Cookie,
|
||||
method, path string,
|
||||
body string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
r := apiRouter(tc)
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestAPILoginSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
|
||||
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "admin", resp["username"])
|
||||
|
||||
// Should have a Set-Cookie header.
|
||||
assert.NotEmpty(t, rr.Result().Cookies())
|
||||
}
|
||||
|
||||
func TestAPILoginInvalidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
|
||||
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPILoginMissingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"","password":""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIRejectsUnauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := setupTestHandlers(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIWhoAmI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "admin", resp["username"])
|
||||
}
|
||||
|
||||
func TestAPIListAppsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var apps []any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &apps))
|
||||
assert.Empty(t, apps)
|
||||
}
|
||||
|
||||
func TestAPICreateApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var app map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||
assert.Equal(t, "test-app", app["name"])
|
||||
assert.Equal(t, "pending", app["status"])
|
||||
}
|
||||
|
||||
func TestAPICreateAppValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"","repoUrl":""}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIGetApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var app map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||
assert.Equal(t, "my-app", app["name"])
|
||||
}
|
||||
|
||||
func TestAPIGetAppNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
|
||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIDeleteApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIListDeployments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var deployments []any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
|
||||
assert.Empty(t, deployments)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -380,6 +382,30 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAppRollback handles rolling back to the previous deployment image.
|
||||
func (h *Handlers) HandleAppRollback() 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
|
||||
}
|
||||
|
||||
rollbackErr := h.deploy.Rollback(request.Context(), application)
|
||||
if rollbackErr != nil {
|
||||
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAppDeployments returns the deployments history handler.
|
||||
func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
@@ -1116,6 +1142,207 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrVolumePathEmpty is returned when a volume path is empty.
|
||||
var ErrVolumePathEmpty = errors.New("path must not be empty")
|
||||
|
||||
// ErrVolumePathNotAbsolute is returned when a volume path is not absolute.
|
||||
var ErrVolumePathNotAbsolute = errors.New("path must be absolute")
|
||||
|
||||
// ErrVolumePathNotClean is returned when a volume path is not clean.
|
||||
var ErrVolumePathNotClean = errors.New("path must be clean")
|
||||
|
||||
// ValidateVolumePath checks that a path is absolute and clean.
|
||||
func ValidateVolumePath(p string) error {
|
||||
if p == "" {
|
||||
return ErrVolumePathEmpty
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(p) {
|
||||
return ErrVolumePathNotAbsolute
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(p)
|
||||
if cleaned != p {
|
||||
return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEnvVarEdit handles editing an existing environment variable.
|
||||
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
envVarIDStr := chi.URLParam(request, "varID")
|
||||
|
||||
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
|
||||
if findErr != nil || envVar == nil || envVar.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formErr := request.ParseForm()
|
||||
if formErr != nil {
|
||||
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
key := request.FormValue("key")
|
||||
value := request.FormValue("value")
|
||||
|
||||
if key == "" || value == "" {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
envVar.Key = key
|
||||
envVar.Value = value
|
||||
|
||||
saveErr := envVar.Save(request.Context())
|
||||
if saveErr != nil {
|
||||
h.log.Error("failed to update env var", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
writer,
|
||||
request,
|
||||
"/apps/"+appID+"?success=env-updated",
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLabelEdit handles editing an existing label.
|
||||
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
labelIDStr := chi.URLParam(request, "labelID")
|
||||
|
||||
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
|
||||
if findErr != nil || label == nil || label.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formErr := request.ParseForm()
|
||||
if formErr != nil {
|
||||
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
key := request.FormValue("key")
|
||||
value := request.FormValue("value")
|
||||
|
||||
if key == "" || value == "" {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
label.Key = key
|
||||
label.Value = value
|
||||
|
||||
saveErr := label.Save(request.Context())
|
||||
if saveErr != nil {
|
||||
h.log.Error("failed to update label", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleVolumeEdit handles editing an existing volume mount.
|
||||
func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
volumeIDStr := chi.URLParam(request, "volumeID")
|
||||
|
||||
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
|
||||
if findErr != nil || volume == nil || volume.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formErr := request.ParseForm()
|
||||
if formErr != nil {
|
||||
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hostPath := request.FormValue("host_path")
|
||||
containerPath := request.FormValue("container_path")
|
||||
readOnly := request.FormValue("readonly") == "1"
|
||||
|
||||
if hostPath == "" || containerPath == "" {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pathErr := validateVolumePaths(hostPath, containerPath)
|
||||
if pathErr != nil {
|
||||
h.log.Error("invalid volume path", "error", pathErr)
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
volume.HostPath = hostPath
|
||||
volume.ContainerPath = containerPath
|
||||
volume.ReadOnly = readOnly
|
||||
|
||||
saveErr := volume.Save(request.Context())
|
||||
if saveErr != nil {
|
||||
h.log.Error("failed to update volume", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// validateVolumePaths validates both host and container paths for a volume.
|
||||
func validateVolumePaths(hostPath, containerPath string) error {
|
||||
hostErr := ValidateVolumePath(hostPath)
|
||||
if hostErr != nil {
|
||||
return fmt.Errorf("host path: %w", hostErr)
|
||||
}
|
||||
|
||||
containerErr := ValidateVolumePath(containerPath)
|
||||
if containerErr != nil {
|
||||
return fmt.Errorf("container path: %w", containerErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatDeployKey formats an SSH public key with a descriptive comment.
|
||||
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
||||
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"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"
|
||||
@@ -32,10 +33,11 @@ import (
|
||||
)
|
||||
|
||||
type testContext struct {
|
||||
handlers *handlers.Handlers
|
||||
database *database.Database
|
||||
authSvc *auth.Service
|
||||
appSvc *app.Service
|
||||
handlers *handlers.Handlers
|
||||
database *database.Database
|
||||
authSvc *auth.Service
|
||||
appSvc *app.Service
|
||||
middleware *middleware.Middleware
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) *config.Config {
|
||||
@@ -166,11 +168,20 @@ func setupTestHandlers(t *testing.T) *testContext {
|
||||
)
|
||||
require.NoError(t, handlerErr)
|
||||
|
||||
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
|
||||
Logger: logInstance,
|
||||
Globals: globalInstance,
|
||||
Config: cfg,
|
||||
Auth: authSvc,
|
||||
})
|
||||
require.NoError(t, mwErr)
|
||||
|
||||
return &testContext{
|
||||
handlers: handlersInstance,
|
||||
database: dbInstance,
|
||||
authSvc: authSvc,
|
||||
appSvc: appSvc,
|
||||
handlers: handlersInstance,
|
||||
database: dbInstance,
|
||||
authSvc: authSvc,
|
||||
appSvc: appSvc,
|
||||
middleware: mw,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
internal/handlers/volume_validation_test.go
Normal file
34
internal/handlers/volume_validation_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handlers //nolint:testpackage // tests exported ValidateVolumePath function
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateVolumePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid absolute path", "/data/myapp", false},
|
||||
{"root path", "/", false},
|
||||
{"empty path", "", true},
|
||||
{"relative path", "data/myapp", true},
|
||||
{"path with dotdot", "/data/../etc", true},
|
||||
{"path with trailing slash", "/data/", true},
|
||||
{"path with double slash", "/data//myapp", true},
|
||||
{"single dot path", ".", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := ValidateVolumePath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v",
|
||||
tt.path, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,27 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// APISessionAuth returns middleware that requires session authentication for API routes.
|
||||
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
|
||||
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
user, err := m.params.Auth.GetCurrentUser(request.Context(), request)
|
||||
if err != nil || user == nil {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRequired returns middleware that redirects to setup if no user exists.
|
||||
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||
created_at, updated_at`
|
||||
previous_image_id, created_at, updated_at`
|
||||
|
||||
// AppStatus represents the status of an app.
|
||||
type AppStatus string
|
||||
@@ -41,8 +41,9 @@ type App struct {
|
||||
WebhookSecretHash string
|
||||
SSHPrivateKey string
|
||||
SSHPublicKey string
|
||||
ImageID sql.NullString
|
||||
Status AppStatus
|
||||
ImageID sql.NullString
|
||||
PreviousImageID sql.NullString
|
||||
Status AppStatus
|
||||
DockerNetwork sql.NullString
|
||||
NtfyTopic sql.NullString
|
||||
SlackWebhook sql.NullString
|
||||
@@ -140,13 +141,15 @@ func (a *App) insert(ctx context.Context) error {
|
||||
INSERT INTO apps (
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||
previous_image_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := a.db.Exec(ctx, query,
|
||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
|
||||
a.PreviousImageID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -161,6 +164,7 @@ func (a *App) update(ctx context.Context) error {
|
||||
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
|
||||
image_id = ?, status = ?,
|
||||
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
|
||||
previous_image_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
|
||||
@@ -168,6 +172,7 @@ func (a *App) update(ctx context.Context) error {
|
||||
a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
|
||||
a.ImageID, a.Status,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
||||
a.PreviousImageID,
|
||||
a.ID,
|
||||
)
|
||||
|
||||
@@ -182,6 +187,7 @@ func (a *App) scan(row *sql.Row) error {
|
||||
&a.ImageID, &a.Status,
|
||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||
&a.WebhookSecretHash,
|
||||
&a.PreviousImageID,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
}
|
||||
@@ -199,6 +205,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
||||
&app.ImageID, &app.Status,
|
||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||
&app.WebhookSecretHash,
|
||||
&app.PreviousImageID,
|
||||
&app.CreatedAt, &app.UpdatedAt,
|
||||
)
|
||||
if scanErr != nil {
|
||||
|
||||
@@ -76,20 +76,24 @@ func (s *Server) SetupRoutes() {
|
||||
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
|
||||
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
||||
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
|
||||
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
|
||||
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
|
||||
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
||||
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
||||
|
||||
// Environment variables
|
||||
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
|
||||
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
||||
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
||||
|
||||
// Labels
|
||||
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
||||
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
|
||||
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
|
||||
|
||||
// Volumes
|
||||
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
|
||||
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
|
||||
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
|
||||
|
||||
// Ports
|
||||
@@ -98,6 +102,26 @@ func (s *Server) SetupRoutes() {
|
||||
})
|
||||
})
|
||||
|
||||
// API v1 routes (cookie-based session auth, no CSRF)
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
// Login endpoint is public (returns session cookie)
|
||||
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST())
|
||||
|
||||
// All other API routes require session auth
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.APISessionAuth())
|
||||
|
||||
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
|
||||
|
||||
r.Get("/apps", s.handlers.HandleAPIListApps())
|
||||
r.Post("/apps", s.handlers.HandleAPICreateApp())
|
||||
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
|
||||
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
|
||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
|
||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
|
||||
})
|
||||
})
|
||||
|
||||
// Metrics endpoint (optional, with basic auth)
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
|
||||
@@ -49,6 +49,8 @@ var (
|
||||
ErrBuildTimeout = errors.New("build timeout exceeded")
|
||||
// ErrDeployTimeout indicates the deploy phase exceeded the timeout.
|
||||
ErrDeployTimeout = errors.New("deploy timeout exceeded")
|
||||
// ErrNoPreviousImage indicates there is no previous image to rollback to.
|
||||
ErrNoPreviousImage = errors.New("no previous image available for rollback")
|
||||
)
|
||||
|
||||
// logFlushInterval is how often to flush buffered logs to the database.
|
||||
@@ -359,6 +361,107 @@ func (svc *Service) Deploy(
|
||||
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
|
||||
}
|
||||
|
||||
// Rollback rolls back an app to its previous image.
|
||||
// It stops the current container, starts a new one with the previous image,
|
||||
// and creates a deployment record for the rollback.
|
||||
func (svc *Service) Rollback(ctx context.Context, app *models.App) error {
|
||||
if !app.PreviousImageID.Valid || app.PreviousImageID.String == "" {
|
||||
return ErrNoPreviousImage
|
||||
}
|
||||
|
||||
// Acquire per-app deployment lock
|
||||
if !svc.tryLockApp(app.ID) {
|
||||
return ErrDeploymentInProgress
|
||||
}
|
||||
defer svc.unlockApp(app.ID)
|
||||
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
|
||||
deployment, err := svc.createRollbackDeployment(bgCtx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.executeRollback(ctx, bgCtx, app, deployment)
|
||||
}
|
||||
|
||||
// createRollbackDeployment creates a deployment record for a rollback operation.
|
||||
func (svc *Service) createRollbackDeployment(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
) (*models.Deployment, error) {
|
||||
deployment := models.NewDeployment(svc.db)
|
||||
deployment.AppID = app.ID
|
||||
deployment.Status = models.DeploymentStatusDeploying
|
||||
deployment.ImageID = sql.NullString{String: app.PreviousImageID.String, Valid: true}
|
||||
|
||||
saveErr := deployment.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
|
||||
|
||||
return deployment, nil
|
||||
}
|
||||
|
||||
// executeRollback performs the container swap for a rollback.
|
||||
func (svc *Service) executeRollback(
|
||||
ctx context.Context,
|
||||
bgCtx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) error {
|
||||
previousImageID := app.PreviousImageID.String
|
||||
|
||||
svc.removeOldContainer(ctx, app, deployment)
|
||||
|
||||
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||
if err != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, err)
|
||||
|
||||
return fmt.Errorf("failed to build container options: %w", err)
|
||||
}
|
||||
|
||||
rollbackOpts.Image = previousImageID
|
||||
|
||||
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
|
||||
if err != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
|
||||
|
||||
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)
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
|
||||
|
||||
return fmt.Errorf("failed to start rollback container: %w", startErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback container started")
|
||||
|
||||
currentImageID := app.ImageID
|
||||
app.ImageID = sql.NullString{String: previousImageID, Valid: true}
|
||||
app.PreviousImageID = currentImageID
|
||||
app.Status = models.AppStatusRunning
|
||||
|
||||
saveErr := app.Save(bgCtx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to update app after rollback: %w", saveErr)
|
||||
}
|
||||
|
||||
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusSuccess)
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback complete")
|
||||
|
||||
svc.log.Info("rollback completed", "app", app.Name, "image", previousImageID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runBuildAndDeploy executes the build and deploy phases, handling cancellation.
|
||||
func (svc *Service) runBuildAndDeploy(
|
||||
deployCtx context.Context,
|
||||
@@ -390,6 +493,11 @@ func (svc *Service) runBuildAndDeploy(
|
||||
return err
|
||||
}
|
||||
|
||||
// Save current image as previous before updating to new one
|
||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
||||
app.PreviousImageID = app.ImageID
|
||||
}
|
||||
|
||||
err = svc.updateAppRunning(bgCtx, app, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-warning-500 text-white hover:bg-warning-700 active:bg-orange-800 focus:ring-orange-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="btn-danger">Cancel Deploy</button>
|
||||
</form>
|
||||
{{if .App.PreviousImageID.Valid}}
|
||||
<form method="POST" action="/apps/{{.App.ID}}/rollback" class="inline" x-data="confirmAction('Roll back to the previous deployment?')" @submit="confirm($event)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="btn-warning">Rollback</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,15 +112,34 @@
|
||||
</thead>
|
||||
<tbody class="table-body">
|
||||
{{range .EnvVars}}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{.Key}}</td>
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
<td class="text-right">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
<tr x-data="{ editing: false }">
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono font-medium">{{.Key}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="text-right">
|
||||
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||
{{ $.CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="editing">
|
||||
<td colspan="3">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
|
||||
{{ $.CSRFField }}
|
||||
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
|
||||
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
||||
</form>
|
||||
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@@ -151,15 +176,33 @@
|
||||
</td>
|
||||
</tr>
|
||||
{{range .Labels}}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{.Key}}</td>
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
<td class="text-right">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
<tr x-data="{ editing: false }">
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono font-medium">{{.Key}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="text-right">
|
||||
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
||||
{{ $.CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="editing">
|
||||
<td colspan="3">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
|
||||
{{ $.CSRFField }}
|
||||
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
|
||||
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@@ -189,22 +232,46 @@
|
||||
</thead>
|
||||
<tbody class="table-body">
|
||||
{{range .Volumes}}
|
||||
<tr>
|
||||
<td class="font-mono">{{.HostPath}}</td>
|
||||
<td class="font-mono">{{.ContainerPath}}</td>
|
||||
<td>
|
||||
{{if .ReadOnly}}
|
||||
<span class="badge-neutral">Read-only</span>
|
||||
{{else}}
|
||||
<span class="badge-info">Read-write</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
<tr x-data="{ editing: false }">
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono">{{.HostPath}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="font-mono">{{.ContainerPath}}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td>
|
||||
{{if .ReadOnly}}
|
||||
<span class="badge-neutral">Read-only</span>
|
||||
{{else}}
|
||||
<span class="badge-info">Read-write</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="text-right">
|
||||
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
||||
{{ $.CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="editing">
|
||||
<td colspan="4">
|
||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
|
||||
{{ $.CSRFField }}
|
||||
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
|
||||
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
|
||||
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
|
||||
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
|
||||
RO
|
||||
</label>
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user