410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
419 lines
13 KiB
Go
419 lines
13 KiB
Go
package webhook
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
|
)
|
|
|
|
// Vendor headers we recognize for short-circuit dispatch. The header
|
|
// presence (not just value) is the trigger — vendors version event names
|
|
// over time so matching on value alone is brittle.
|
|
const (
|
|
headerGiteaEvent = "X-Gitea-Event"
|
|
headerGiteaDelivery = "X-Gitea-Delivery"
|
|
headerGitHubEvent = "X-GitHub-Event"
|
|
headerGitLabEvent = "X-Gitlab-Event"
|
|
)
|
|
|
|
// vendorParseResult bundles the parsed event with an "ok" flag distinct
|
|
// from the error: ok=false means "this parser doesn't apply to the
|
|
// request, try the next one"; ok=true with err!=nil means "this parser
|
|
// applied but the payload was malformed."
|
|
type vendorParseResult struct {
|
|
event plugin.InboundEvent
|
|
ok bool
|
|
err error
|
|
}
|
|
|
|
// tryVendorParsers runs each vendor-specific parser in order and returns
|
|
// the first one that recognizes the payload. ok=false means no vendor
|
|
// parser claimed the request — caller should fall back to the generic
|
|
// simple-body parser.
|
|
func tryVendorParsers(body []byte, headers http.Header) vendorParseResult {
|
|
for _, fn := range []func([]byte, http.Header) vendorParseResult{
|
|
parseGiteaPackageEvent,
|
|
parseGitHubPackageEvent,
|
|
parseGitHubPushEvent,
|
|
parseGiteaPushEvent,
|
|
parseGitLabPushEvent,
|
|
} {
|
|
res := fn(body, headers)
|
|
if res.ok {
|
|
return res
|
|
}
|
|
}
|
|
return vendorParseResult{}
|
|
}
|
|
|
|
// parseGiteaPackageEvent recognizes Gitea container-registry package
|
|
// pushes. Distinguishing fingerprint: X-Gitea-Event: package and a body
|
|
// with package.type == "container".
|
|
//
|
|
// Reference: https://docs.gitea.com/usage/webhooks
|
|
//
|
|
// Body excerpt:
|
|
//
|
|
// {
|
|
// "action": "created",
|
|
// "package": {
|
|
// "name": "my-app",
|
|
// "type": "container",
|
|
// "version": "v1.2.3",
|
|
// "owner": {"login": "alexei"},
|
|
// "repository": {"full_name": "alexei/my-app"}
|
|
// }
|
|
// }
|
|
func parseGiteaPackageEvent(body []byte, headers http.Header) vendorParseResult {
|
|
if headers.Get(headerGiteaEvent) != "package" {
|
|
return vendorParseResult{}
|
|
}
|
|
var probe struct {
|
|
Action string `json:"action"`
|
|
Package struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Version string `json:"version"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
Username string `json:"username"`
|
|
} `json:"owner"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
} `json:"repository"`
|
|
} `json:"package"`
|
|
Sender struct {
|
|
Login string `json:"login"`
|
|
Username string `json:"username"`
|
|
} `json:"sender"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("invalid Gitea package JSON")}
|
|
}
|
|
if !strings.EqualFold(probe.Package.Type, "container") {
|
|
// Non-container package (npm, maven, generic, etc.) — Tinyforge
|
|
// only deploys containers today, so drop these as not-applicable.
|
|
// Return ok=true so we don't fall through to the simple-body
|
|
// parser and accidentally pick up the "version" field as a ref.
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package type %q is not a container", probe.Package.Type)}
|
|
}
|
|
if probe.Package.Name == "" || probe.Package.Version == "" {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package event missing name/version")}
|
|
}
|
|
owner := probe.Package.Owner.Login
|
|
if owner == "" {
|
|
owner = probe.Package.Owner.Username
|
|
}
|
|
repo := probe.Package.Name
|
|
if owner != "" {
|
|
repo = owner + "/" + probe.Package.Name
|
|
}
|
|
registry := registryHostFromGiteaRepoURL(probe.Package.Repository.HTMLURL)
|
|
|
|
evt := plugin.InboundEvent{
|
|
Kind: "image-push",
|
|
Image: &plugin.ImagePushEvent{
|
|
Registry: registry,
|
|
Repo: repo,
|
|
Tag: probe.Package.Version,
|
|
},
|
|
}
|
|
return vendorParseResult{event: evt, ok: true}
|
|
}
|
|
|
|
// registryHostFromGiteaRepoURL extracts the host portion of a Gitea
|
|
// repository URL. Gitea container registry lives on the same host, so
|
|
// the repo URL host is a reliable proxy for the registry host.
|
|
func registryHostFromGiteaRepoURL(repoURL string) string {
|
|
if repoURL == "" {
|
|
return ""
|
|
}
|
|
// Strip scheme.
|
|
if i := strings.Index(repoURL, "://"); i >= 0 {
|
|
repoURL = repoURL[i+3:]
|
|
}
|
|
if i := strings.Index(repoURL, "/"); i >= 0 {
|
|
repoURL = repoURL[:i]
|
|
}
|
|
return repoURL
|
|
}
|
|
|
|
// parseGitHubPackageEvent recognizes GitHub Container Registry (ghcr.io)
|
|
// publish events. Two event names exist depending on the year the webhook
|
|
// was registered: "package" and "registry_package". Both share the same
|
|
// body shape under registry_package.
|
|
//
|
|
// Reference:
|
|
// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#package
|
|
// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
|
//
|
|
// Body excerpt:
|
|
//
|
|
// {
|
|
// "action": "published",
|
|
// "registry_package": {
|
|
// "name": "my-app",
|
|
// "namespace": "owner",
|
|
// "package_type": "CONTAINER",
|
|
// "package_version": {
|
|
// "container_metadata": { "tag": { "name": "v1.2.3" } },
|
|
// "name": "sha256:..."
|
|
// },
|
|
// "registry": { "url": "https://ghcr.io/..." }
|
|
// }
|
|
// }
|
|
func parseGitHubPackageEvent(body []byte, headers http.Header) vendorParseResult {
|
|
ev := headers.Get(headerGitHubEvent)
|
|
if ev != "package" && ev != "registry_package" {
|
|
return vendorParseResult{}
|
|
}
|
|
// GitHub sends the body under either "package" or "registry_package"
|
|
// depending on event name; tolerate both by probing for whichever is
|
|
// populated.
|
|
var probe struct {
|
|
Action string `json:"action"`
|
|
Package json.RawMessage `json:"package"`
|
|
RegistryPackage json.RawMessage `json:"registry_package"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package JSON")}
|
|
}
|
|
raw := probe.RegistryPackage
|
|
if len(raw) == 0 {
|
|
raw = probe.Package
|
|
}
|
|
if len(raw) == 0 {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing package body")}
|
|
}
|
|
var pkg struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
PackageType string `json:"package_type"`
|
|
PackageVersion struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
ContainerMetadata struct {
|
|
Tag struct {
|
|
Name string `json:"name"`
|
|
} `json:"tag"`
|
|
} `json:"container_metadata"`
|
|
} `json:"package_version"`
|
|
Registry struct {
|
|
URL string `json:"url"`
|
|
} `json:"registry"`
|
|
}
|
|
if err := json.Unmarshal(raw, &pkg); err != nil {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package payload")}
|
|
}
|
|
if !strings.EqualFold(pkg.PackageType, "CONTAINER") && !strings.EqualFold(pkg.PackageType, "container") {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package type %q is not a container", pkg.PackageType)}
|
|
}
|
|
tag := pkg.PackageVersion.ContainerMetadata.Tag.Name
|
|
digest := ""
|
|
// PackageVersion.Name is "sha256:..." for container versions.
|
|
if strings.HasPrefix(pkg.PackageVersion.Name, "sha256:") {
|
|
digest = pkg.PackageVersion.Name
|
|
}
|
|
if tag == "" && digest == "" {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing tag and digest")}
|
|
}
|
|
repo := pkg.Name
|
|
if pkg.Namespace != "" {
|
|
repo = pkg.Namespace + "/" + pkg.Name
|
|
}
|
|
evt := plugin.InboundEvent{
|
|
Kind: "image-push",
|
|
Image: &plugin.ImagePushEvent{
|
|
Registry: registryHostFromGitHubRegistry(pkg.Registry.URL),
|
|
Repo: repo,
|
|
Tag: tag,
|
|
Digest: digest,
|
|
},
|
|
}
|
|
return vendorParseResult{event: evt, ok: true}
|
|
}
|
|
|
|
// registryHostFromGitHubRegistry pulls the host from a GitHub registry
|
|
// URL ("https://ghcr.io/..." → "ghcr.io"). Defaults to "ghcr.io" since
|
|
// that's the only host GitHub uses for container packages today.
|
|
func registryHostFromGitHubRegistry(rawURL string) string {
|
|
host := registryHostFromGiteaRepoURL(rawURL) // same scheme/host stripping
|
|
if host == "" {
|
|
return "ghcr.io"
|
|
}
|
|
return host
|
|
}
|
|
|
|
// parseGitHubPushEvent handles the standard GitHub push event with a
|
|
// vendor annotation. The generic parser already covers the body shape
|
|
// (ref / after / repository.full_name / pusher.name) — this exists so
|
|
// the resulting GitEvent.Vendor is populated and trigger plugins can
|
|
// distinguish GitHub from Gitea without re-parsing headers.
|
|
func parseGitHubPushEvent(body []byte, headers http.Header) vendorParseResult {
|
|
if headers.Get(headerGitHubEvent) != "push" {
|
|
return vendorParseResult{}
|
|
}
|
|
evt, err := parseGenericGitPush(body)
|
|
if err != nil {
|
|
return vendorParseResult{ok: true, err: err}
|
|
}
|
|
if evt.Git != nil {
|
|
evt.Git.Vendor = "github"
|
|
}
|
|
return vendorParseResult{event: evt, ok: true}
|
|
}
|
|
|
|
// parseGiteaPushEvent — same idea, for Gitea. Gitea push events share
|
|
// shape with GitHub's (Gitea explicitly modeled them).
|
|
func parseGiteaPushEvent(body []byte, headers http.Header) vendorParseResult {
|
|
if headers.Get(headerGiteaEvent) != "push" {
|
|
return vendorParseResult{}
|
|
}
|
|
evt, err := parseGenericGitPush(body)
|
|
if err != nil {
|
|
return vendorParseResult{ok: true, err: err}
|
|
}
|
|
if evt.Git != nil {
|
|
evt.Git.Vendor = "gitea"
|
|
}
|
|
return vendorParseResult{event: evt, ok: true}
|
|
}
|
|
|
|
// parseGitLabPushEvent handles GitLab's push event, which uses a
|
|
// different body shape than GitHub/Gitea:
|
|
//
|
|
// {
|
|
// "ref": "refs/heads/main",
|
|
// "after": "abc123",
|
|
// "user_username": "alice",
|
|
// "project": { "path_with_namespace": "owner/repo" }
|
|
// }
|
|
//
|
|
// Reference: https://docs.gitlab.com/user/project/integrations/webhook_events/
|
|
func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult {
|
|
ev := headers.Get(headerGitLabEvent)
|
|
if ev != "Push Hook" && ev != "Tag Push Hook" {
|
|
return vendorParseResult{}
|
|
}
|
|
var probe struct {
|
|
Ref string `json:"ref"`
|
|
After string `json:"after"`
|
|
UserName string `json:"user_username"`
|
|
UserNameAlt string `json:"user_name"`
|
|
Project struct {
|
|
PathWithNamespace string `json:"path_with_namespace"`
|
|
GitHTTPURL string `json:"git_http_url"`
|
|
} `json:"project"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitLab push JSON")}
|
|
}
|
|
if probe.Ref == "" {
|
|
return vendorParseResult{ok: true, err: fmt.Errorf("GitLab push event missing ref")}
|
|
}
|
|
pusher := probe.UserName
|
|
if pusher == "" {
|
|
pusher = probe.UserNameAlt
|
|
}
|
|
evt := plugin.InboundEvent{
|
|
Kind: "git-push",
|
|
Git: &plugin.GitEvent{
|
|
Vendor: "gitlab",
|
|
Repo: probe.Project.PathWithNamespace,
|
|
Ref: probe.Ref,
|
|
CommitSHA: probe.After,
|
|
Pusher: pusher,
|
|
// GitLab does not emit `deleted: true`; the canonical signal
|
|
// is an all-zero `after` SHA. Same parser helper used for the
|
|
// GitHub / Gitea fallback so the two branches agree.
|
|
Deleted: isZeroSHA(probe.After),
|
|
},
|
|
}
|
|
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
|
evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
|
|
}
|
|
if strings.HasPrefix(probe.Ref, "refs/tags/") {
|
|
evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
|
|
evt.Kind = "git-tag"
|
|
}
|
|
return vendorParseResult{event: evt, ok: true}
|
|
}
|
|
|
|
// parseGenericGitPush extracts a GitEvent from a GitHub/Gitea-style push
|
|
// body. Shared by the GitHub and Gitea vendor wrappers and by the
|
|
// generic fallback in buildInboundEvent.
|
|
func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
|
var probe struct {
|
|
Ref string `json:"ref"`
|
|
After string `json:"after"`
|
|
Deleted bool `json:"deleted"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
CloneURL string `json:"clone_url"`
|
|
} `json:"repository"`
|
|
Repo string `json:"repo"`
|
|
Pusher struct {
|
|
Name string `json:"name"`
|
|
Username string `json:"username"`
|
|
} `json:"pusher"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
return plugin.InboundEvent{}, fmt.Errorf("invalid git push JSON")
|
|
}
|
|
if probe.Ref == "" {
|
|
return plugin.InboundEvent{}, fmt.Errorf("git push event missing ref")
|
|
}
|
|
repo := probe.Repository.FullName
|
|
if repo == "" {
|
|
repo = probe.Repo
|
|
}
|
|
pusher := probe.Pusher.Name
|
|
if pusher == "" {
|
|
pusher = probe.Pusher.Username
|
|
}
|
|
// Branch / tag deletion is signalled either by the explicit
|
|
// `deleted: true` flag (GitHub / Gitea) or by an all-zero `after`
|
|
// SHA (older shapes). Both are honoured so the preview-deploy flow
|
|
// can tear down ephemeral workloads even when a vendor omits the
|
|
// boolean flag.
|
|
deleted := probe.Deleted || isZeroSHA(probe.After)
|
|
evt := plugin.InboundEvent{
|
|
Kind: "git-push",
|
|
Git: &plugin.GitEvent{
|
|
Repo: repo,
|
|
Ref: probe.Ref,
|
|
CommitSHA: probe.After,
|
|
Pusher: pusher,
|
|
Deleted: deleted,
|
|
},
|
|
}
|
|
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
|
evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
|
|
}
|
|
if strings.HasPrefix(probe.Ref, "refs/tags/") {
|
|
evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
|
|
evt.Kind = "git-tag"
|
|
}
|
|
return evt, nil
|
|
}
|
|
|
|
// isZeroSHA returns true when sha is the canonical "no commit" sentinel
|
|
// (40 zeros) that vendors emit on the `after` field of a branch- or
|
|
// tag-delete push event. Length-tolerant because some test fixtures
|
|
// truncate the SHA.
|
|
func isZeroSHA(sha string) bool {
|
|
if sha == "" {
|
|
return false
|
|
}
|
|
for _, r := range sha {
|
|
if r != '0' {
|
|
return false
|
|
}
|
|
}
|
|
return len(sha) >= 7
|
|
}
|