package deploy import ( "context" "errors" "time" "github.com/iceBear67/simplepages/internal/site" "github.com/iceBear67/simplepages/internal/store" ) // index reads a deployment's manifest and turns it into the lookup structures // the serving path needs. This is the expensive, failure-prone half of building // a snapshot, which is why it is separable: an activation runs it before it // changes anything. func (s *Service) index(ctx context.Context, dep *store.Deployment) (*site.Index, error) { files, err := s.DB.DeploymentFiles(ctx, dep.ID) if err != nil { return nil, err } return site.NewIndex(files), nil } // deploymentDir is where a deployment's tree was assembled, or "" under // assemble_mode=none, where nothing was. func (s *Service) deploymentDir(p *store.Project, dep *store.Deployment) string { if s.Dir == "" { return "" } return DeploymentDir(s.Dir, p.ID, dep.PublicID) } // LoadSites builds the registry from the database and makes the webroot agree // with it. It runs once, before the listeners start. // // Without this a restart would serve 404 for every project until each one was // deployed again. It also has to complete before the registry can stand in as // the API's project resolver: a half-built registry would report a caller's own // project as one that does not exist. func (s *Service) LoadSites(ctx context.Context) error { projects, err := s.DB.AllProjects(ctx) if err != nil { return err } entries := make([]*site.Project, 0, len(projects)) var active int for _, p := range projects { sp := site.NewProject(p) entries = append(entries, sp) dep, err := s.DB.ActiveDeployment(ctx, p.ID) if err != nil { if errors.Is(err, store.ErrNotFound) { // A project that has never been deployed to. It exists, it // resolves, and it answers 503 until something is activated. continue } return err } idx, err := s.index(ctx, dep) if err != nil { return err } sp.Activate(site.NewDeployment(dep, idx, s.deploymentDir(p, dep))) active++ } s.Sites.Replace(entries) s.Log.Info("site registry loaded", "projects", len(entries), "active", active) // Best effort, like every other webroot operation: the server serves its own // content and a stale symlink is not a reason to refuse to start. if err := s.Reconcile(); err != nil { s.Log.Warn("could not fully reconcile the webroot", "err", err) } return nil } // Reconcile makes $WEBROOT agree with what this process is serving: one symlink // per project with an active deployment, and nothing of ours left over for // projects that no longer have one. // // The wanted set comes from the registry rather than from the database, because // the registry is what requests are actually being answered from. The symlinks // exist so that an external reader — a reverse proxy, a backup job — sees the // same version this process does, and taking them from anywhere else would let // the two disagree. func (s *Service) Reconcile() error { if s.Webroot == nil { return nil } want := make(map[string]string) for _, p := range s.Sites.Projects() { // One load per project, and the value is used for both the name and the // directory: reading Active() twice could straddle an activation and // produce a link to a directory the other half of the pair disagrees with. if d := p.Active(); d != nil && d.Dir != "" { want[p.Name] = d.Dir } } return s.Webroot.Reconcile(want) } // RunReconciler repairs the webroot on a timer until ctx is done. // // Activation repoints a symlink itself and logs when it cannot, so this is for // the cases where nothing was there to notice: a link an operator deleted or // edited, one whose repoint failed on a full disk, or one left pointing at a // deployment that has since been collected. Serving does not depend on any of // it, which is why a failure here is a warning and never stops the loop. func (s *Service) RunReconciler(ctx context.Context, every time.Duration) { if s.Webroot == nil || every <= 0 { return } t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := s.Reconcile(); err != nil { s.Log.Warn("could not fully reconcile the webroot", "err", err) } } } }