Ctrl K
ComunidadGuías…

nonrelease.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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()
}