Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ Sticky sessions default to 3 probes per sample and pool sessions to 8. The defau

The list and detail views are backed by PostgreSQL and remain useful if ClickHouse reporting is unavailable. Reports, grouped samples, and CSV depend on ClickHouse. Export a session with `GET /api/sessions/{id}/export.csv`, stop it with `POST /api/sessions/{id}/stop`, and delete it with `DELETE /api/sessions/{id}`. Deleting a running session stops its worker first. Deletion then crosses the writer queue barrier, removes ClickHouse events, and finally removes the PostgreSQL session and its IP inventory.

Rename a session with `PATCH /api/sessions/{id}` and a run with `PATCH /api/runs/{id}`, sending the properties to change (`{"name": "..."}`). Renaming never touches sampling state, so it is valid in any status. A run's variant sessions are named `<run name> (axis=value)` when the run is created; renaming the run rewrites the variants that still carry a generated name and leaves any variant you renamed yourself untouched. Which is which is recorded per session when you rename it, not inferred from the name's text, so a variant you named by hand keeps that name even if it happens to match what a later run name would generate.

On SIGINT or SIGTERM, the HTTP server stops accepting work, active samplers are cancelled and awaited, and the ClickHouse writer drains its accepted queue before connections close. Shutdown does not change active session rows from `running`; starting the service with the same databases and encryption key resumes them from their persisted sample counters, so sequence numbers continue rather than restarting.

`GET /healthz` is process liveness. `GET /readyz` checks both PostgreSQL and ClickHouse and returns HTTP 503 with per-dependency status when either is unavailable. The container intentionally has no baked-in healthcheck so the target orchestrator can set its own intervals and failure policy against these endpoints.
Expand Down
58 changes: 58 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ paths:
'404': {$ref: '#/components/responses/NotFound'}
'500': {$ref: '#/components/responses/InternalError'}
'503': {$ref: '#/components/responses/DependencyUnavailable'}
patch:
operationId: editSession
description: Edits the mutable properties of a session.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/EditSessionRequest'}
responses:
'200':
description: Session updated
content:
application/json:
schema:
$ref: '#/components/schemas/Session'
'400': {$ref: '#/components/responses/BadRequest'}
'404': {$ref: '#/components/responses/NotFound'}
'500': {$ref: '#/components/responses/InternalError'}
'503': {$ref: '#/components/responses/DependencyUnavailable'}
delete:
operationId: deleteSession
responses:
Expand Down Expand Up @@ -209,6 +228,27 @@ paths:
'404': {$ref: '#/components/responses/NotFound'}
'500': {$ref: '#/components/responses/InternalError'}
'503': {$ref: '#/components/responses/DependencyUnavailable'}
patch:
operationId: editRun
description: >-
Edits the mutable properties of a run. Renaming a run also renames the
child sessions that still carry their generated name, leaving any
variant renamed by hand untouched.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/EditRunRequest'}
responses:
'200':
description: Run updated
content:
application/json:
schema: {$ref: '#/components/schemas/Run'}
'400': {$ref: '#/components/responses/BadRequest'}
'404': {$ref: '#/components/responses/NotFound'}
'500': {$ref: '#/components/responses/InternalError'}
'503': {$ref: '#/components/responses/DependencyUnavailable'}
delete:
operationId: deleteRun
responses:
Expand Down Expand Up @@ -373,6 +413,15 @@ components:
max_samples: {type: integer, minimum: 1, maximum: 2147483647, nullable: true}
max_duration_seconds: {type: integer, minimum: 1, maximum: 2147483647, nullable: true}
target_country: {type: string, pattern: '^([A-Za-z]{2})?$', nullable: true}
EditSessionRequest:
type: object
additionalProperties: false
description: >-
Mutable session properties. Every property is optional so the payload
can grow to cover further editable fields; a request that sets none of
them is rejected.
properties:
name: {type: string, minLength: 1, maxLength: 100}
Session:
type: object
required: [id, name, proxy_display, mode, status, cadence_seconds, probes_per_sample,
Expand Down Expand Up @@ -612,6 +661,15 @@ components:
required: [max_variants_per_run]
properties:
max_variants_per_run: {type: integer, minimum: 1}
EditRunRequest:
type: object
additionalProperties: false
description: >-
Mutable run properties. Every property is optional so the payload can
grow to cover further editable fields; a request that sets none of them
is rejected.
properties:
name: {type: string, minLength: 1, maxLength: 100}
Run:
type: object
required: [id, name, template_display, status, variant_count, distinct_ips, created_at]
Expand Down
85 changes: 85 additions & 0 deletions internal/api/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package api

import (
"context"
"errors"
"strings"
"unicode/utf8"

"github.com/timo972/proxy-sampler/internal/api/openapi"
"github.com/timo972/proxy-sampler/internal/session"
"github.com/timo972/proxy-sampler/internal/variation"
)

// EditSession applies the mutable properties of a session. Only the display
// name is editable today; the request shape carries every property as optional
// so further fields can join it without breaking existing clients.
func (s *Server) EditSession(ctx context.Context, request openapi.EditSessionRequestObject) (openapi.EditSessionResponseObject, error) {
if request.Body == nil {
return nil, invalidRequest()
}
name, err := editedName(request.Body.Name)
if err != nil {
return nil, err
}

if err := s.store.Rename(ctx, request.Id, name); err != nil {
if errors.Is(err, session.ErrNotFound) {
return nil, notFound()
}
return nil, internalError()
}
value, err := s.store.SessionByID(ctx, request.Id)
if err != nil {
if errors.Is(err, session.ErrNotFound) {
return nil, notFound()
}
return nil, internalError()
}
return openapi.EditSession200JSONResponse(mapSession(value)), nil
}

// EditRun applies the mutable properties of a run. Renaming a run also renames
// the child sessions that still carry their generated name, so a run and its
// variants stay consistent while any variant renamed by hand is preserved.
func (s *Server) EditRun(ctx context.Context, request openapi.EditRunRequestObject) (openapi.EditRunResponseObject, error) {
if s.runStore == nil {
return nil, internalError()
}
if request.Body == nil {
return nil, invalidRequest()
}
name, err := editedName(request.Body.Name)
if err != nil {
return nil, err
}

if err := s.runStore.RenameRun(ctx, request.Id, name); err != nil {
if errors.Is(err, variation.ErrRunNotFound) {
return nil, notFound()
}
return nil, internalError()
}
summary, err := s.runStore.RunByID(ctx, request.Id)
if err != nil {
if errors.Is(err, variation.ErrRunNotFound) {
return nil, notFound()
}
return nil, internalError()
}
return openapi.EditRun200JSONResponse(mapRun(summary)), nil
}

// editedName validates the one property an edit request can currently set. A
// nil pointer means the client sent no editable property at all, which is a
// bad request rather than a silent no-op.
func editedName(raw *string) (string, error) {
if raw == nil {
return "", invalidRequest()
}
name := strings.TrimSpace(*raw)
if name == "" || utf8.RuneCountInString(name) > 100 {
return "", invalidRequest()
}
return name, nil
}
Loading