package sitesd import ( "context" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" "os" "strings" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/sitesd/cache" "tangled.org/core/sitesd/cloudflare" "tangled.org/core/sitesd/config" "tangled.org/core/sitesd/models" "tangled.org/core/sitesd/reposync" "tangled.org/core/sitesd/sitefeed" "tangled.org/core/sitesd/sites" "tangled.org/core/sitesd/worker" ) const ( // deployTimeout bounds one deploy's R2 sync. deployTimeout = 10 * time.Minute // how long Stop lets in-flight deploys record their outcome deployShutdownGrace = 30 * time.Second // recordTimeout bounds the history write recordTimeout = 30 * time.Second // shutdownTimeout is the HTTP server drain budget at Stop. shutdownTimeout = 15 * time.Second preflightTimeout = 10 * time.Second internalWriteTimeout = 30 * time.Second ) // Options are the runtime parameters of the site deploy executor, provided by // the cmd/sitesd entrypoint (flags with env defaults). type Options struct { Listen string InternalListen string WorkerURL string Audience string Issuer string KeyHex string Knots string Jetstream string RedisAddr string Dev bool } // Serve runs the sitesd daemon until ctx is canceled: the push feed, // the deploy HTTP surface (internal /deploy when InternalListen is set), did // document, health. func Serve(ctx context.Context, logger *slog.Logger, opts Options) error { var knots []string for _, knot := range strings.Split(opts.Knots, ",") { if knot = strings.TrimSpace(knot); knot != "" { knots = append(knots, knot) } } if opts.Jetstream == "" { return fmt.Errorf("no jetstream configured (SITESD_JETSTREAM_ENDPOINT); push-triggered deploys would never run") } runCtx, cancel := context.WithCancel(ctx) defer cancel() key := opts.KeyHex if key == "" { key = os.Getenv("SITESD_PRIVATE_KEY") } priv, err := loadKey(key) if err != nil { return fmt.Errorf("loading P-256 key: %w", err) } cfg := &config.Config{ Core: config.Core{Dev: opts.Dev}, Cloudflare: config.Cloudflare{ AccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), R2: config.R2Config{ AccessKeyID: os.Getenv("CLOUDFLARE_R2_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("CLOUDFLARE_R2_SECRET_ACCESS_KEY"), Bucket: os.Getenv("CLOUDFLARE_R2_BUCKET"), BridgeURL: os.Getenv("SITESD_R2_BRIDGE_URL"), BridgeToken: os.Getenv("SITESD_R2_BRIDGE_TOKEN"), }, }, } cf, err := cloudflare.New(cfg) if err != nil { return fmt.Errorf("building cloudflare client: %w", err) } if !opts.Dev && (!cf.Enabled() || cfg.Cloudflare.R2.AccessKeyID == "" || cfg.Cloudflare.R2.SecretAccessKey == "") { return fmt.Errorf("cloudflare R2 requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_R2_* credentials") } if !cf.Enabled() { logger.Warn("cloudflare is not configured (CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_R2_*): every deploy is refused") } wrk := worker.New(opts.WorkerURL, opts.Issuer, opts.Audience, priv) var rdb *cache.Cache if opts.RedisAddr != "" { rdb = cache.New(opts.RedisAddr) } else { logger.Warn("no redis address (SITESD_REDIS): the jetstream cursor is not persisted, so a restart resumes live and the pushes it missed never deploy") } deployCtx, stopDeploys := context.WithCancel(context.Background()) defer stopDeploys() locks := reposync.New() feed := sitefeed.New(rdb, cfg, cf, wrk, locks, deployCtx, knots, logger) if cf.Enabled() { if err := feed.Run(runCtx, opts.Jetstream); err != nil { return err } } mux := http.NewServeMux() srv := &server{ cfg: cfg, cf: cf, wrk: wrk, locks: locks, logger: logger, deployCtx: deployCtx, getCtx: wrk.GetDeployContext, deploy: sites.Deploy, record: wrk.RecordDeploy, } mux.HandleFunc("GET /.well-known/did.json", srv.handleDidDoc(opts.Issuer, priv)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) logger.Info("sitesd listening", "addr", opts.Listen, "worker", opts.WorkerURL, "did", opts.Issuer) httpServer := &http.Server{ Addr: opts.Listen, Handler: mux, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 10 * time.Minute, IdleTimeout: 2 * time.Minute, } serveErr := make(chan error, 2) go func() { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { serveErr <- fmt.Errorf("public server: %w", err) } }() var internalServer *http.Server if opts.InternalListen != "" { // private-network only: the worker cannot sign service auth internalMux := http.NewServeMux() internalMux.HandleFunc("POST /deploy", srv.handleInternalDeploy) internalServer = &http.Server{ Addr: opts.InternalListen, Handler: internalMux, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: internalWriteTimeout, IdleTimeout: 2 * time.Minute, } go func() { logger.Info("internal deploy trigger listening", "addr", opts.InternalListen) if err := internalServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { serveErr <- fmt.Errorf("internal server: %w", err) } }() } else { logger.Warn("no internal listen address (SITESD_INTERNAL_ADDR): config-change and manual deploys have no endpoint") } var serveErrVal error select { case err := <-serveErr: serveErrVal = err cancel() case <-runCtx.Done(): } logger.Info("shutting down", "reason", runCtx.Err()) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) defer shutdownCancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { logger.Error("public server shutdown", "err", err) } if internalServer != nil { if err := internalServer.Shutdown(shutdownCtx); err != nil { logger.Error("internal server shutdown", "err", err) } } srv.drainDeploys(stopDeploys) return serveErrVal } func (s *server) drainDeploys(stop context.CancelFunc) { done := make(chan struct{}) go func() { defer close(done) s.locks.Drain() }() select { case <-done: case <-time.After(deployShutdownGrace): s.logger.Warn("deploy drain grace elapsed, cancelling in-flight deploys", "grace", deployShutdownGrace) stop() <-done } } func loadKey(hexStr string) (*atcrypto.PrivateKeyP256, error) { if hexStr == "" { return nil, fmt.Errorf("no key provided: set SITESD_PRIVATE_KEY or -key (64 hex chars)") } raw, err := hex.DecodeString(strings.TrimSpace(hexStr)) if err != nil { return nil, fmt.Errorf("decoding key hex: %w", err) } return atcrypto.ParsePrivateBytesP256(raw) } type server struct { cfg *config.Config cf *cloudflare.Client wrk *worker.Client locks *reposync.Registry logger *slog.Logger deployCtx context.Context getCtx func(context.Context, string) (*worker.DeployContext, error) deploy func(context.Context, *cloudflare.Client, *config.Config, *models.Repo, string, string) error record func(context.Context, *worker.DeployContext, string, string, string, string) error } type deployRequest struct { RepoDid string `json:"repoDid"` // an omitted trigger is a config-change deploy Trigger string `json:"trigger"` } type acceptedDeploy struct { dc *worker.DeployContext trigger models.SiteDeployTrigger release func() done func() } // ack 202 before deploy runs because worker wait_until expires in seconds while deploys take minutes func (s *server) handleInternalDeploy(w http.ResponseWriter, r *http.Request) { var req deployRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "InvalidRequest", "request body must be JSON") return } if req.RepoDid == "" { writeErr(w, http.StatusBadRequest, "InvalidRequest", "missing repoDid") return } trigger := models.SiteDeployTriggerConfigChange if req.Trigger != "" { var ok bool if trigger, ok = models.ParseSiteDeployTrigger(req.Trigger); !ok { writeErr(w, http.StatusBadRequest, "InvalidRequest", "trigger must be one of config_change, push, manual") return } } if !s.cf.Enabled() { writeErr(w, http.StatusServiceUnavailable, "DeployUnavailable", "cloudflare is not configured") return } preflightCtx, preflightCancel := context.WithTimeout(r.Context(), preflightTimeout) defer preflightCancel() dc, err := s.getCtx(preflightCtx, req.RepoDid) if err != nil { s.logger.Error("internal deploy: deploy context unavailable", "repo", req.RepoDid, "err", err) writeErr(w, http.StatusBadGateway, "DeployContextUnavailable", err.Error()) return } if dc == nil { writeErr(w, http.StatusUnprocessableEntity, "NoDeployContext", "no site config or active claim for this repo") return } // register before ack so draining waits for accepted work done, ok := s.locks.Begin() if !ok { writeErr(w, http.StatusServiceUnavailable, "ShuttingDown", "deploy executor is shutting down") return } release, ok := s.locks.TryAcquire() if !ok { done() writeErr(w, http.StatusTooManyRequests, "DeployAtCapacity", "every deploy slot is busy") return } go s.runDeploy(acceptedDeploy{dc: dc, trigger: trigger, release: release, done: done}) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]string{"status": "accepted"}) } func (s *server) runDeploy(a acceptedDeploy) { defer a.done() defer a.release() runCtx, cancel := context.WithTimeout(s.deployCtx, deployTimeout) defer cancel() dc := a.dc deploy := &models.SiteDeploy{ RepoDid: syntax.DID(dc.RepoDid), Branch: dc.Branch, Dir: dc.Dir, CommitSHA: "", Trigger: a.trigger, } repo := &models.Repo{ Did: dc.OwnerDid, Name: dc.Name, Rkey: dc.Rkey, Knot: dc.Knot, RepoDid: dc.RepoDid, } // push feed shares this lock, serializing against trigger deploys for the same repo unlock, lockErr := s.locks.Lock(runCtx, dc.RepoDid) var deployErr error if lockErr != nil { deployErr = fmt.Errorf("waiting for the repo lock: %w", lockErr) } else { defer unlock() deployErr = s.deploy(runCtx, s.cf, s.cfg, repo, dc.Branch, dc.Dir) } deploy.Status = models.SiteDeployStatusSuccess if deployErr != nil { deploy.Status = models.SiteDeployStatusFailure deploy.Error = deployErr.Error() s.logger.Error("internal deploy: failed", "repo", dc.RepoDid, "err", deployErr) } // outside the deploy's budget: timeouts and shutdowns still record recordCtx, recordCancel := context.WithTimeout(context.Background(), recordTimeout) defer recordCancel() if err := s.record(recordCtx, dc, deploy.CommitSHA, string(deploy.Status), string(deploy.Trigger), deploy.Error); err != nil { s.logger.Error("internal deploy: recording the outcome failed", "repo", dc.RepoDid, "status", deploy.Status, "err", err) } } func (s *server) handleDidDoc(issuer string, priv *atcrypto.PrivateKeyP256) http.HandlerFunc { pub, err := priv.PublicKey() multibase := "" if err == nil { multibase = pub.Multibase() } doc := map[string]any{ "id": issuer, "verificationMethod": []map[string]string{ { "id": issuer + "#atproto", "type": "Multikey", "controller": issuer, "publicKeyMultibase": multibase, }, }, } return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(doc) } } func writeErr(w http.ResponseWriter, status int, name, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": name, "message": message}) }