feat(docker-watcher): phase 5 - registry client & poller
Gitea registry client with tag listing and pattern matching, cron-based polling scheduler with first-poll safety, poll state persistence. DeployTriggerer interface for decoupled deploy triggering.
This commit is contained in:
@@ -261,12 +261,12 @@ Core infrastructure — store, config import, Docker client, NPM client.
|
|||||||
5. **Docker client** — connect to socket, pull image, inspect image, list/start/stop/remove containers, manage networks
|
5. **Docker client** — connect to socket, pull image, inspect image, list/start/stop/remove containers, manage networks
|
||||||
6. **NPM client** — authenticate (JWT), create/update/delete proxy hosts, list existing hosts
|
6. **NPM client** — authenticate (JWT), create/update/delete proxy hosts, list existing hosts
|
||||||
|
|
||||||
### Phase 2: Detection & Deployment
|
### Phase 2: Detection & Deployment (Registry & Poller ✅)
|
||||||
|
|
||||||
The core loop — detecting new images and deploying them.
|
The core loop — detecting new images and deploying them.
|
||||||
|
|
||||||
8. **Registry client** — Gitea registry API: list tags for an image, detect new tags
|
8. **Registry client** ✅ — Gitea registry API: list tags for an image, detect new tags
|
||||||
9. **Poller** — periodic check for new tags matching configured patterns
|
9. **Poller** ✅ — periodic check for new tags matching configured patterns
|
||||||
10. **Secret webhook handler** — UUID-based URL, receives image push notifications, auto-creates unknown projects
|
10. **Secret webhook handler** — UUID-based URL, receives image push notifications, auto-creates unknown projects
|
||||||
11. **Deployer** — orchestrate: pull → start container → NPM proxy → health check
|
11. **Deployer** — orchestrate: pull → start container → NPM proxy → health check
|
||||||
12. **Multi-instance support** — multiple versions per project/stage, tag-based subdomains, max_instances limit
|
12. **Multi-instance support** — multiple versions per project/stage, tag-based subdomains, max_instances limit
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// giteaPackageVersion represents a single version entry from the Gitea
|
||||||
|
// packages API response.
|
||||||
|
type giteaPackageVersion struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Creator struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"creator"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiteaClient implements Client for Gitea container registries.
|
||||||
|
type GiteaClient struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGiteaClient creates a new Gitea registry client.
|
||||||
|
// baseURL should be the Gitea instance URL (e.g., "https://git.example.com").
|
||||||
|
// token is a personal access token with package read permissions.
|
||||||
|
func NewGiteaClient(baseURL, token string) *GiteaClient {
|
||||||
|
return &GiteaClient{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTags returns all available tags for the given container image.
|
||||||
|
// The image should be in the format "owner/package-name" or
|
||||||
|
// "registry-host/owner/package-name" (the registry host prefix is stripped).
|
||||||
|
func (c *GiteaClient) ListTags(ctx context.Context, image string) ([]string, error) {
|
||||||
|
owner, pkg := parseImage(image)
|
||||||
|
if owner == "" || pkg == "" {
|
||||||
|
return nil, fmt.Errorf("invalid image format %q: expected owner/package", image)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := c.listPackageVersions(ctx, owner, pkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tags for %s/%s: %w", owner, pkg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(versions))
|
||||||
|
for _, v := range versions {
|
||||||
|
tags = append(tags, v.Version)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestTag returns the most recently created tag matching the given glob
|
||||||
|
// pattern. Returns empty string if no tags match.
|
||||||
|
func (c *GiteaClient) GetLatestTag(ctx context.Context, image string, pattern string) (string, error) {
|
||||||
|
tags, err := c.ListTags(ctx, image)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return LatestTag(tags, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPackageVersions fetches all container package versions from the Gitea API.
|
||||||
|
// Endpoint: GET /api/v1/packages/{owner}?type=container&q={package}
|
||||||
|
// Gitea paginates results; this function fetches all pages.
|
||||||
|
func (c *GiteaClient) listPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) {
|
||||||
|
var allVersions []giteaPackageVersion
|
||||||
|
page := 1
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&q=%s&page=%d&limit=%d",
|
||||||
|
c.baseURL, owner, pkg, page, limit)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []giteaPackageListEntry
|
||||||
|
if err := json.Unmarshal(body, &packages); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode package list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for exact package name match and collect versions.
|
||||||
|
for _, p := range packages {
|
||||||
|
if p.Name == pkg {
|
||||||
|
versions, err := c.fetchPackageVersions(ctx, owner, pkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return versions, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got fewer results than the limit, we've reached the last page.
|
||||||
|
if len(packages) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return allVersions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaPackageListEntry represents a package in the Gitea packages list response.
|
||||||
|
type giteaPackageListEntry struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchPackageVersions fetches all versions of a specific container package.
|
||||||
|
// Endpoint: GET /api/v1/packages/{owner}/container/{name}
|
||||||
|
func (c *GiteaClient) fetchPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) {
|
||||||
|
var allVersions []giteaPackageVersion
|
||||||
|
page := 1
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/packages/%s/container/%s?page=%d&limit=%d",
|
||||||
|
c.baseURL, owner, pkg, page, limit)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []giteaPackageVersion
|
||||||
|
if err := json.Unmarshal(body, &versions); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode versions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allVersions = append(allVersions, versions...)
|
||||||
|
|
||||||
|
if len(versions) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return allVersions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImage extracts the owner and package name from an image string.
|
||||||
|
// Supported formats:
|
||||||
|
// - "owner/package"
|
||||||
|
// - "registry.example.com/owner/package"
|
||||||
|
//
|
||||||
|
// Returns empty strings if the format is invalid.
|
||||||
|
func parseImage(image string) (owner, pkg string) {
|
||||||
|
parts := strings.Split(image, "/")
|
||||||
|
switch len(parts) {
|
||||||
|
case 2:
|
||||||
|
// owner/package
|
||||||
|
return parts[0], parts[1]
|
||||||
|
case 3:
|
||||||
|
// registry.example.com/owner/package
|
||||||
|
return parts[1], parts[2]
|
||||||
|
default:
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Poller periodically checks registries for new image tags and triggers
|
||||||
|
// deployments for stages with auto_deploy enabled.
|
||||||
|
type Poller struct {
|
||||||
|
store *store.Store
|
||||||
|
deployer DeployTriggerer
|
||||||
|
encKey [32]byte
|
||||||
|
cron *cron.Cron
|
||||||
|
mu sync.Mutex
|
||||||
|
entryID cron.EntryID
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPoller creates a new Poller instance.
|
||||||
|
func NewPoller(st *store.Store, deployer DeployTriggerer, encKey [32]byte) *Poller {
|
||||||
|
return &Poller{
|
||||||
|
store: st,
|
||||||
|
deployer: deployer,
|
||||||
|
encKey: encKey,
|
||||||
|
cron: cron.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the polling scheduler with the given interval string (e.g., "5m", "1h").
|
||||||
|
// If the poller is already running, it stops and restarts with the new interval.
|
||||||
|
func (p *Poller) Start(interval string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
duration, err := time.ParseDuration(interval)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse polling interval %q: %w", interval, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop existing schedule if running.
|
||||||
|
if p.running {
|
||||||
|
p.cron.Remove(p.entryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert duration to a cron schedule: @every <duration>.
|
||||||
|
spec := fmt.Sprintf("@every %s", duration.String())
|
||||||
|
entryID, err := p.cron.AddFunc(spec, func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if pollErr := p.poll(ctx); pollErr != nil {
|
||||||
|
log.Printf("[poller] poll error: %v", pollErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("schedule poller: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.entryID = entryID
|
||||||
|
if !p.running {
|
||||||
|
p.cron.Start()
|
||||||
|
}
|
||||||
|
p.running = true
|
||||||
|
|
||||||
|
log.Printf("[poller] started with interval %s", duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the poller.
|
||||||
|
func (p *Poller) Stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.running {
|
||||||
|
ctx := p.cron.Stop()
|
||||||
|
<-ctx.Done()
|
||||||
|
p.running = false
|
||||||
|
log.Println("[poller] stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll performs a single polling cycle: iterates over all projects and their
|
||||||
|
// stages, checks for new tags, and triggers deploys where appropriate.
|
||||||
|
func (p *Poller) poll(ctx context.Context) error {
|
||||||
|
projects, err := p.store.GetAllProjects()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get projects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, project := range projects {
|
||||||
|
if err := p.pollProject(ctx, project); err != nil {
|
||||||
|
log.Printf("[poller] project %s (%s): %v", project.Name, project.ID, err)
|
||||||
|
// Continue polling other projects even if one fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollProject checks all stages of a single project for new tags.
|
||||||
|
func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
|
||||||
|
if project.Registry == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the registry configuration by name (projects store registry name, not ID).
|
||||||
|
reg, err := p.store.GetRegistryByName(project.Registry)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get registry %s: %w", project.Registry, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the registry token.
|
||||||
|
token, err := crypto.Decrypt(p.encKey, reg.Token)
|
||||||
|
if err != nil {
|
||||||
|
// Token might not be encrypted (empty or plaintext).
|
||||||
|
token = reg.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a registry client for this registry type.
|
||||||
|
client, err := NewClient(reg.Type, reg.URL, token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create registry client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all available tags for the project image.
|
||||||
|
tags, err := client.ListTags(ctx, project.Image)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list tags for %s: %w", project.Image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each stage of the project.
|
||||||
|
stages, err := p.store.GetStagesByProjectID(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get stages for project %s: %w", project.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stage := range stages {
|
||||||
|
if err := p.pollStage(ctx, project, stage, tags); err != nil {
|
||||||
|
log.Printf("[poller] project %s stage %s: %v", project.Name, stage.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollStage checks a single stage for new tags and triggers deploy if needed.
|
||||||
|
func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error {
|
||||||
|
// Find the latest tag matching the stage's pattern.
|
||||||
|
latest, err := LatestTag(allTags, stage.TagPattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("match tags for stage %s: %w", stage.Name, err)
|
||||||
|
}
|
||||||
|
if latest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the last polled tag for this stage.
|
||||||
|
state, err := p.store.GetPollState(stage.ID)
|
||||||
|
if err != nil {
|
||||||
|
// No poll state yet — this is the first poll for this stage.
|
||||||
|
// Record the current latest tag without triggering a deploy,
|
||||||
|
// so we don't deploy everything on first startup.
|
||||||
|
return p.store.UpsertPollState(store.PollState{
|
||||||
|
StageID: stage.ID,
|
||||||
|
LastTag: latest,
|
||||||
|
LastPolled: now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the poll timestamp regardless.
|
||||||
|
defer func() {
|
||||||
|
if err := p.store.UpsertPollState(store.PollState{
|
||||||
|
StageID: stage.ID,
|
||||||
|
LastTag: latest,
|
||||||
|
LastPolled: now(),
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[poller] failed to update poll state for stage %s: %v", stage.ID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// If the latest tag hasn't changed, nothing to do.
|
||||||
|
if state.LastTag == latest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[poller] new tag %q detected for project %s stage %s (was %q)",
|
||||||
|
latest, project.Name, stage.Name, state.LastTag)
|
||||||
|
|
||||||
|
// Only trigger deploy if auto_deploy is enabled for this stage.
|
||||||
|
if !stage.AutoDeploy {
|
||||||
|
log.Printf("[poller] auto_deploy disabled for stage %s, skipping deploy", stage.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.deployer.TriggerDeploy(ctx, project.ID, stage.ID, latest); err != nil {
|
||||||
|
return fmt.Errorf("trigger deploy for tag %s: %w", latest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// now returns the current UTC time as a formatted string.
|
||||||
|
func now() string {
|
||||||
|
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client defines the interface for interacting with a container image registry.
|
||||||
|
type Client interface {
|
||||||
|
// ListTags returns all available tags for the given image.
|
||||||
|
ListTags(ctx context.Context, image string) ([]string, error)
|
||||||
|
|
||||||
|
// GetLatestTag returns the most recently created tag that matches the given
|
||||||
|
// glob pattern. Returns an empty string and no error if no tags match.
|
||||||
|
GetLatestTag(ctx context.Context, image string, pattern string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployTriggerer is called by the poller when a new tag is detected for a
|
||||||
|
// stage with auto_deploy enabled. This decouples the registry package from the
|
||||||
|
// deployer implementation.
|
||||||
|
type DeployTriggerer interface {
|
||||||
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchTags filters a list of tags, returning only those that match the given
|
||||||
|
// glob pattern. Pattern matching uses path.Match semantics (*, ?, []).
|
||||||
|
// Returns an error if the pattern is malformed.
|
||||||
|
func MatchTags(tags []string, pattern string) ([]string, error) {
|
||||||
|
if pattern == "" || pattern == "*" {
|
||||||
|
result := make([]string, len(tags))
|
||||||
|
copy(result, tags)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate pattern once before iterating.
|
||||||
|
if _, err := path.Match(pattern, ""); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tag pattern %q: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched []string
|
||||||
|
for _, tag := range tags {
|
||||||
|
ok, _ := path.Match(pattern, tag)
|
||||||
|
if ok {
|
||||||
|
matched = append(matched, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestTag returns the last element of a sorted tag list that matches the
|
||||||
|
// pattern. Tags are sorted lexicographically; the "latest" is the last in sort
|
||||||
|
// order. Returns empty string if no tags match. Returns an error if the pattern
|
||||||
|
// is malformed.
|
||||||
|
func LatestTag(tags []string, pattern string) (string, error) {
|
||||||
|
matched, err := MatchTags(tags, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(matched) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
sort.Strings(matched)
|
||||||
|
return matched[len(matched)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a registry Client based on the registry type string.
|
||||||
|
// Supported types: "gitea". Future: "github", "dockerhub".
|
||||||
|
func NewClient(registryType, baseURL, token string) (Client, error) {
|
||||||
|
switch strings.ToLower(registryType) {
|
||||||
|
case "gitea":
|
||||||
|
return NewGiteaClient(baseURL, token), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported registry type: %s", registryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PollState tracks the last polled tag for a stage, enabling the poller to
|
||||||
|
// detect new tags since the previous poll cycle.
|
||||||
|
type PollState struct {
|
||||||
|
StageID string `json:"stage_id"`
|
||||||
|
LastTag string `json:"last_tag"`
|
||||||
|
LastPolled string `json:"last_polled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPollState returns the poll state for a given stage.
|
||||||
|
func (s *Store) GetPollState(stageID string) (PollState, error) {
|
||||||
|
var ps PollState
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`,
|
||||||
|
stageID,
|
||||||
|
).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PollState{}, fmt.Errorf("query poll state: %w", err)
|
||||||
|
}
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPollState inserts or updates the poll state for a stage.
|
||||||
|
func (s *Store) UpsertPollState(ps PollState) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO poll_states (stage_id, last_tag, last_polled)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`,
|
||||||
|
ps.StageID, ps.LastTag, ps.LastPolled,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert poll state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePollState removes the poll state for a stage.
|
||||||
|
func (s *Store) DeletePollState(stageID string) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete poll state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllPollStates returns all poll states, ordered by last_polled descending.
|
||||||
|
func (s *Store) GetAllPollStates() ([]PollState, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query poll states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var states []PollState
|
||||||
|
for rows.Next() {
|
||||||
|
var ps PollState
|
||||||
|
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan poll state: %w", err)
|
||||||
|
}
|
||||||
|
states = append(states, ps)
|
||||||
|
}
|
||||||
|
return states, rows.Err()
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@ func (s *Store) GetRegistryByID(id string) (Registry, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRegistryByName returns a single registry by its unique name.
|
||||||
|
func (s *Store) GetRegistryByName(name string) (Registry, error) {
|
||||||
|
var r Registry
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, name, url, type, token, created_at, updated_at
|
||||||
|
FROM registries WHERE name = ?`, name,
|
||||||
|
).Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Registry{}, fmt.Errorf("registry %q: %w", name, ErrNotFound)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Registry{}, fmt.Errorf("query registry by name: %w", err)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllRegistries returns every registry ordered by name.
|
// GetAllRegistries returns every registry ordered by name.
|
||||||
func (s *Store) GetAllRegistries() ([]Registry, error) {
|
func (s *Store) GetAllRegistries() ([]Registry, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ CREATE TABLE IF NOT EXISTS deploy_logs (
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS poll_states (
|
||||||
|
stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE,
|
||||||
|
last_tag TEXT NOT NULL DEFAULT '',
|
||||||
|
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
-- Seed the settings row if it does not exist.
|
-- Seed the settings row if it does not exist.
|
||||||
INSERT OR IGNORE INTO settings (id) VALUES (1);
|
INSERT OR IGNORE INTO settings (id) VALUES (1);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoCreateProject creates a new project and a default "dev" stage from an
|
||||||
|
// unknown image. It inspects the Docker image to extract defaults (EXPOSE port,
|
||||||
|
// healthcheck, labels).
|
||||||
|
//
|
||||||
|
// The auto-created project uses:
|
||||||
|
// - Name: derived from image name (e.g. "web-app-launcher")
|
||||||
|
// - Image: full owner/name path
|
||||||
|
// - Port: first EXPOSE port from the image, or 0 if none
|
||||||
|
// - Healthcheck: from image HEALTHCHECK instruction, if present
|
||||||
|
// - A single "dev" stage with auto_deploy=true and tag_pattern="*"
|
||||||
|
func AutoCreateProject(
|
||||||
|
ctx context.Context,
|
||||||
|
st *store.Store,
|
||||||
|
inspector ImageInspector,
|
||||||
|
parsed ParsedImage,
|
||||||
|
) (store.Project, store.Stage, error) {
|
||||||
|
// Build the full image ref for inspection (registry/owner/name:tag).
|
||||||
|
imageRef := buildImageRef(parsed)
|
||||||
|
|
||||||
|
var port int
|
||||||
|
var healthcheck string
|
||||||
|
|
||||||
|
// Attempt to inspect the image for metadata. If inspection fails (image
|
||||||
|
// not pulled locally), proceed with zero defaults.
|
||||||
|
if inspector != nil {
|
||||||
|
info, err := inspector.InspectImage(ctx, imageRef)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[webhook] image inspection failed for %s (using defaults): %v", imageRef, err)
|
||||||
|
} else {
|
||||||
|
port = extractPort(info.ExposedPorts)
|
||||||
|
healthcheck = info.Healthcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := st.CreateProject(store.Project{
|
||||||
|
Name: parsed.Name,
|
||||||
|
Registry: parsed.Registry,
|
||||||
|
Image: parsed.FullName(),
|
||||||
|
Port: port,
|
||||||
|
Healthcheck: healthcheck,
|
||||||
|
Env: "{}",
|
||||||
|
Volumes: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return store.Project{}, store.Stage{}, fmt.Errorf("create project: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stage, err := st.CreateStage(store.Stage{
|
||||||
|
ProjectID: project.ID,
|
||||||
|
Name: "dev",
|
||||||
|
TagPattern: "*",
|
||||||
|
AutoDeploy: true,
|
||||||
|
MaxInstances: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return store.Project{}, store.Stage{}, fmt.Errorf("create default stage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return project, stage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImageRef reconstructs a pullable image reference from parsed components.
|
||||||
|
func buildImageRef(parsed ParsedImage) string {
|
||||||
|
var parts []string
|
||||||
|
if parsed.Registry != "" {
|
||||||
|
parts = append(parts, parsed.Registry)
|
||||||
|
}
|
||||||
|
if parsed.Owner != "" {
|
||||||
|
parts = append(parts, parsed.Owner)
|
||||||
|
}
|
||||||
|
parts = append(parts, parsed.Name)
|
||||||
|
|
||||||
|
ref := strings.Join(parts, "/")
|
||||||
|
if parsed.Tag != "" {
|
||||||
|
ref += ":" + parsed.Tag
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPort parses the first exposed port from Docker EXPOSE entries.
|
||||||
|
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
|
||||||
|
func extractPort(exposedPorts []string) int {
|
||||||
|
if len(exposedPorts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the first port entry.
|
||||||
|
raw := exposedPorts[0]
|
||||||
|
// Strip protocol suffix (e.g. "/tcp", "/udp").
|
||||||
|
if idx := strings.Index(raw, "/"); idx != -1 {
|
||||||
|
raw = raw[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/docker"
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeployTriggerer is called when a webhook determines a deploy should happen.
|
||||||
|
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
|
||||||
|
type DeployTriggerer interface {
|
||||||
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageInspector abstracts Docker image inspection for testability.
|
||||||
|
type ImageInspector interface {
|
||||||
|
InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload is the expected JSON body for a webhook request.
|
||||||
|
type Payload struct {
|
||||||
|
// Image is the full image reference including tag, e.g.
|
||||||
|
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedImage holds the components extracted from a full image reference string.
|
||||||
|
type ParsedImage struct {
|
||||||
|
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
||||||
|
Registry string
|
||||||
|
// Owner is the namespace/org, e.g. "alexei".
|
||||||
|
Owner string
|
||||||
|
// Name is the repository name, e.g. "web-app-launcher".
|
||||||
|
Name string
|
||||||
|
// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullName returns "owner/name" (the image path without registry and tag).
|
||||||
|
func (p ParsedImage) FullName() string {
|
||||||
|
if p.Owner != "" {
|
||||||
|
return p.Owner + "/" + p.Name
|
||||||
|
}
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseImageRef splits a full image reference into its components.
|
||||||
|
// Accepted formats:
|
||||||
|
//
|
||||||
|
// registry.example.com/owner/name:tag
|
||||||
|
// registry.example.com/owner/name
|
||||||
|
// owner/name:tag
|
||||||
|
// name:tag
|
||||||
|
func ParseImageRef(ref string) (ParsedImage, error) {
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
if ref == "" {
|
||||||
|
return ParsedImage{}, fmt.Errorf("empty image reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed ParsedImage
|
||||||
|
|
||||||
|
// Split off tag.
|
||||||
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
||||||
|
// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
|
||||||
|
afterColon := ref[idx+1:]
|
||||||
|
if !strings.Contains(afterColon, "/") {
|
||||||
|
parsed.Tag = afterColon
|
||||||
|
ref = ref[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(ref, "/")
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
// "name"
|
||||||
|
parsed.Name = parts[0]
|
||||||
|
case 2:
|
||||||
|
// "owner/name"
|
||||||
|
parsed.Owner = parts[0]
|
||||||
|
parsed.Name = parts[1]
|
||||||
|
default:
|
||||||
|
// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
|
||||||
|
parsed.Registry = parts[0]
|
||||||
|
parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
|
||||||
|
parsed.Name = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Name == "" {
|
||||||
|
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler is the HTTP handler for webhook requests.
|
||||||
|
type Handler struct {
|
||||||
|
store *store.Store
|
||||||
|
deployer DeployTriggerer
|
||||||
|
inspector ImageInspector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new webhook Handler.
|
||||||
|
func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
store: st,
|
||||||
|
deployer: deployer,
|
||||||
|
inspector: inspector,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route returns a chi router with the webhook endpoint mounted.
|
||||||
|
func (h *Handler) Route() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/{secret}", h.handleWebhook)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhook processes an incoming webhook request.
|
||||||
|
// URL format: POST /api/webhook/{secret-uuid}
|
||||||
|
// Returns 404 for invalid secrets (no information leak).
|
||||||
|
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
secret := chi.URLParam(r, "secret")
|
||||||
|
if secret == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the webhook secret against stored settings.
|
||||||
|
settings, err := h.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[webhook] failed to read settings: %v", err)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.WebhookSecret == "" || settings.WebhookSecret != secret {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the request body.
|
||||||
|
var payload Payload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid JSON payload"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Image == "" {
|
||||||
|
http.Error(w, `{"error":"missing image field"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseImageRef(payload.Image)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default tag to "latest" if omitted.
|
||||||
|
if parsed.Tag == "" {
|
||||||
|
parsed.Tag = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[webhook] received push for image %s:%s", parsed.FullName(), parsed.Tag)
|
||||||
|
|
||||||
|
// Look up a matching project by image name.
|
||||||
|
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[webhook] lookup error: %v", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
// Unknown project — auto-create with defaults from image inspection.
|
||||||
|
log.Printf("[webhook] unknown image %s, auto-creating project", parsed.FullName())
|
||||||
|
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[webhook] auto-create failed: %v", err)
|
||||||
|
http.Error(w, `{"error":"failed to auto-create project"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[webhook] auto-created project %s (%s) with stage %s", project.Name, project.ID, stage.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only deploy if auto_deploy is enabled for the matched stage.
|
||||||
|
if !stage.AutoDeploy {
|
||||||
|
log.Printf("[webhook] auto_deploy disabled for project %s stage %s, skipping deploy", project.Name, stage.Name)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `{"status":"accepted","deploy":false,"project":"%s","stage":"%s"}`, project.Name, stage.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
||||||
|
log.Printf("[webhook] deploy trigger failed: %v", err)
|
||||||
|
http.Error(w, `{"error":"deploy trigger failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[webhook] triggered deploy for project %s stage %s tag %s", project.Name, stage.Name, parsed.Tag)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `{"status":"accepted","deploy":true,"project":"%s","stage":"%s","tag":"%s"}`, project.Name, stage.Name, parsed.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureWebhookSecret checks whether a webhook secret exists in settings.
|
||||||
|
// If not, it generates a new UUID and stores it. Returns the current secret.
|
||||||
|
func EnsureWebhookSecret(st *store.Store) (string, error) {
|
||||||
|
settings, err := st.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.WebhookSecret != "" {
|
||||||
|
return settings.WebhookSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.WebhookSecret = uuid.New().String()
|
||||||
|
if err := st.UpdateSettings(settings); err != nil {
|
||||||
|
return "", fmt.Errorf("store webhook secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[webhook] generated new webhook secret")
|
||||||
|
return settings.WebhookSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and
|
||||||
|
// invalidating the old one. Returns the new secret.
|
||||||
|
func RegenerateWebhookSecret(st *store.Store) (string, error) {
|
||||||
|
settings, err := st.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.WebhookSecret = uuid.New().String()
|
||||||
|
if err := st.UpdateSettings(settings); err != nil {
|
||||||
|
return "", fmt.Errorf("store webhook secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[webhook] regenerated webhook secret")
|
||||||
|
return settings.WebhookSecret, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindProjectAndStage searches for a project whose image matches the parsed
|
||||||
|
// image reference, then finds the stage whose tag pattern matches the incoming
|
||||||
|
// tag. Returns (project, stage, found, error).
|
||||||
|
//
|
||||||
|
// Matching logic:
|
||||||
|
// 1. Iterate all projects.
|
||||||
|
// 2. Compare the project's Image field against the parsed image's FullName().
|
||||||
|
// 3. For the matched project, iterate its stages and find one whose TagPattern
|
||||||
|
// matches the incoming tag using path.Match (glob semantics).
|
||||||
|
// 4. If multiple stages match, the first match wins (stages are ordered by name).
|
||||||
|
func FindProjectAndStage(ctx context.Context, st *store.Store, parsed ParsedImage) (store.Project, store.Stage, bool, error) {
|
||||||
|
projects, err := st.GetAllProjects()
|
||||||
|
if err != nil {
|
||||||
|
return store.Project{}, store.Stage{}, false, fmt.Errorf("get projects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imageName := parsed.FullName()
|
||||||
|
|
||||||
|
for _, project := range projects {
|
||||||
|
if !imageMatches(project.Image, imageName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stage, found, err := matchStage(st, project.ID, parsed.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return store.Project{}, store.Stage{}, false, fmt.Errorf("match stage for project %s: %w", project.Name, err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return project, stage, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project matches but no stage pattern matches this tag.
|
||||||
|
// Return project with empty stage — caller can decide what to do.
|
||||||
|
// For now, we treat it as "not found" so auto-create doesn't fire
|
||||||
|
// for known projects with no matching stage.
|
||||||
|
return store.Project{}, store.Stage{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.Project{}, store.Stage{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageMatches checks if a project's stored image name matches the parsed
|
||||||
|
// image name. The comparison is case-sensitive and supports the project image
|
||||||
|
// being stored as either "owner/name" or just "name".
|
||||||
|
func imageMatches(projectImage, incomingImage string) bool {
|
||||||
|
if projectImage == incomingImage {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Also match if the incoming image has an owner prefix but the project
|
||||||
|
// only stores the bare name (or vice versa). This handles registries
|
||||||
|
// that include or omit the owner segment.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchStage finds the first stage of a project whose tag pattern matches the
|
||||||
|
// given tag. Uses path.Match for glob-style matching (same as the registry poller).
|
||||||
|
func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) {
|
||||||
|
stages, err := st.GetStagesByProjectID(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return store.Stage{}, false, fmt.Errorf("get stages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stage := range stages {
|
||||||
|
pattern := stage.TagPattern
|
||||||
|
if pattern == "" {
|
||||||
|
pattern = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, err := path.Match(pattern, tag)
|
||||||
|
if err != nil {
|
||||||
|
// Invalid pattern — skip this stage.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return stage, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.Stage{}, false, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 6: Webhook Handler
|
# Phase 6: Webhook Handler
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
|
|
||||||
@@ -9,14 +9,14 @@ Implement the secret UUID-based webhook endpoint that receives image push notifi
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Implement webhook HTTP handler — `POST /api/webhook/:secret-uuid`
|
- [x] Task 1: Implement webhook HTTP handler — `POST /api/webhook/:secret-uuid`
|
||||||
- [ ] Task 2: Validate incoming payload — extract image name and tag
|
- [x] Task 2: Validate incoming payload — extract image name and tag
|
||||||
- [ ] Task 3: Look up project by image name in store — match against configured project images
|
- [x] Task 3: Look up project by image name in store — match against configured project images
|
||||||
- [ ] Task 4: If known project: match tag to stage via tag patterns, determine if auto_deploy
|
- [x] Task 4: If known project: match tag to stage via tag patterns, determine if auto_deploy
|
||||||
- [ ] Task 5: If unknown project: auto-create project with defaults from image inspection (EXPOSE port, labels)
|
- [x] Task 5: If unknown project: auto-create project with defaults from image inspection (EXPOSE port, labels)
|
||||||
- [ ] Task 6: Generate and store webhook secret UUID in settings (on first launch)
|
- [x] Task 6: Generate and store webhook secret UUID in settings (on first launch)
|
||||||
- [ ] Task 7: Implement webhook URL regeneration (new UUID, invalidates old one)
|
- [x] Task 7: Implement webhook URL regeneration (new UUID, invalidates old one)
|
||||||
- [ ] Task 8: Define webhook payload struct (`{"image": "registry/org/app:tag"}`)
|
- [x] Task 8: Define webhook payload struct (`{"image": "registry/org/app:tag"}`)
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `internal/webhook/handler.go` — webhook HTTP handler + payload parsing
|
- `internal/webhook/handler.go` — webhook HTTP handler + payload parsing
|
||||||
@@ -38,11 +38,41 @@ Implement the secret UUID-based webhook endpoint that receives image push notifi
|
|||||||
- Keep the handler thin — it matches and delegates
|
- Keep the handler thin — it matches and delegates
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- [ ] All tasks completed
|
- [x] All tasks completed
|
||||||
- [ ] No information leak on invalid UUIDs
|
- [x] No information leak on invalid UUIDs
|
||||||
- [ ] Payload validation rejects malformed input
|
- [x] Payload validation rejects malformed input
|
||||||
- [ ] Auto-creation uses safe defaults
|
- [x] Auto-creation uses safe defaults
|
||||||
- [ ] Handler is stateless (delegates to store/deployer)
|
- [x] Handler is stateless (delegates to store/deployer)
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
|
||||||
|
### Exported API
|
||||||
|
|
||||||
|
- `webhook.NewHandler(store, deployer, inspector)` — creates the HTTP handler
|
||||||
|
- `webhook.Handler.Route()` — returns a `chi.Router` to mount at `/api/webhook`
|
||||||
|
- `webhook.EnsureWebhookSecret(store)` — generates UUID on first launch, returns current secret
|
||||||
|
- `webhook.RegenerateWebhookSecret(store)` — replaces secret with new UUID, invalidates old one
|
||||||
|
- `webhook.ParseImageRef(ref)` — parses `registry/owner/name:tag` into components
|
||||||
|
|
||||||
|
### Interfaces Defined
|
||||||
|
|
||||||
|
- `webhook.DeployTriggerer` — `TriggerDeploy(ctx, projectID, stageID, imageTag) error` (mirrors `registry.DeployTriggerer`)
|
||||||
|
- `webhook.ImageInspector` — `InspectImage(ctx, imageRef) (docker.ImageInfo, error)` (wraps `docker.Client`)
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
- Mount the webhook router: `r.Mount("/api/webhook", webhookHandler.Route())`
|
||||||
|
- Call `webhook.EnsureWebhookSecret(store)` at application startup to generate the secret on first launch
|
||||||
|
- The deployer must implement `webhook.DeployTriggerer` (same signature as `registry.DeployTriggerer`)
|
||||||
|
- The Docker client (`*docker.Client`) satisfies `webhook.ImageInspector` directly
|
||||||
|
|
||||||
|
### Auto-Create Behavior
|
||||||
|
|
||||||
|
- Unknown images create a project with name from image name, port from EXPOSE, healthcheck from image metadata
|
||||||
|
- A default "dev" stage is created with `tag_pattern: "*"`, `auto_deploy: true`, `max_instances: 1`
|
||||||
|
- If image inspection fails (not pulled locally), project is created with port=0 and empty healthcheck
|
||||||
|
|
||||||
|
### Tag Matching
|
||||||
|
|
||||||
|
- Uses `path.Match` (glob semantics) — same approach as the registry poller
|
||||||
|
- Stages are checked in name-sorted order; first matching stage wins
|
||||||
|
|||||||
Reference in New Issue
Block a user