nonrelease.go
Rama main · réplica de lectura del repositorio original.
// Copyright The Pit Project Owners. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Please see https://officer.openpit.dev and the OWNERS file for details.
package officer
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
frameworkapp "go.openpit.dev/officer/framework/app"
"go.openpit.dev/officer/framework/domain"
httpx "go.openpit.dev/officer/framework/web/httpapi"
"go.openpit.dev/officer/httpapi"
"go.openpit.dev/officer/internal/nonrelease"
"go.openpit.dev/officer/internal/store/sqlite"
)
// PrepareNonReleaseDatabase creates cfg.SQLitePath as a migrated database with
// all trading connections disabled, optionally copying the release database.
// It never overwrites an existing own file or opens the release file for
// writing. Preparation happens in a temporary file beside the own path.
// The own path's filesystem must support hard links: publication hard-links the
// temporary file to the own path so concurrent preparation cannot replace it.
// An error can follow successful publication only if removing the temporary file
// fails, and then states that the own database is in place.
func PrepareNonReleaseDatabase(
ctx context.Context, cfg Config, releasePath string, fromRelease bool,
) (result error) {
own, err := filepath.Abs(cfg.SQLitePath)
if err != nil {
return fmt.Errorf("officer: prepare non-release database: own path: %w", err)
}
release, err := filepath.Abs(releasePath)
if err != nil {
return fmt.Errorf("officer: prepare non-release database: release path: %w", err)
}
if own == release {
return fmt.Errorf("officer: prepare non-release database: own path %q equals release path", own)
}
if _, err := os.Lstat(own); err == nil {
return fmt.Errorf("officer: prepare non-release database: own path %q already exists", own)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("officer: prepare non-release database: stat own path %q: %w", own, err)
}
tmp, err := os.CreateTemp(filepath.Dir(own), filepath.Base(own)+".prepare-*")
if err != nil {
return fmt.Errorf("officer: prepare non-release database: create temporary file: %w", err)
}
tmpPath := tmp.Name()
published := false
defer func() {
if err := sqlite.RemoveDatabaseFiles(tmpPath); err != nil {
if published {
err = fmt.Errorf("officer: prepare non-release database: own database %q is published; remove temporary database: %w", own, err)
} else {
err = fmt.Errorf("officer: prepare non-release database: remove temporary database: %w", err)
}
result = errors.Join(result, err)
}
}()
if err := tmp.Close(); err != nil {
return fmt.Errorf("officer: prepare non-release database: close temporary file: %w", err)
}
if fromRelease {
if err := sqlite.Snapshot(ctx, releasePath, tmpPath); err != nil {
return fmt.Errorf("officer: prepare non-release database: snapshot release: %w", err)
}
}
cfg.SQLitePath = tmpPath
if err := func() (result error) {
st, err := newStore(cfg)
if err != nil {
return fmt.Errorf("officer: prepare non-release database: open temporary store: %w", err)
}
defer func() {
if err := st.Close(); err != nil {
result = errors.Join(result, fmt.Errorf("officer: prepare non-release database: close temporary store: %w", err))
}
}()
if err := st.Migrate(ctx); err != nil {
return fmt.Errorf("officer: prepare non-release database: migrate: %w", err)
}
realm, err := st.ForRealm(ctx, domain.DefaultRealm)
if err != nil {
return fmt.Errorf("officer: prepare non-release database: bind realm: %w", err)
}
connections, err := realm.ListTradingConnections(ctx)
if err != nil {
return fmt.Errorf("officer: prepare non-release database: list trading connections: %w", err)
}
for _, connection := range connections {
if connection.Enabled {
if err := realm.SetTradingConnectionEnabled(ctx, connection.ExternalID, false); err != nil {
return fmt.Errorf("officer: prepare non-release database: disable trading connection %q: %w", connection.ExternalID, err)
}
}
}
return nil
}(); err != nil {
return err
}
if err := publishNonReleaseDatabase(tmpPath, own); err != nil {
return err
}
published = true
return nil
}
func publishNonReleaseDatabase(tmpPath, own string) error {
if err := os.Link(tmpPath, own); err != nil {
return fmt.Errorf("officer: prepare non-release database: publish %q by hard link: %w", own, err)
}
return nil
}
// NonReleaseGate serves the panel shell, both build routes, health, the OpenAPI
// document and API docs before ready. Before ready it also returns 503
// setup_required on the MCP path and route manifest, and a typed 404 on other
// /api/v1 and /app/api/v1 paths.
// Its mutex owns every phase transition and preparation launch; its atomic
// handler publishes only a fully built application, once ready.
// A gate must not be copied after construction.
type NonReleaseGate struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
work sync.WaitGroup
handler atomic.Pointer[http.Handler]
phase nonrelease.Phase
failure string
releaseDatabaseExists bool
cfg Config
releasePath string
prepare func(context.Context, Config, string, bool) error
build func(context.Context) (http.Handler, error)
}
// NewNonReleaseGate serves a first-screen database choice on cfg.SQLitePath,
// which the caller must already have derived as its own per-commit path.
// mcpPath is where the application built by build mounts its MCP handler;
// before ready, the gate answers that path with 503 setup_required.
// spa loads the panel the gate serves until ready.
// Each accepted choice prepares the database at cfg.SQLitePath, then calls
// build with the gate's context.
// The caller owns the application returned by build and its eventual cleanup.
// Close cancels that context, so call it at shutdown after the server stops
// serving, not when the gate reaches ready. Before returning an error, build
// must close everything it opened: the gate then removes its own database files.
func NewNonReleaseGate(
ctx context.Context, cfg Config, releasePath, mcpPath string,
releaseDatabaseExists bool, spa frameworkapp.SPAFactory,
build func(context.Context) (http.Handler, error),
) (*NonReleaseGate, error) {
return newNonReleaseGate(ctx, cfg, releasePath, mcpPath, releaseDatabaseExists, spa, build,
PrepareNonReleaseDatabase)
}
func newNonReleaseGate(
ctx context.Context, cfg Config, releasePath, mcpPath string,
releaseDatabaseExists bool, spaFactory frameworkapp.SPAFactory,
build func(context.Context) (http.Handler, error),
prepare func(context.Context, Config, string, bool) error,
) (*NonReleaseGate, error) {
if spaFactory == nil || build == nil || prepare == nil {
return nil, errors.New("officer: non-release gate requires SPA, prepare and build functions")
}
guards, err := browserGuards(cfg)
if err != nil {
return nil, err
}
spa, err := spaFactory()
if err != nil {
return nil, fmt.Errorf("officer: non-release gate: load panel: %w", err)
}
ctx, cancel := context.WithCancel(ctx)
g := &NonReleaseGate{
ctx: ctx, cancel: cancel, cfg: cfg, releasePath: releasePath,
releaseDatabaseExists: releaseDatabaseExists, phase: nonrelease.PhaseChoice,
prepare: prepare, build: build,
}
panel := &httpx.RouteRegistry{}
panel.Register(nonrelease.BuildRoute(g.status))
panel.Register(nonrelease.DatabaseRoute(g.choose))
handler, err := httpx.NewRouter(httpx.RouterConfig{
Surfaces: httpx.V1Surfaces(&httpx.RouteRegistry{}, panel), Guards: guards,
SPA: spa, BodyLimit: httpapi.BodyLimitPolicy(),
ExtraMounts: httpapi.ExtraMounts(),
MCP: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteErr(w, domain.ErrSetupRequired)
}),
MCPPath: mcpPath,
Authorizer: accessAuthorizer(func(context.Context, domain.Caller, string) error {
return domain.ErrSetupRequired
}),
CallerResolver: func(*http.Request) (domain.Caller, error) {
return domain.Caller{}, domain.ErrSetupRequired
},
AccessMode: func() httpx.AccessMode { return httpx.AccessModeSetup },
})
if err != nil {
cancel()
return nil, fmt.Errorf("officer: non-release gate: build router: %w", err)
}
g.handler.Store(&handler)
return g, nil
}
// ServeHTTP atomically selects the gate router until ready, then the app.
func (g *NonReleaseGate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
(*g.handler.Load()).ServeHTTP(w, r)
}
func (g *NonReleaseGate) status() nonrelease.Status {
g.mu.Lock()
defer g.mu.Unlock()
return g.statusLocked()
}
func (g *NonReleaseGate) statusLocked() nonrelease.Status {
return nonrelease.Status{
Phase: g.phase, ReleaseDatabaseExists: g.releaseDatabaseExists,
Error: g.failure,
}
}
// choose accepts only choice or failed, rejects a missing release source,
// and launches exactly one preparation before returning the preparing status.
func (g *NonReleaseGate) choose(fromRelease bool) (nonrelease.Status, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.phase != nonrelease.PhaseChoice && g.phase != nonrelease.PhaseFailed {
return nonrelease.Status{}, fmt.Errorf("%w: database choice in phase %q", domain.ErrConflict, g.phase)
}
if err := g.ctx.Err(); err != nil {
return nonrelease.Status{}, fmt.Errorf("%w: non-release gate closed: %v", domain.ErrConflict, err)
}
if fromRelease && !g.releaseDatabaseExists {
return nonrelease.Status{}, fmt.Errorf("%w: release database does not exist", domain.ErrInvalid)
}
g.phase, g.failure = nonrelease.PhasePreparing, ""
g.work.Add(1)
go g.prepareAndBuild(fromRelease)
return g.statusLocked(), nil
}
// prepareAndBuild leaves preparation failures alone: prepare owns its cleanup
// and may have refused an existing file. A build failure removes the prepared
// own database and joins cleanup errors so a new choice can prepare again.
// Cancellation after preparation skips build and keeps the prepared database.
func (g *NonReleaseGate) prepareAndBuild(fromRelease bool) {
defer g.work.Done()
err := g.prepare(g.ctx, g.cfg, g.releasePath, fromRelease)
var handler http.Handler
if err == nil {
err = g.ctx.Err()
if err == nil {
handler, err = g.build(g.ctx)
if err == nil && handler == nil {
err = errors.New("officer: non-release build returned no handler")
}
if err != nil {
err = errors.Join(err, sqlite.RemoveDatabaseFiles(g.cfg.SQLitePath))
}
}
}
g.mu.Lock()
defer g.mu.Unlock()
if err != nil {
g.phase, g.failure = nonrelease.PhaseFailed, err.Error()
return
}
g.phase = nonrelease.PhaseReady
g.handler.Store(&handler)
}
// Close cancels the gate context and waits until prepare and build have
// returned. Neither can run after Close returns, including for a later choice.
// build receives this same gate context, so Close cancels any contexts the
// application derived from it. The caller must still close the application itself.
// Call Close at shutdown after the server stops serving, not upon reaching ready.
// Close is idempotent and may be called concurrently.
func (g *NonReleaseGate) Close() {
g.mu.Lock()
g.cancel()
g.mu.Unlock()
g.work.Wait()
}