// Package registry implements the "registry" trigger: matches inbound image // push events from container registries (Docker Hub, Gitea, ghcr, generic // webhooks, polling) against a repo + tag-pattern filter. package registry import ( "context" "encoding/json" "fmt" "path" "strings" "time" "github.com/alexei/tinyforge/internal/workload/plugin" ) // Config is the per-workload trigger config blob. Image is the // fully-qualified image reference the workload deploys (e.g. // "registry.example.com/owner/app"); a push of any matching tag fires a // deploy. TagPattern is a path.Match glob ("*" matches all). type Config struct { Image string `json:"image"` TagPattern string `json:"tag_pattern"` } type trigger struct{} func init() { plugin.RegisterTrigger(&trigger{}) } func (*trigger) Kind() string { return "registry" } func (*trigger) SchemaSample() any { return Config{ Image: "registry.example.com/owner/app", TagPattern: "v*", } } func (*trigger) Validate(cfg json.RawMessage) error { var c Config if len(cfg) == 0 { return fmt.Errorf("registry trigger: config is required") } if err := json.Unmarshal(cfg, &c); err != nil { return fmt.Errorf("registry trigger: invalid json: %w", err) } if strings.TrimSpace(c.Image) == "" { return fmt.Errorf("registry trigger: image is required") } pattern := c.TagPattern if pattern == "" { pattern = "*" } if _, err := path.Match(pattern, "probe"); err != nil { return fmt.Errorf("registry trigger: invalid tag_pattern %q: %w", pattern, err) } return nil } func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) { if evt.Kind != "image-push" || evt.Image == nil { return nil, nil } cfg, err := plugin.TriggerConfigOf[Config](w) if err != nil { return nil, fmt.Errorf("registry trigger: decode config: %w", err) } if !imageMatches(cfg.Image, fullRepo(evt.Image)) { return nil, nil } pattern := cfg.TagPattern if pattern == "" { pattern = "*" } matched, err := path.Match(pattern, evt.Image.Tag) if err != nil || !matched { return nil, nil } return &plugin.DeploymentIntent{ Reason: "registry-push", Reference: evt.Image.Tag, Metadata: map[string]string{"digest": evt.Image.Digest, "repo": evt.Image.Repo}, TriggeredAt: time.Now().UTC(), TriggeredBy: "registry-webhook", }, nil } func fullRepo(e *plugin.ImagePushEvent) string { if e.Registry == "" { return e.Repo } return e.Registry + "/" + e.Repo } // imageMatches: registry host case-insensitive, path/owner/name exact. // Single-segment refs (e.g. Docker Hub officials like "nginx") have no // `/` and match by exact equality of the bare name. func imageMatches(want, got string) bool { if want == got { return true } wIdx := strings.IndexByte(want, '/') gIdx := strings.IndexByte(got, '/') // Both single-segment: equality already failed above, so no match. if wIdx < 0 && gIdx < 0 { return false } // One side single-segment, the other qualified — does not match. if wIdx < 0 || gIdx < 0 { return false } wHost, wPath := want[:wIdx], want[wIdx:] gHost, gPath := got[:gIdx], got[gIdx:] return strings.EqualFold(wHost, gHost) && wPath == gPath }