0405ecd9ce
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
778 lines
25 KiB
Go
778 lines
25 KiB
Go
package staticsite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/moby/moby/api/types/mount"
|
|
|
|
"github.com/alexei/tinyforge/internal/crypto"
|
|
"github.com/alexei/tinyforge/internal/docker"
|
|
"github.com/alexei/tinyforge/internal/events"
|
|
"github.com/alexei/tinyforge/internal/notify"
|
|
"github.com/alexei/tinyforge/internal/proxy"
|
|
"github.com/alexei/tinyforge/internal/staticsite/deno"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// Manager orchestrates the static site deployment pipeline.
|
|
type Manager struct {
|
|
store *store.Store
|
|
docker *docker.Client
|
|
proxyProvider proxy.Provider
|
|
eventBus *events.Bus
|
|
notifier *notify.Notifier
|
|
encKey [32]byte
|
|
}
|
|
|
|
// NewManager creates a new static site manager.
|
|
func NewManager(
|
|
st *store.Store,
|
|
dockerClient *docker.Client,
|
|
proxyProvider proxy.Provider,
|
|
eventBus *events.Bus,
|
|
notifier *notify.Notifier,
|
|
encKey [32]byte,
|
|
) *Manager {
|
|
return &Manager{
|
|
store: st,
|
|
docker: dockerClient,
|
|
proxyProvider: proxyProvider,
|
|
eventBus: eventBus,
|
|
notifier: notifier,
|
|
encKey: encKey,
|
|
}
|
|
}
|
|
|
|
// SetProxyProvider updates the proxy provider at runtime.
|
|
func (m *Manager) SetProxyProvider(provider proxy.Provider) {
|
|
m.proxyProvider = provider
|
|
}
|
|
|
|
// Deploy fetches content from Gitea and deploys a static site container.
|
|
// If force is true, skips the "no changes" check and always rebuilds/redeploys.
|
|
func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
|
site, err := m.store.GetStaticSiteByID(siteID)
|
|
if err != nil {
|
|
return fmt.Errorf("get site: %w", err)
|
|
}
|
|
|
|
// Decrypt access token if present.
|
|
token := ""
|
|
if site.AccessToken != "" {
|
|
decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken)
|
|
if err != nil {
|
|
slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err)
|
|
} else {
|
|
token = decrypted
|
|
}
|
|
}
|
|
|
|
provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token)
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err))
|
|
return fmt.Errorf("create provider: %w", err)
|
|
}
|
|
|
|
// Check if there's a new commit.
|
|
latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch)
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err))
|
|
return fmt.Errorf("get latest commit: %w", err)
|
|
}
|
|
|
|
// Skip redeploy only if SHA matches, status is deployed, container is running,
|
|
// proxy route exists, AND force is false. Manual deploys always force a full rebuild.
|
|
if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" {
|
|
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
|
if !running {
|
|
slog.Info("static site: container not running, forcing redeploy", "site", site.Name)
|
|
} else if site.Domain != "" {
|
|
// Also verify the proxy route still exists (it may have been deleted externally).
|
|
proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain)
|
|
if err != nil {
|
|
slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err)
|
|
} else if !proxyOK {
|
|
slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name)
|
|
} else {
|
|
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
|
return nil
|
|
}
|
|
} else {
|
|
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Update status to syncing.
|
|
m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "")
|
|
m.publishEvent(site.ID, site.Name, "syncing")
|
|
|
|
// Create temp directory for the build context.
|
|
buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*")
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err))
|
|
return fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(buildDir)
|
|
|
|
// Download folder contents.
|
|
if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil {
|
|
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err))
|
|
return fmt.Errorf("download folder: %w", err)
|
|
}
|
|
|
|
// Render markdown if enabled.
|
|
if site.RenderMarkdown {
|
|
if err := RenderMarkdownFiles(buildDir); err != nil {
|
|
slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Determine mode: check for api/ subdirectory.
|
|
mode := site.Mode
|
|
apiDir := filepath.Join(buildDir, "api")
|
|
hasAPI := false
|
|
if info, err := os.Stat(apiDir); err == nil && info.IsDir() {
|
|
hasAPI = true
|
|
}
|
|
if mode == "deno" && !hasAPI {
|
|
// Fallback to static if no api/ folder found.
|
|
mode = "static"
|
|
slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name)
|
|
}
|
|
|
|
// Prepare build context based on mode.
|
|
imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name)
|
|
contextDir, err := os.MkdirTemp("", "dw-site-build-*")
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err))
|
|
return fmt.Errorf("create build context dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(contextDir)
|
|
|
|
if mode == "deno" {
|
|
if err := m.prepareDenoBuild(buildDir, contextDir); err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err))
|
|
return fmt.Errorf("prepare deno build: %w", err)
|
|
}
|
|
} else {
|
|
if err := m.prepareStaticBuild(buildDir, contextDir); err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err))
|
|
return fmt.Errorf("prepare static build: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build Docker image.
|
|
if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err))
|
|
return fmt.Errorf("build image: %w", err)
|
|
}
|
|
|
|
// Prepare environment variables (secrets).
|
|
env, err := m.buildEnvVars(site.ID)
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err))
|
|
return fmt.Errorf("build env vars: %w", err)
|
|
}
|
|
|
|
// Determine container port.
|
|
containerPort := "80"
|
|
if mode == "deno" {
|
|
containerPort = "8000"
|
|
}
|
|
|
|
// Get network settings.
|
|
settings, err := m.store.GetSettings()
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err))
|
|
return fmt.Errorf("get settings: %w", err)
|
|
}
|
|
|
|
networkName := settings.Network
|
|
networkID, err := m.docker.EnsureNetwork(ctx, networkName)
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err))
|
|
return fmt.Errorf("ensure network: %w", err)
|
|
}
|
|
|
|
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
|
|
|
// Prepare volume mounts for persistent storage.
|
|
var mounts []mount.Mount
|
|
if site.StorageEnabled && mode == "deno" {
|
|
volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name)
|
|
if volErr != nil {
|
|
slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr)
|
|
} else {
|
|
mounts = append(mounts, mount.Mount{
|
|
Type: mount.TypeVolume,
|
|
Source: volName,
|
|
Target: "/app/data",
|
|
})
|
|
slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName)
|
|
}
|
|
}
|
|
|
|
// Create and start new container.
|
|
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
|
Name: containerName,
|
|
Image: imageTag,
|
|
Env: env,
|
|
ExposedPorts: []string{containerPort + "/tcp"},
|
|
NetworkName: networkName,
|
|
NetworkID: networkID,
|
|
Mounts: mounts,
|
|
Labels: map[string]string{
|
|
"tinyforge.static-site": site.ID,
|
|
"tinyforge.static-site-name": site.Name,
|
|
},
|
|
Project: "static-site",
|
|
Stage: site.Name,
|
|
})
|
|
if err != nil {
|
|
// Container might already exist — try to remove and recreate.
|
|
if site.ContainerID != "" {
|
|
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
|
}
|
|
// Also try by name.
|
|
m.removeContainerByName(ctx, containerName)
|
|
|
|
containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
|
Name: containerName,
|
|
Image: imageTag,
|
|
Env: env,
|
|
ExposedPorts: []string{containerPort + "/tcp"},
|
|
NetworkName: networkName,
|
|
NetworkID: networkID,
|
|
Mounts: mounts,
|
|
Labels: map[string]string{
|
|
"tinyforge.static-site": site.ID,
|
|
"tinyforge.static-site-name": site.Name,
|
|
},
|
|
Project: "static-site",
|
|
Stage: site.Name,
|
|
})
|
|
if err != nil {
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err))
|
|
return fmt.Errorf("create container: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := m.docker.StartContainer(ctx, containerID); err != nil {
|
|
m.docker.RemoveContainer(ctx, containerID, true)
|
|
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err))
|
|
return fmt.Errorf("start container: %w", err)
|
|
}
|
|
|
|
// Brief health check: wait 3 seconds and verify container is still running.
|
|
time.Sleep(3 * time.Second)
|
|
running, err := m.docker.IsContainerRunning(ctx, containerID)
|
|
if err != nil || !running {
|
|
// Grab container logs for the error message.
|
|
logMsg := "container exited immediately after start"
|
|
if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil {
|
|
buf, _ := io.ReadAll(logs)
|
|
logs.Close()
|
|
if len(buf) > 0 {
|
|
logMsg = string(buf)
|
|
// Truncate to reasonable length.
|
|
if len(logMsg) > 500 {
|
|
logMsg = logMsg[:500] + "..."
|
|
}
|
|
}
|
|
}
|
|
m.docker.RemoveContainer(ctx, containerID, true)
|
|
m.updateStatus(site.ID, "failed", latestSHA, logMsg)
|
|
return fmt.Errorf("container not running: %s", logMsg)
|
|
}
|
|
|
|
// Determine proxy target: container name + internal port (default),
|
|
// or server IP + host port for NPM remote mode.
|
|
internalPort, _ := strconv.Atoi(containerPort)
|
|
forwardHost := containerName
|
|
forwardPort := internalPort
|
|
|
|
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
|
if settings.ServerIP != "" {
|
|
hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp")
|
|
if err != nil {
|
|
slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err)
|
|
} else {
|
|
forwardHost = settings.ServerIP
|
|
forwardPort = int(hostPort)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Configure proxy if domain is set.
|
|
proxyRouteID := site.ProxyRouteID
|
|
if site.Domain != "" {
|
|
// Remove old proxy route if exists.
|
|
if site.ProxyRouteID != "" {
|
|
m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID)
|
|
}
|
|
|
|
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
|
SSLCertificateID: settings.SSLCertificateID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err)
|
|
} else {
|
|
proxyRouteID = routeID
|
|
slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID)
|
|
}
|
|
}
|
|
|
|
// Remove old container if different.
|
|
if site.ContainerID != "" && site.ContainerID != containerID {
|
|
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
|
}
|
|
|
|
// Update site status.
|
|
if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil {
|
|
slog.Error("static site: failed to update container info", "site", site.Name, "error", err)
|
|
}
|
|
m.updateStatus(site.ID, "deployed", latestSHA, "")
|
|
m.publishEvent(site.ID, site.Name, "deployed")
|
|
|
|
slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode)
|
|
return nil
|
|
}
|
|
|
|
// Remove stops and removes a static site's container and proxy route.
|
|
func (m *Manager) Remove(ctx context.Context, siteID string) error {
|
|
site, err := m.store.GetStaticSiteByID(siteID)
|
|
if err != nil {
|
|
return fmt.Errorf("get site: %w", err)
|
|
}
|
|
|
|
// Remove proxy route (best effort).
|
|
if site.ProxyRouteID != "" {
|
|
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
|
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Stop and remove container (best effort).
|
|
if site.ContainerID != "" {
|
|
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil {
|
|
slog.Warn("static site: failed to remove container", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Remove storage volume if it was enabled (best effort).
|
|
if site.StorageEnabled {
|
|
if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil {
|
|
slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops a running static site container and removes its proxy route.
|
|
// The container is kept (not removed) so Start can bring it back without a full rebuild.
|
|
func (m *Manager) Stop(ctx context.Context, siteID string) error {
|
|
site, err := m.store.GetStaticSiteByID(siteID)
|
|
if err != nil {
|
|
return fmt.Errorf("get site: %w", err)
|
|
}
|
|
|
|
// Remove proxy route first (best effort).
|
|
if site.ProxyRouteID != "" {
|
|
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
|
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Stop container.
|
|
if site.ContainerID != "" {
|
|
if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil {
|
|
slog.Warn("static site: failed to stop container", "site", site.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Clear proxy route ID; keep container ID.
|
|
if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil {
|
|
slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err)
|
|
}
|
|
m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "")
|
|
m.publishEvent(site.ID, site.Name, "stopped")
|
|
|
|
slog.Info("static site stopped", "site", site.Name)
|
|
return nil
|
|
}
|
|
|
|
// Start starts a previously stopped static site container and reconfigures the proxy.
|
|
// If the container no longer exists, it triggers a full redeploy.
|
|
func (m *Manager) Start(ctx context.Context, siteID string) error {
|
|
site, err := m.store.GetStaticSiteByID(siteID)
|
|
if err != nil {
|
|
return fmt.Errorf("get site: %w", err)
|
|
}
|
|
|
|
// If no container exists, do a full deploy.
|
|
if site.ContainerID == "" {
|
|
return m.Deploy(ctx, siteID, true)
|
|
}
|
|
|
|
// Try to start the existing container.
|
|
if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil {
|
|
slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err)
|
|
return m.Deploy(ctx, siteID, true)
|
|
}
|
|
|
|
// Verify it's running after a brief wait.
|
|
time.Sleep(2 * time.Second)
|
|
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
|
if !running {
|
|
return m.Deploy(ctx, siteID, true)
|
|
}
|
|
|
|
// Reconfigure proxy if domain is set.
|
|
settings, err := m.store.GetSettings()
|
|
if err == nil && site.Domain != "" {
|
|
containerPort := "80"
|
|
if site.Mode == "deno" {
|
|
containerPort = "8000"
|
|
}
|
|
internalPort, _ := strconv.Atoi(containerPort)
|
|
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
|
forwardHost := containerName
|
|
forwardPort := internalPort
|
|
|
|
if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" {
|
|
if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil {
|
|
forwardHost = settings.ServerIP
|
|
forwardPort = int(hp)
|
|
}
|
|
}
|
|
|
|
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
|
SSLCertificateID: settings.SSLCertificateID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err)
|
|
} else {
|
|
m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID)
|
|
}
|
|
}
|
|
|
|
m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "")
|
|
m.publishEvent(site.ID, site.Name, "deployed")
|
|
|
|
slog.Info("static site started", "site", site.Name)
|
|
return nil
|
|
}
|
|
|
|
// TestConnection tests connectivity to a Git repository.
|
|
func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error {
|
|
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return provider.TestConnection(ctx, owner, repo)
|
|
}
|
|
|
|
// ListBranches returns branches for a Git repository.
|
|
func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) {
|
|
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return provider.ListBranches(ctx, owner, repo)
|
|
}
|
|
|
|
// ListTree returns the repository tree for the folder picker.
|
|
func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) {
|
|
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return provider.ListTree(ctx, owner, repo, branch)
|
|
}
|
|
|
|
// ListRepos returns repositories from a Git server.
|
|
func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) {
|
|
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return provider.ListRepos(ctx, query)
|
|
}
|
|
|
|
// DetectProvider autodetects the Git provider from a URL, with API probing.
|
|
func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string {
|
|
return string(DetectProviderWithProbe(ctx, baseURL))
|
|
}
|
|
|
|
// createProvider builds a GitProvider from encrypted credentials.
|
|
func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) {
|
|
token := ""
|
|
if accessToken != "" {
|
|
decrypted, err := crypto.Decrypt(m.encKey, accessToken)
|
|
if err != nil {
|
|
token = accessToken // might be plaintext
|
|
} else {
|
|
token = decrypted
|
|
}
|
|
}
|
|
return NewGitProvider(ProviderType(providerType), baseURL, token)
|
|
}
|
|
|
|
// prepareDenoBuild sets up the build context for a Deno container.
|
|
func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error {
|
|
// Move api/ to context.
|
|
apiSrc := filepath.Join(srcDir, "api")
|
|
apiDst := filepath.Join(contextDir, "api")
|
|
if err := os.Rename(apiSrc, apiDst); err != nil {
|
|
return fmt.Errorf("move api dir: %w", err)
|
|
}
|
|
|
|
// Move remaining files to public/.
|
|
publicDir := filepath.Join(contextDir, "public")
|
|
if err := os.Rename(srcDir, publicDir); err != nil {
|
|
// If rename fails (cross-device), use copy.
|
|
if err := copyDir(srcDir, publicDir); err != nil {
|
|
return fmt.Errorf("copy public dir: %w", err)
|
|
}
|
|
}
|
|
|
|
// Scan routes and generate router.
|
|
routes, err := deno.ScanRoutes(apiDst)
|
|
if err != nil {
|
|
return fmt.Errorf("scan routes: %w", err)
|
|
}
|
|
|
|
routerSrc, err := deno.GenerateRouter(routes)
|
|
if err != nil {
|
|
return fmt.Errorf("generate router: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil {
|
|
return fmt.Errorf("write router.ts: %w", err)
|
|
}
|
|
|
|
// Generate Dockerfile.
|
|
dockerfile := deno.GenerateDockerfile()
|
|
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
|
return fmt.Errorf("write Dockerfile: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prepareStaticBuild sets up the build context for a static nginx container.
|
|
func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error {
|
|
// Copy all files to context directory.
|
|
if err := copyDir(srcDir, contextDir); err != nil {
|
|
return fmt.Errorf("copy files: %w", err)
|
|
}
|
|
|
|
// Generate Dockerfile.
|
|
dockerfile := deno.GenerateStaticDockerfile()
|
|
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
|
return fmt.Errorf("write Dockerfile: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildEnvVars decrypts secrets and builds environment variable list.
|
|
func (m *Manager) buildEnvVars(siteID string) ([]string, error) {
|
|
secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get secrets: %w", err)
|
|
}
|
|
|
|
env := make([]string, 0, len(secrets))
|
|
for _, s := range secrets {
|
|
value := s.Value
|
|
if s.Encrypted {
|
|
decrypted, err := crypto.Decrypt(m.encKey, value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err)
|
|
}
|
|
value = decrypted
|
|
}
|
|
env = append(env, s.Key+"="+value)
|
|
}
|
|
|
|
return env, nil
|
|
}
|
|
|
|
// removeContainerByName removes a container by its name (best effort).
|
|
func (m *Manager) removeContainerByName(ctx context.Context, name string) {
|
|
containers, err := m.docker.ListContainers(ctx, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, c := range containers {
|
|
if c.Name == name {
|
|
m.docker.StopContainer(ctx, c.ID, 10)
|
|
m.docker.RemoveContainer(ctx, c.ID, true)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateStatus updates the site status in the database.
|
|
// On failure, it also publishes an event to the event log. On terminal
|
|
// state transitions (deployed / failed), it dispatches an outgoing
|
|
// notification using the per-site URL+secret with fall-through to global.
|
|
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
|
|
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
|
|
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
|
|
}
|
|
|
|
// Persist failures to event log automatically.
|
|
if status == "failed" {
|
|
site, err := m.store.GetStaticSiteByID(id)
|
|
siteName := id
|
|
if err == nil {
|
|
siteName = site.Name
|
|
}
|
|
m.publishEvent(id, siteName, "failed: "+errMsg)
|
|
}
|
|
|
|
if status == "deployed" || status == "failed" {
|
|
m.dispatchSiteNotification(id, status, errMsg)
|
|
}
|
|
}
|
|
|
|
// dispatchSiteNotification emits a site_sync_success or site_sync_failure
|
|
// event to the configured outgoing webhook. Resolution: per-site URL+secret
|
|
// first, falling through to the global settings.notification_url/secret.
|
|
// Always best-effort — failures are logged but never block status updates.
|
|
func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) {
|
|
if m.notifier == nil {
|
|
return
|
|
}
|
|
site, err := m.store.GetStaticSiteByID(siteID)
|
|
if err != nil {
|
|
slog.Warn("static site: notify lookup failed", "site", siteID, "error", err)
|
|
return
|
|
}
|
|
settings, err := m.store.GetSettings()
|
|
if err != nil {
|
|
slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err)
|
|
return
|
|
}
|
|
|
|
url, secret, tier := resolveSiteTarget(site, settings)
|
|
if url == "" {
|
|
return
|
|
}
|
|
|
|
eventType := "site_sync_success"
|
|
if status == "failed" {
|
|
eventType = "site_sync_failure"
|
|
}
|
|
siteURL := ""
|
|
if site.Domain != "" {
|
|
siteURL = "https://" + site.Domain
|
|
}
|
|
m.notifier.SendSigned(url, secret, tier, notify.Event{
|
|
Type: eventType,
|
|
Project: site.Name,
|
|
URL: siteURL,
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site
|
|
// URL beats global, secret travels with the URL that sourced it.
|
|
func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
|
if site.NotificationURL != "" {
|
|
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
|
}
|
|
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
}
|
|
|
|
// publishEvent publishes a static site status event on the event bus
|
|
// and persists it to the event log for the dashboard.
|
|
func (m *Manager) publishEvent(siteID, siteName, status string) {
|
|
m.eventBus.Publish(events.Event{
|
|
Type: events.EventStaticSiteStatus,
|
|
Payload: events.StaticSiteStatusPayload{
|
|
SiteID: siteID,
|
|
Name: siteName,
|
|
Status: status,
|
|
},
|
|
})
|
|
|
|
// Persist to event log.
|
|
severity := "info"
|
|
message := fmt.Sprintf("Static site \"%s\": %s", siteName, status)
|
|
if status == "failed" {
|
|
severity = "error"
|
|
}
|
|
metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status)
|
|
|
|
evt, err := m.store.InsertEvent(store.EventLog{
|
|
Source: "static_site",
|
|
Severity: severity,
|
|
Message: message,
|
|
Metadata: metadata,
|
|
})
|
|
if err != nil {
|
|
slog.Error("static site: failed to persist event log", "error", err)
|
|
return
|
|
}
|
|
|
|
// Publish the persisted event for SSE clients.
|
|
m.eventBus.Publish(events.Event{
|
|
Type: events.EventLog,
|
|
Payload: events.EventLogPayload{
|
|
ID: evt.ID,
|
|
Source: "static_site",
|
|
Severity: severity,
|
|
Message: message,
|
|
Metadata: metadata,
|
|
CreatedAt: evt.CreatedAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
// copyDir recursively copies a directory.
|
|
func copyDir(src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relPath, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dstPath := filepath.Join(dst, relPath)
|
|
|
|
if info.IsDir() {
|
|
return os.MkdirAll(dstPath, 0o755)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(dstPath, data, info.Mode())
|
|
})
|
|
}
|
|
|
|
// hostPortStr converts a uint16 port to a string for proxy configuration.
|
|
func hostPortStr(port uint16) string {
|
|
return strconv.FormatUint(uint64(port), 10)
|
|
}
|