From 08dce8a9d5707e5a819a3aee8dbae131a032eed8 Mon Sep 17 00:00:00 2001 From: wangchuan Date: Thu, 8 Oct 2026 06:47:37 +0000 Subject: [PATCH 1/4] feat: make the backend deployable to kubernetes Adds the switches and lifecycle hooks a Kubernetes deployment needs, keeping the docker compose behaviour as the default everywhere: every new option defaults to what the code already did. - serve /healthz and /readyz on a separate port (health.port, off by default) so probe traffic stays out of the API router and never runs through the auth, session or request-logging middleware - drain on SIGTERM and SIGINT instead of dying on the signal, and stop the cron scheduler and the telemetry reporter on the way out. The cron instance had been lost to a shadowed variable, so it could not be stopped at all - move the SQL migrations and the raglite database creation out of pod startup behind pg.auto_migrate and pg.create_raglite_db, so they can run as one job instead of once per replica, and treat a concurrent database creation as success - reach the Caddy admin endpoint over unix, http or tcp rather than a unix socket only, make the reverse_proxy upstreams configurable instead of deriving them from a subnet prefix, and restate the admin listener when the configuration is reloaded - push an empty routing table when no knowledge base is left, so deleting the last one no longer leaves its rule in Caddy - tolerate a MinIO bucket and a NATS stream being created concurrently - make telemetry optional and add setup.init_cert, which skips the self-signed certificate that only the bundled admin nginx consumed through a shared volume - stop building a transport for every Caddy sync without releasing its connections --- backend/cmd/api/main.go | 69 +++++++++++++- backend/cmd/api/wire.go | 5 + backend/cmd/api/wire_gen.go | 7 ++ backend/cmd/consumer/main.go | 38 +++++++- backend/cmd/consumer/wire.go | 4 + backend/cmd/consumer/wire_gen.go | 7 ++ backend/config/config.go | 121 +++++++++++++++++++++--- backend/handler/mq/cron.go | 24 +++-- backend/health/checker.go | 124 +++++++++++++++++++++++++ backend/health/provider.go | 8 ++ backend/health/server.go | 107 +++++++++++++++++++++ backend/mq/nats/consumer.go | 13 ++- backend/mq/nats/producer.go | 13 +++ backend/repo/pg/knowledge_base.go | 123 +++++++++++++++++++----- backend/repo/pg/knowledge_base_test.go | 84 +++++++++++++++++ backend/setup/cert.go | 7 +- backend/store/pg/pg.go | 40 ++++++-- backend/store/s3/minio.go | 8 +- backend/telemetry/client.go | 15 ++- 19 files changed, 751 insertions(+), 66 deletions(-) create mode 100644 backend/health/checker.go create mode 100644 backend/health/provider.go create mode 100644 backend/health/server.go create mode 100644 backend/repo/pg/knowledge_base_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index b796201d0..2e02390b9 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,11 +1,24 @@ package main import ( + "context" + "errors" "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + "github.com/chaitin/panda-wiki/health" + "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/setup" ) +// shutdownTimeout bounds how long in-flight requests, including streaming +// chat responses, are allowed to finish after a termination signal. +const shutdownTimeout = 30 * time.Second + // @title panda-wiki API // @version 1.0 // @description panda-wiki API documentation @@ -19,10 +32,60 @@ func main() { if err != nil { panic(err) } - if err := setup.CheckInitCert(); err != nil { - panic(err) + // The self-signed certificate only exists for the bundled admin nginx, which + // reads it from a shared volume. Deployment targets where the certificate + // comes from elsewhere, or where the filesystem is read-only, turn it off. + if app.Config.Setup.InitCert { + if err := setup.CheckInitCert(); err != nil { + panic(err) + } + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if conn, ok := app.MQProducer.(health.Connection); ok { + app.Health.RegisterMQProbe("nats", conn) + } + + if app.Health.Enabled() { + go func() { + if err := app.Health.Start(ctx); err != nil { + app.Logger.Error("health server stopped", log.Error(err)) + } + }() } + port := app.Config.HTTP.Port app.Logger.Info(fmt.Sprintf("Starting server on port %d", port)) - app.HTTPServer.Echo.Logger.Fatal(app.HTTPServer.Echo.Start(fmt.Sprintf(":%d", port))) + + serveErr := make(chan error, 1) + go func() { + if err := app.HTTPServer.Echo.Start(fmt.Sprintf(":%d", port)); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + }() + + var exitCode int + select { + case err := <-serveErr: + app.Logger.Error("http server stopped unexpectedly", log.Error(err)) + // Exit non-zero so Kubernetes treats this as a crash and restarts the + // pod, rather than as a clean shutdown. + exitCode = 1 + case <-ctx.Done(): + app.Logger.Info("termination signal received, draining in-flight requests") + } + + // Stop the periodic reporter first so it cannot delay the drain. + app.Telemetry.Stop() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := app.HTTPServer.Echo.Shutdown(shutdownCtx); err != nil { + app.Logger.Error("graceful shutdown did not finish", log.Error(err)) + } + if exitCode != 0 { + os.Exit(exitCode) + } } diff --git a/backend/cmd/api/wire.go b/backend/cmd/api/wire.go index 0793584df..10482d397 100644 --- a/backend/cmd/api/wire.go +++ b/backend/cmd/api/wire.go @@ -8,7 +8,9 @@ import ( "github.com/chaitin/panda-wiki/config" share "github.com/chaitin/panda-wiki/handler/share" v1 "github.com/chaitin/panda-wiki/handler/v1" + "github.com/chaitin/panda-wiki/health" "github.com/chaitin/panda-wiki/log" + "github.com/chaitin/panda-wiki/mq" "github.com/chaitin/panda-wiki/server/http" "github.com/chaitin/panda-wiki/telemetry" ) @@ -22,6 +24,7 @@ func createApp() (*App, error) { telemetry.ProviderSet, http.ProviderSet, + health.ProviderSet, v1.ProviderSet, share.ProviderSet, ), @@ -36,4 +39,6 @@ type App struct { Config *config.Config Logger *log.Logger Telemetry *telemetry.Client + Health *health.Server + MQProducer mq.MQProducer } diff --git a/backend/cmd/api/wire_gen.go b/backend/cmd/api/wire_gen.go index 56b8b63b1..9e04260c7 100644 --- a/backend/cmd/api/wire_gen.go +++ b/backend/cmd/api/wire_gen.go @@ -11,6 +11,7 @@ import ( "github.com/chaitin/panda-wiki/handler" "github.com/chaitin/panda-wiki/handler/share" "github.com/chaitin/panda-wiki/handler/v1" + "github.com/chaitin/panda-wiki/health" "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/middleware" "github.com/chaitin/panda-wiki/mq" @@ -196,6 +197,8 @@ func createApp() (*App, error) { if err != nil { return nil, err } + checker := health.NewChecker(logger, db, cacheCache, minioClient) + server := health.NewServer(configConfig, checker, logger) app := &App{ HTTPServer: httpServer, Handlers: apiHandlers, @@ -203,6 +206,8 @@ func createApp() (*App, error) { Config: configConfig, Logger: logger, Telemetry: client, + Health: server, + MQProducer: mqProducer, } return app, nil } @@ -216,4 +221,6 @@ type App struct { Config *config.Config Logger *log.Logger Telemetry *telemetry.Client + Health *health.Server + MQProducer mq.MQProducer } diff --git a/backend/cmd/consumer/main.go b/backend/cmd/consumer/main.go index 431e58606..0b4dc0dff 100644 --- a/backend/cmd/consumer/main.go +++ b/backend/cmd/consumer/main.go @@ -2,6 +2,12 @@ package main import ( "context" + "os" + "os/signal" + "syscall" + + "github.com/chaitin/panda-wiki/health" + "github.com/chaitin/panda-wiki/log" ) func main() { @@ -9,10 +15,36 @@ func main() { if err != nil { panic(err) } - if err := app.MQConsumer.StartConsumerHandlers(context.Background()); err != nil { - panic(err) + + // Cancelling this context is what stops the consumer handlers, so it has to + // be wired to the termination signal rather than left as context.Background(). + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if conn, ok := app.MQConsumer.(health.Connection); ok { + app.Health.RegisterMQProbe("nats", conn) + } + + if app.Health.Enabled() { + go func() { + if err := app.Health.Start(ctx); err != nil { + app.Logger.Error("health server stopped", log.Error(err)) + } + }() + } + + if err := app.MQConsumer.StartConsumerHandlers(ctx); err != nil { + // This only returns on cancellation, so a non-nil error is unexpected. + app.Logger.Error("consumer handlers stopped unexpectedly", log.Error(err)) + os.Exit(1) } + + // Order matters: stop scheduling new cron jobs before tearing the + // subscriptions down, so no job starts against a closed connection. + app.StatCronHandler.Stop() if err := app.MQConsumer.Close(); err != nil { - panic(err) + // Reported, but not as a crash: this runs because a signal asked the + // process to stop, and JetStream redelivers anything left unacked. + app.Logger.Error("failed to close the message queue consumer", log.Error(err)) } } diff --git a/backend/cmd/consumer/wire.go b/backend/cmd/consumer/wire.go index 3a21abd81..3a88cba4f 100644 --- a/backend/cmd/consumer/wire.go +++ b/backend/cmd/consumer/wire.go @@ -7,6 +7,7 @@ import ( "github.com/chaitin/panda-wiki/config" handler "github.com/chaitin/panda-wiki/handler/mq" + "github.com/chaitin/panda-wiki/health" "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/mq" ) @@ -18,6 +19,7 @@ func createApp() (*App, error) { config.ProviderSet, log.ProviderSet, handler.ProviderSet, + health.ProviderSet, ), ) return &App{}, nil @@ -28,4 +30,6 @@ type App struct { Config *config.Config MQHandlers *handler.MQHandlers StatCronHandler *handler.CronHandler + Health *health.Server + Logger *log.Logger } diff --git a/backend/cmd/consumer/wire_gen.go b/backend/cmd/consumer/wire_gen.go index ef18bd1db..a1049056c 100644 --- a/backend/cmd/consumer/wire_gen.go +++ b/backend/cmd/consumer/wire_gen.go @@ -9,6 +9,7 @@ package main import ( "github.com/chaitin/panda-wiki/config" mq3 "github.com/chaitin/panda-wiki/handler/mq" + "github.com/chaitin/panda-wiki/health" "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/mq" cache2 "github.com/chaitin/panda-wiki/repo/cache" @@ -94,11 +95,15 @@ func createApp() (*App, error) { RagDocUpdateHandler: ragDocUpdateHandler, StatCronHandler: cronHandler, } + checker := health.NewChecker(logger, db, cacheCache, minioClient) + server := health.NewServer(configConfig, checker, logger) app := &App{ MQConsumer: mqConsumer, Config: configConfig, MQHandlers: mqHandlers, StatCronHandler: cronHandler, + Health: server, + Logger: logger, } return app, nil } @@ -110,4 +115,6 @@ type App struct { Config *config.Config MQHandlers *mq3.MQHandlers StatCronHandler *mq3.CronHandler + Health *health.Server + Logger *log.Logger } diff --git a/backend/config/config.go b/backend/config/config.go index 58d542076..7b9be4393 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -9,18 +9,22 @@ import ( ) type Config struct { - Log LogConfig `mapstructure:"log"` - HTTP HTTPConfig `mapstructure:"http"` - AdminPassword string `mapstructure:"admin_password"` - PG PGConfig `mapstructure:"pg"` - MQ MQConfig `mapstructure:"mq"` - RAG RAGConfig `mapstructure:"rag"` - Redis RedisConfig `mapstructure:"redis"` - Auth AuthConfig `mapstructure:"auth"` - S3 S3Config `mapstructure:"s3"` - Sentry SentryConfig `mapstructure:"sentry"` - CaddyAPI string `mapstructure:"caddy_api"` - SubnetPrefix string `mapstructure:"subnet_prefix"` + Log LogConfig `mapstructure:"log"` + HTTP HTTPConfig `mapstructure:"http"` + Health HealthConfig `mapstructure:"health"` + AdminPassword string `mapstructure:"admin_password"` + PG PGConfig `mapstructure:"pg"` + MQ MQConfig `mapstructure:"mq"` + RAG RAGConfig `mapstructure:"rag"` + Redis RedisConfig `mapstructure:"redis"` + Auth AuthConfig `mapstructure:"auth"` + S3 S3Config `mapstructure:"s3"` + Sentry SentryConfig `mapstructure:"sentry"` + Telemetry TelemetryConfig `mapstructure:"telemetry"` + Setup SetupConfig `mapstructure:"setup"` + Caddy CaddyConfig `mapstructure:"caddy"` + CaddyAPI string `mapstructure:"caddy_api"` + SubnetPrefix string `mapstructure:"subnet_prefix"` } type LogConfig struct { @@ -31,8 +35,46 @@ type HTTPConfig struct { Port int `mapstructure:"port"` } +// HealthConfig configures the dedicated liveness/readiness endpoint. It is +// disabled by default (Port 0) so existing deployments are unaffected. +type HealthConfig struct { + Port int `mapstructure:"port"` +} + type PGConfig struct { DSN string `mapstructure:"dsn"` + // AutoMigrate runs the SQL migrations on every startup. Disable it when + // migrations are executed by a separate job. + AutoMigrate bool `mapstructure:"auto_migrate"` + // CreateRagliteDB creates the raglite database when it is missing. + CreateRagliteDB bool `mapstructure:"create_raglite_db"` +} + +type TelemetryConfig struct { + Enabled bool `mapstructure:"enabled"` +} + +type SetupConfig struct { + // InitCert makes the api generate a self-signed certificate on startup for + // the bundled admin nginx to consume through a shared volume. Set to false + // when the certificate is provided by other means, or when the root + // filesystem is read-only. + InitCert bool `mapstructure:"init_cert"` +} + +type CaddyConfig struct { + // Upstreams are the reverse_proxy dial targets (host:port, without scheme). + // Empty values fall back to addresses derived from SubnetPrefix. + Upstreams CaddyUpstreams `mapstructure:"upstreams"` + // AdminListen is merged into the top-level "admin" key of the config pushed + // to Caddy. Leave empty to keep Caddy's own admin listener untouched. + AdminListen string `mapstructure:"admin_listen"` +} + +type CaddyUpstreams struct { + API string `mapstructure:"api"` + App string `mapstructure:"app"` + StaticFile string `mapstructure:"static_file"` } type MQConfig struct { @@ -95,8 +137,14 @@ func NewConfig() (*Config, error) { HTTP: HTTPConfig{ Port: 8000, }, + Health: HealthConfig{ + // disabled by default; existing deployments keep their behaviour + Port: 0, + }, PG: PGConfig{ - DSN: "host=panda-wiki-postgres user=panda-wiki password=panda-wiki-secret dbname=panda-wiki port=5432 sslmode=disable TimeZone=Asia/Shanghai", + DSN: "host=panda-wiki-postgres user=panda-wiki password=panda-wiki-secret dbname=panda-wiki port=5432 sslmode=disable TimeZone=Asia/Shanghai", + AutoMigrate: true, + CreateRagliteDB: true, }, MQ: MQConfig{ Type: "nats", @@ -130,6 +178,12 @@ func NewConfig() (*Config, error) { Enabled: true, DSN: "https://2a4cff1ae04b624ffc72663f523024ff@sentry.baizhi.cloud/4", }, + Telemetry: TelemetryConfig{ + Enabled: true, + }, + Setup: SetupConfig{ + InitCert: true, + }, CaddyAPI: "/app/run/caddy-admin.sock", SubnetPrefix: "169.254.15", } @@ -185,6 +239,12 @@ func overrideWithEnv(c *Config) { if env := os.Getenv("PG_DSN"); env != "" { c.PG.DSN = env } + if env := os.Getenv("PG_AUTO_MIGRATE"); env != "" { + c.PG.AutoMigrate = env == "true" + } + if env := os.Getenv("PG_CREATE_RAGLITE_DB"); env != "" { + c.PG.CreateRagliteDB = env == "true" + } // nats if env := os.Getenv("MQ_NATS_SERVER"); env != "" { c.MQ.NATS.Server = env @@ -208,10 +268,45 @@ func overrideWithEnv(c *Config) { if env := os.Getenv("SENTRY_DSN"); env != "" { c.Sentry.DSN = env } + // telemetry + if env := os.Getenv("TELEMETRY_ENABLED"); env != "" { + c.Telemetry.Enabled = env == "true" + } + // setup + if env := os.Getenv("INIT_CERT"); env != "" { + c.Setup.InitCert = env == "true" + } + // http + if env := os.Getenv("HTTP_PORT"); env != "" { + if i, err := strconv.Atoi(env); err == nil { + c.HTTP.Port = i + } else { + fmt.Fprintf(os.Stderr, "Invalid http port: %s with err: %s\n", env, err) + } + } + if env := os.Getenv("HEALTH_PORT"); env != "" { + if i, err := strconv.Atoi(env); err == nil { + c.Health.Port = i + } else { + fmt.Fprintf(os.Stderr, "Invalid health port: %s with err: %s\n", env, err) + } + } // caddy api if env := os.Getenv("CADDY_API"); env != "" { c.CaddyAPI = env } + if env := os.Getenv("CADDY_ADMIN_LISTEN"); env != "" { + c.Caddy.AdminListen = env + } + if env := os.Getenv("CADDY_UPSTREAM_API"); env != "" { + c.Caddy.Upstreams.API = env + } + if env := os.Getenv("CADDY_UPSTREAM_APP"); env != "" { + c.Caddy.Upstreams.App = env + } + if env := os.Getenv("CADDY_UPSTREAM_STATIC"); env != "" { + c.Caddy.Upstreams.StaticFile = env + } // log level if env := os.Getenv("LOG_LEVEL"); env != "" { if i, err := strconv.Atoi(env); err == nil { diff --git a/backend/handler/mq/cron.go b/backend/handler/mq/cron.go index 3935079a1..93b764f8b 100644 --- a/backend/handler/mq/cron.go +++ b/backend/handler/mq/cron.go @@ -17,6 +17,7 @@ type CronHandler struct { nodeRepo *pg.NodeRepository statUseCase *usecase.StatUseCase nodeUseCase *usecase.NodeUsecase + cron *cron.Cron } func NewCronHandler(logger *log.Logger, statRepo *pg.StatRepository, nodeRepo *pg.NodeRepository, statUseCase *usecase.StatUseCase, nodeUseCase *usecase.NodeUsecase) (*CronHandler, error) { @@ -26,25 +27,25 @@ func NewCronHandler(logger *log.Logger, statRepo *pg.StatRepository, nodeRepo *p statUseCase: statUseCase, nodeUseCase: nodeUseCase, logger: logger.WithModule("handler.mq.cron"), + cron: cron.New(), } - cron := cron.New() // 每小时 */10 分执行聚合统计数据任务 - if _, err := cron.AddFunc("*/10 */1 * * *", h.AggregateHourlyStats); err != nil { + if _, err := h.cron.AddFunc("*/10 */1 * * *", h.AggregateHourlyStats); err != nil { h.logger.Error("failed to add cron job for aggregating hourly stats", log.Error(err)) return nil, err } h.logger.Info("add cron job", log.String("cron_id", "aggregate_hourly_stats")) // 每小时1分执行清理旧数据任务 - if _, err := cron.AddFunc("1 */1 * * *", h.RemoveOldStatData); err != nil { + if _, err := h.cron.AddFunc("1 */1 * * *", h.RemoveOldStatData); err != nil { h.logger.Error("failed to add cron job for removing old data", log.Error(err)) return nil, err } h.logger.Info("add cron job", log.String("cron_id", "remove_old_stat_data")) // 每天0点执行清理90天前的小时统计数据 - if _, err := cron.AddFunc("3 0 * * *", h.CleanupOldHourlyStats); err != nil { + if _, err := h.cron.AddFunc("3 0 * * *", h.CleanupOldHourlyStats); err != nil { h.logger.Error("failed to add cron job for cleaning up old hourly stats", log.Error(err)) return nil, err } @@ -56,24 +57,33 @@ func NewCronHandler(logger *log.Logger, statRepo *pg.StatRepository, nodeRepo *p h.logger.Error("initial sync rag node status failed", log.Error(err)) } }() - if _, err := cron.AddFunc("26 * * * *", h.SyncRagNodeStatus); err != nil { + if _, err := h.cron.AddFunc("26 * * * *", h.SyncRagNodeStatus); err != nil { h.logger.Error("failed to sync rag node status", log.Error(err)) return nil, err } h.logger.Info("add cron job", log.String("cron_id", "sync_rag_node_status")) // 每天2点执行清理30天前的node_release_backup数据 - if _, err := cron.AddFunc("0 2 * * *", h.CleanupOldNodeReleaseBackups); err != nil { + if _, err := h.cron.AddFunc("0 2 * * *", h.CleanupOldNodeReleaseBackups); err != nil { h.logger.Error("failed to add cron job for cleaning up old node release backups", log.Error(err)) return nil, err } h.logger.Info("add cron job", log.String("cron_id", "cleanup_old_node_release_backups")) - cron.Start() + h.cron.Start() h.logger.Info("start cron jobs") return h, nil } +// Stop stops the scheduler and waits for the jobs that are already running to +// finish, so a shutdown does not leave a job half done. +func (h *CronHandler) Stop() { + if h.cron == nil { + return + } + <-h.cron.Stop().Done() +} + func (h *CronHandler) RemoveOldStatData() { h.logger.Info("remove old stat data start") diff --git a/backend/health/checker.go b/backend/health/checker.go new file mode 100644 index 000000000..e85ded1d8 --- /dev/null +++ b/backend/health/checker.go @@ -0,0 +1,124 @@ +package health + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/chaitin/panda-wiki/domain" + "github.com/chaitin/panda-wiki/log" + "github.com/chaitin/panda-wiki/store/cache" + "github.com/chaitin/panda-wiki/store/pg" + "github.com/chaitin/panda-wiki/store/s3" +) + +// perCheckTimeout bounds a single dependency probe so that readiness stays +// responsive even when a dependency hangs. +const perCheckTimeout = 2 * time.Second + +type check struct { + name string + probe func(context.Context) error +} + +// Checker aggregates dependency probes. Liveness only reports that the process +// is running; readiness reports whether the dependencies are usable. +type Checker struct { + logger *log.Logger + mu sync.RWMutex + checks []check +} + +func NewChecker(logger *log.Logger, db *pg.DB, cache *cache.Cache, minio *s3.MinioClient) *Checker { + c := &Checker{logger: logger.WithModule("health")} + + c.Register("postgres", func(ctx context.Context) error { + // pg.DB embeds *gorm.DB, whose DB() returns the underlying *sql.DB. + sqlDB, err := db.DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) + }) + c.Register("redis", func(ctx context.Context) error { + return cache.Ping(ctx).Err() + }) + c.Register("minio", func(ctx context.Context) error { + exists, err := minio.BucketExists(ctx, domain.Bucket) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("bucket %q does not exist", domain.Bucket) + } + return nil + }) + + return c +} + +// Register adds a named dependency probe. Services register the probes whose +// handles this package does not build itself, such as the MQ connection. +func (c *Checker) Register(name string, probe func(context.Context) error) { + c.mu.Lock() + defer c.mu.Unlock() + c.checks = append(c.checks, check{name: name, probe: probe}) +} + +// Run probes every dependency concurrently and returns the per-dependency +// result keyed by name. The bool is false when at least one probe failed. +func (c *Checker) Run(ctx context.Context) (map[string]string, bool) { + type outcome struct { + name string + err string + } + + c.mu.RLock() + checks := make([]check, len(c.checks)) + copy(checks, c.checks) + c.mu.RUnlock() + + outcomes := make(chan outcome, len(checks)) + for _, ck := range checks { + go func(ck check) { + probeCtx, cancel := context.WithTimeout(ctx, perCheckTimeout) + defer cancel() + if err := ck.probe(probeCtx); err != nil { + outcomes <- outcome{name: ck.name, err: err.Error()} + return + } + outcomes <- outcome{name: ck.name} + }(ck) + } + + reports := make(map[string]string, len(checks)) + healthy := true + for range checks { + res := <-outcomes + if res.err != "" { + reports[res.name] = res.err + healthy = false + continue + } + reports[res.name] = "ok" + } + return reports, healthy +} + +// Connection is the narrow view of a message queue connection the readiness +// probe needs. It keeps this package independent of any concrete MQ client. +type Connection interface { + IsConnected() bool +} + +// RegisterMQProbe registers a readiness probe for a message queue connection. +func (c *Checker) RegisterMQProbe(name string, conn Connection) { + c.Register(name, func(context.Context) error { + if !conn.IsConnected() { + return errors.New("connection is not established") + } + return nil + }) +} diff --git a/backend/health/provider.go b/backend/health/provider.go new file mode 100644 index 000000000..caa854534 --- /dev/null +++ b/backend/health/provider.go @@ -0,0 +1,8 @@ +package health + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet( + NewChecker, + NewServer, +) diff --git a/backend/health/server.go b/backend/health/server.go new file mode 100644 index 000000000..ce21c25e0 --- /dev/null +++ b/backend/health/server.go @@ -0,0 +1,107 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/chaitin/panda-wiki/config" + "github.com/chaitin/panda-wiki/log" +) + +// Server exposes /healthz and /readyz on a dedicated port. It is deliberately +// kept out of the API router so that probe traffic never runs through auth, +// session or request-logging middleware. +type Server struct { + checker *Checker + logger *log.Logger + srv *http.Server +} + +func NewServer(cfg *config.Config, checker *Checker, logger *log.Logger) *Server { + s := &Server{ + checker: checker, + logger: logger.WithModule("health.server"), + } + if cfg.Health.Port == 0 { + return s + } + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleLiveness) + mux.HandleFunc("/readyz", s.handleReadiness) + s.srv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Health.Port), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + return s +} + +// Enabled reports whether a health port was configured. +func (s *Server) Enabled() bool { + return s.srv != nil +} + +// RegisterMQProbe registers a readiness probe for a message queue connection. +// It delegates to the checker so callers only need to hold the server. +func (s *Server) RegisterMQProbe(name string, conn Connection) { + s.checker.RegisterMQProbe(name, conn) +} + +// Start serves until ctx is cancelled, then drains. It is a no-op when no +// health port is configured. +func (s *Server) Start(ctx context.Context) error { + if s.srv == nil { + return nil + } + + errCh := make(chan error, 1) + go func() { + if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + s.logger.Info("health server started", log.String("addr", s.srv.Addr)) + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.srv.Shutdown(shutdownCtx) + } +} + +func (s *Server) handleLiveness(w http.ResponseWriter, _ *http.Request) { + // Liveness must not depend on downstream services: a dependency outage + // should take the pod out of rotation, not restart it. + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) +} + +func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { + reports, healthy := s.checker.Run(r.Context()) + if !healthy { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "status": "unavailable", + "dependencies": reports, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "dependencies": reports, + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + // The status line is already committed, so an encode failure can only be + // reported to the client by the truncated body. + _ = json.NewEncoder(w).Encode(body) +} diff --git a/backend/mq/nats/consumer.go b/backend/mq/nats/consumer.go index 30f0acb74..9d9914880 100644 --- a/backend/mq/nats/consumer.go +++ b/backend/mq/nats/consumer.go @@ -139,14 +139,21 @@ func (c *MQConsumer) StartConsumerHandlers(ctx context.Context) error { return nil } +// IsConnected reports whether the underlying NATS connection is usable. +func (c *MQConsumer) IsConnected() bool { + return c.conn != nil && c.conn.IsConnected() +} + func (c *MQConsumer) Close() error { c.mutex.Lock() defer c.mutex.Unlock() - // close all subscriptions + // Drain rather than unsubscribe so that in-flight messages finish being + // handled and are acked before the connection goes away. Unacked messages + // are redelivered because the subscriptions are durable with explicit acks. for _, sub := range c.handlers { - if err := sub.Unsubscribe(); err != nil { - c.logger.Error("unsubscribe failed", log.Any("error", err)) + if err := sub.Drain(); err != nil { + c.logger.Error("drain subscription failed", log.Any("error", err)) } } diff --git a/backend/mq/nats/producer.go b/backend/mq/nats/producer.go index 2f9928464..5ea8108ce 100644 --- a/backend/mq/nats/producer.go +++ b/backend/mq/nats/producer.go @@ -55,6 +55,14 @@ func (p *MQProducer) EnsureStreams() error { Duplicates: 120 * time.Second, }) if err != nil { + // Another instance may have created the stream between the check + // above and this add, which is expected when several replicas start + // at the same time. Re-check before treating it as a failure. + if _, infoErr := p.js.StreamInfo(stream.name); infoErr == nil { + p.logger.Info("stream already created concurrently", + log.String("stream", stream.name)) + continue + } return fmt.Errorf("failed to create stream %s: %w", stream.name, err) } @@ -103,6 +111,11 @@ func NewMQProducer(config *config.Config, logger *log.Logger) (*MQProducer, erro return producer, nil } +// IsConnected reports whether the underlying NATS connection is usable. +func (p *MQProducer) IsConnected() bool { + return p.conn != nil && p.conn.IsConnected() +} + func (p *MQProducer) Produce(ctx context.Context, topic string, key string, value []byte) error { p.logger.Debug("publishing message", log.String("topic", topic), diff --git a/backend/repo/pg/knowledge_base.go b/backend/repo/pg/knowledge_base.go index e588fe974..fe20a6ffc 100644 --- a/backend/repo/pg/knowledge_base.go +++ b/backend/repo/pg/knowledge_base.go @@ -10,6 +10,7 @@ import ( "maps" "net" "net/http" + "strings" "time" "github.com/google/go-cmp/cmp" @@ -46,22 +47,23 @@ func NewKnowledgeBaseRepository(db *pg.DB, config *config.Config, logger *log.Lo r.logger.Error("failed to get knowledge base list", "error", err) return r } - if len(kbList) > 0 { - if err := r.SyncKBAccessSettingsToCaddy(ctx, kbList); err != nil { - r.logger.Error("failed to sync kb access settings to caddy", "error", err) - } + // Pushed even when there is no knowledge base, so that Caddy drops the routes + // of knowledge bases that have been deleted rather than keeping them. + if err := r.SyncKBAccessSettingsToCaddy(ctx, kbList); err != nil { + r.logger.Error("failed to sync kb access settings to caddy", "error", err) } return r } func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Context, kbList []*domain.KnowledgeBaseListItem) error { - if len(kbList) == 0 { - return nil - } - firstKB := kbList[0] + // An empty list still has to be pushed: the config replaces Caddy's whole + // routing table, so it is the only way Caddy learns to drop the routes of + // knowledge bases that no longer exist. firstHost := "" - if len(firstKB.AccessSettings.Hosts) > 0 { - firstHost = firstKB.AccessSettings.Hosts[0] + if len(kbList) > 0 && len(kbList[0].AccessSettings.Hosts) > 0 { + // The first host of the first knowledge base also serves as the default + // host for its port. + firstHost = kbList[0].AccessSettings.Hosts[0] } certs := make([]map[string]any, 0) portHostKBMap := make(map[string]map[string]*domain.KnowledgeBaseListItem) @@ -92,16 +94,9 @@ func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Contex }) } } - socketPath := r.config.CaddyAPI // sync kb to caddy // create server for each port - subnetPrefix := r.config.SubnetPrefix - if subnetPrefix == "" { - subnetPrefix = "169.254.15" - } - api := fmt.Sprintf("%s.2:8000", subnetPrefix) - app := fmt.Sprintf("%s.112:3010", subnetPrefix) - staticFile := fmt.Sprintf("%s.12:9000", subnetPrefix) // minio + api, app, staticFile := r.caddyUpstreams() servers := make(map[string]any, 0) for port, hostKBMap := range portHostKBMap { trustProxies := make([]string, 0) @@ -312,17 +307,24 @@ func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Contex config := map[string]any{ "apps": apps, } + if listen := r.config.Caddy.AdminListen; listen != "" { + // POST /load replaces the whole config, so the admin listener has to be + // restated here or the endpoint would stop answering after this sync. + config["admin"] = map[string]any{"listen": listen} + } newBody, _ := json.Marshal(config) - tr := &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, + endpoint, transport, err := caddyAdminEndpoint(r.config.CaddyAPI) + if err != nil { + return err } client := &http.Client{ - Transport: tr, + Transport: transport, Timeout: 5 * time.Second, } - req, err := http.NewRequest("POST", "http://unix/load", bytes.NewBuffer(newBody)) + // The transport is built fresh for this one request, so release its + // connections instead of leaving them idle until the transport is collected. + defer client.CloseIdleConnections() + req, err := http.NewRequest("POST", endpoint+"/load", bytes.NewBuffer(newBody)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } @@ -331,6 +333,11 @@ func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Contex if err != nil { return fmt.Errorf("failed to send request: %w", err) } + defer func() { + if err := resp.Body.Close(); err != nil { + r.logger.Error("failed to close caddy config response body", log.Error(err)) + } + }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) r.logger.Error("failed to update caddy config", "error", string(body)) @@ -339,6 +346,74 @@ func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Contex return nil } +// caddyUpstreams returns the reverse_proxy dial targets (host:port, without a +// scheme). Configured values win; anything left empty is derived from +// SubnetPrefix, which is how the compose deployment addresses its containers. +func (r *KnowledgeBaseRepository) caddyUpstreams() (api, app, staticFile string) { + upstreams := r.config.Caddy.Upstreams + + subnetPrefix := r.config.SubnetPrefix + if subnetPrefix == "" { + subnetPrefix = "169.254.15" + } + + api = upstreams.API + if api == "" { + api = fmt.Sprintf("%s.2:8000", subnetPrefix) + } + app = upstreams.App + if app == "" { + app = fmt.Sprintf("%s.112:3010", subnetPrefix) + } + staticFile = upstreams.StaticFile + if staticFile == "" { + staticFile = fmt.Sprintf("%s.12:9000", subnetPrefix) // minio + } + return api, app, staticFile +} + +// caddyAdminEndpoint turns the configured Caddy admin address into a request +// base URL plus the transport that reaches it. A bare path or a unix:// URL +// uses the unix socket the compose deployment mounts into both the api and the +// caddy container; http(s)://, tcp:// and a bare host:port talk to a Caddy +// reachable over the network instead. +func caddyAdminEndpoint(caddyAPI string) (string, http.RoundTripper, error) { + switch { + case caddyAPI == "", strings.HasPrefix(caddyAPI, "/"), strings.HasPrefix(caddyAPI, "unix://"): + socketPath := strings.TrimPrefix(caddyAPI, "unix://") + if socketPath == "" { + return "", nil, errors.New("caddy admin socket path is empty; set caddy_api or CADDY_API") + } + return "http://unix", &http.Transport{ + // A fresh transport is built per sync, so it must not hold + // connections open: see the CloseIdleConnections call in the caller. + DisableKeepAlives: true, + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, nil + case strings.HasPrefix(caddyAPI, "http://"), strings.HasPrefix(caddyAPI, "https://"): + return strings.TrimSuffix(caddyAPI, "/"), caddyAdminTransport(), nil + case strings.HasPrefix(caddyAPI, "tcp://"): + return "http://" + strings.TrimPrefix(caddyAPI, "tcp://"), caddyAdminTransport(), nil + default: + return "http://" + caddyAPI, caddyAdminTransport(), nil + } +} + +// caddyAdminTransport clones the default transport, which keeps its dial +// timeouts, but drops proxy detection: the admin API is an internal endpoint and +// must not be routed through a proxy configured for outbound traffic. +// +// Keep-alives are off because a transport is built for every sync and only used +// for one request; the caller closes idle connections as well. +func caddyAdminTransport() *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DisableKeepAlives = true + return transport +} + func (r *KnowledgeBaseRepository) CreateKnowledgeBase(ctx context.Context, maxKB int, kb *domain.KnowledgeBase) error { authInfo := domain.GetAuthInfoFromCtx(ctx) if authInfo == nil { diff --git a/backend/repo/pg/knowledge_base_test.go b/backend/repo/pg/knowledge_base_test.go new file mode 100644 index 000000000..ea5ce920f --- /dev/null +++ b/backend/repo/pg/knowledge_base_test.go @@ -0,0 +1,84 @@ +package pg + +import ( + "net/http" + "testing" +) + +func TestCaddyAdminEndpoint(t *testing.T) { + tests := []struct { + name string + caddyAPI string + wantBaseURL string + wantErr bool + }{ + { + // The compose default must keep resolving to the shared unix socket. + name: "default socket path", + caddyAPI: "/app/run/caddy-admin.sock", + wantBaseURL: "http://unix", + }, + { + name: "unix scheme", + caddyAPI: "unix:///var/run/caddy/caddy-admin.sock", + wantBaseURL: "http://unix", + }, + { + name: "tcp scheme", + caddyAPI: "tcp://panda-wiki-caddy:2019", + wantBaseURL: "http://panda-wiki-caddy:2019", + }, + { + name: "http url", + caddyAPI: "http://panda-wiki-caddy:2019", + wantBaseURL: "http://panda-wiki-caddy:2019", + }, + { + name: "http url keeps no trailing slash", + caddyAPI: "http://panda-wiki-caddy:2019/", + wantBaseURL: "http://panda-wiki-caddy:2019", + }, + { + name: "https url", + caddyAPI: "https://caddy.example.com", + wantBaseURL: "https://caddy.example.com", + }, + { + name: "bare host and port", + caddyAPI: "panda-wiki-caddy:2019", + wantBaseURL: "http://panda-wiki-caddy:2019", + }, + { + name: "empty value is rejected", + caddyAPI: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseURL, transport, err := caddyAdminEndpoint(tt.caddyAPI) + if tt.wantErr { + if err == nil { + t.Fatalf("caddyAdminEndpoint(%q) expected an error, got none", tt.caddyAPI) + } + return + } + if err != nil { + t.Fatalf("caddyAdminEndpoint(%q) unexpected error: %v", tt.caddyAPI, err) + } + if baseURL != tt.wantBaseURL { + t.Errorf("caddyAdminEndpoint(%q) baseURL = %q, want %q", tt.caddyAPI, baseURL, tt.wantBaseURL) + } + if transport == nil { + t.Errorf("caddyAdminEndpoint(%q) returned a nil transport", tt.caddyAPI) + } + if transport == http.DefaultTransport { + t.Errorf("caddyAdminEndpoint(%q) should not reuse the shared DefaultTransport", tt.caddyAPI) + } + if netTransport, ok := transport.(*http.Transport); ok && netTransport.Proxy != nil { + t.Errorf("caddyAdminEndpoint(%q) must not route the admin API through a proxy", tt.caddyAPI) + } + }) + } +} diff --git a/backend/setup/cert.go b/backend/setup/cert.go index b7a01d2e8..4ad48877a 100644 --- a/backend/setup/cert.go +++ b/backend/setup/cert.go @@ -17,7 +17,12 @@ const ( certFile = "/app/etc/nginx/ssl/panda-wiki.crt" // Certificate file path ) -// check init cert +// CheckInitCert makes sure the self-signed certificate the bundled admin nginx +// reads through a shared volume exists. +// +// Whether to call this at all is the caller's decision, so that the entrypoints +// which build on this package keep compiling unchanged: see setup.init_cert in +// cmd/api. func CheckInitCert() error { // Check both key and cert files keyExists := false diff --git a/backend/store/pg/pg.go b/backend/store/pg/pg.go index fb14d0319..0594560c2 100644 --- a/backend/store/pg/pg.go +++ b/backend/store/pg/pg.go @@ -2,9 +2,11 @@ package pg import ( "database/sql" + "errors" "fmt" "log" "os" + "strings" "time" "github.com/golang-migrate/migrate/v4" @@ -37,23 +39,43 @@ func NewDB(config *config.Config) (*DB, error) { if err != nil { return nil, err } - // create raglite database if not exists - var exists bool - if err := db.Raw("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'raglite')").Scan(&exists).Error; err != nil { - return nil, err - } - if !exists { - if err := db.Exec("CREATE DATABASE raglite").Error; err != nil { + if config.PG.CreateRagliteDB { + if err := createRagliteDB(db); err != nil { return nil, err } } - if err := doMigrate(dsn); err != nil { - return nil, err + if config.PG.AutoMigrate { + if err := doMigrate(dsn); err != nil { + return nil, err + } } return &DB{DB: db}, nil } +// createRagliteDB makes sure the raglite database exists. This is a +// check-then-act, so instances starting at the same time can race; a concurrent +// creation is therefore treated as success rather than as a startup failure. +func createRagliteDB(db *gorm.DB) error { + var exists bool + if err := db.Raw("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'raglite')").Scan(&exists).Error; err != nil { + return err + } + if exists { + return nil + } + if err := db.Exec("CREATE DATABASE raglite").Error; err != nil { + // Postgres answers SQLSTATE 42P04 here. It is matched by message + // instead of by error type so that this stays independent of the + // driver's error implementation. + if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "already exists") { + return nil + } + return err + } + return nil +} + func doMigrate(dsn string) error { db, err := sql.Open("postgres", dsn) if err != nil { diff --git a/backend/store/s3/minio.go b/backend/store/s3/minio.go index b7c7aa999..ca3482138 100644 --- a/backend/store/s3/minio.go +++ b/backend/store/s3/minio.go @@ -40,7 +40,13 @@ func NewMinioClient(config *config.Config) (*MinioClient, error) { Region: "us-east-1", }) if err != nil { - return nil, fmt.Errorf("make bucket: %w", err) + // Several instances may initialise the bucket at the same time, so + // losing that race is not a startup failure as long as the bucket + // is there now. + existsNow, checkErr := minioClient.BucketExists(context.Background(), bucket) + if checkErr != nil || !existsNow { + return nil, fmt.Errorf("make bucket: %w", err) + } } err = minioClient.SetBucketPolicy(context.Background(), bucket, `{ "Version": "2012-10-17", diff --git a/backend/telemetry/client.go b/backend/telemetry/client.go index bde54f7e6..a3d77c9ec 100644 --- a/backend/telemetry/client.go +++ b/backend/telemetry/client.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/google/uuid" @@ -34,6 +35,7 @@ type Client struct { machineID string firstReport bool stopChan chan struct{} + stopOnce sync.Once logger *log.Logger repo *pg.KnowledgeBaseRepository modelUsecase *usecase.ModelUsecase @@ -68,6 +70,13 @@ func NewClient(logger *log.Logger, repo *pg.KnowledgeBaseRepository, modelUsecas aesKey: aesKey, } + if !cfg.Telemetry.Enabled { + // Reporting is off, so there is no need for a machine ID on disk nor + // for the periodic reporter goroutine. + logger.Info("telemetry is disabled") + return client, nil + } + // get or create machine ID machineID, err := client.getOrCreateMachineID() if err != nil { @@ -390,9 +399,11 @@ func (c *Client) isAdminLoggedInYesterday() (bool, error) { return false, nil } -// Stop stops periodic report +// Stop stops periodic report. It is safe to call more than once. func (c *Client) Stop() { - close(c.stopChan) + c.stopOnce.Do(func() { + close(c.stopChan) + }) } // InstallationEvent represents installation event From e9f7fada74dfb6f84d3bc9cbc05d6ed938a87864 Mon Sep 17 00:00:00 2001 From: wangchuan Date: Thu, 8 Oct 2026 06:47:39 +0000 Subject: [PATCH 2/4] fix: stop the admin nginx buffering streaming responses The unbuffered list covered two endpoints and had drifted both ways: /share/v1/chat/widget/search answers with plain JSON, while /share/v1/chat/completions streams when the request asks for it and was missing, so OpenAI-style streaming arrived in a single chunk once generation finished. List the six endpoints that actually answer with text/event-stream, and correct EXPOSE to 8080, the port server.conf listens on. --- web/admin/Dockerfile | 2 +- web/admin/server.conf | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/admin/Dockerfile b/web/admin/Dockerfile index f9d21ac68..7a180fe40 100644 --- a/web/admin/Dockerfile +++ b/web/admin/Dockerfile @@ -3,5 +3,5 @@ COPY dist /opt/frontend/dist COPY server.conf /etc/nginx/conf.d/server.conf COPY nginx.conf /etc/nginx/nginx.conf COPY ssl /etc/nginx/ssl -EXPOSE 80 +EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/web/admin/server.conf b/web/admin/server.conf index 096ea7699..06d2b56de 100644 --- a/web/admin/server.conf +++ b/web/admin/server.conf @@ -13,7 +13,12 @@ server { return 503; } - location ~ ^/(share/v1/chat/message|api/v1/creation/text)$ { + # Every endpoint that answers with text/event-stream. Buffering has to be + # off on all of them or the response only reaches the client once it is + # complete. Keep this list in step with the handlers that set that content + # type: chat message, chat widget, chat completions, the wechat service + # answer, and the two admin-side creation and summary streams. + location ~ ^/(share/v1/chat/message|share/v1/chat/widget|share/v1/chat/completions|share/v1/app/wechat/service/answer|api/v1/creation/text|api/v1/node/summary/stream)$ { proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; From 3d17ee353c4c0f1e82c007a51ca51ea0395e7f7f Mon Sep 17 00:00:00 2001 From: wangchuan Date: Thu, 8 Oct 2026 06:47:40 +0000 Subject: [PATCH 3/4] feat: add a helm chart for kubernetes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys what the docker compose file runs — api, consumer, admin nginx, wiki app, caddy, a migration job, and the raglite and crawler images — plus optionally PostgreSQL, Redis, NATS, MinIO and Qdrant as single-replica StatefulSets. Pointing any of those at a managed service is a values change. - Services are named exactly as the backend hardcodes them, so the Go source needs no change; the cost, one release per namespace, is documented - the api entrypoint is overridden and migrations are off on the serving pods. A Helm hook job runs them instead, after waiting for PostgreSQL, Redis, NATS and MinIO, because the migration binary builds its whole dependency graph on start - the backend config.yml and the admin nginx config are rendered from values, which is also what applies the streaming fix to already-published images - the Caddy admin API sits on a ClusterIP Service of its own, so choosing NodePort or LoadBalancer exposure never publishes an endpoint that accepts unauthenticated config writes - the Secret given as secrets.existingSecret is validated on install and upgrade, because one of its failure modes is silent: the backend only creates the admin account when ADMIN_PASSWORD is non-empty, so a key that exists but is empty would start a console with no way in and no error anywhere. A missing key, or an empty admin-password, now fails while rendering, before anything is created - secrets are generated once and kept across upgrades, and rendering without a cluster connection fails with instructions rather than inventing new values on every render - caddy.resume is on, so a Caddy-only restart keeps the routing table, and the closed-source health endpoints are used for the raglite and qdrant probes - the chart refuses to render values that would fail quietly: a consumer replica count its scheduled jobs cannot support, an Ingress with no hosts or one whose target component is disabled, or a disabled component with no external address. Turning off the migration job or caddy warns through the install notes instead, since running migrations elsewhere is legitimate --- helm/panda-wiki/.helmignore | 8 + helm/panda-wiki/Chart.yaml | 16 + helm/panda-wiki/README.md | 277 ++++++++++ helm/panda-wiki/templates/NOTES.txt | 82 +++ helm/panda-wiki/templates/_backend.tpl | 122 +++++ helm/panda-wiki/templates/_helpers.tpl | 276 ++++++++++ helm/panda-wiki/templates/_wait.tpl | 63 +++ .../templates/configmap-admin-nginx.yaml | 113 ++++ .../templates/configmap-backend.yaml | 40 ++ .../templates/deployment-admin.yaml | 88 +++ helm/panda-wiki/templates/deployment-api.yaml | 128 +++++ helm/panda-wiki/templates/deployment-app.yaml | 74 +++ .../templates/deployment-caddy.yaml | 157 ++++++ .../templates/deployment-consumer.yaml | 96 ++++ .../templates/deployment-crawler.yaml | 88 +++ .../templates/deployment-raglite.yaml | 138 +++++ helm/panda-wiki/templates/deps/minio.yaml | 102 ++++ .../templates/deps/nats-config.yaml | 22 + helm/panda-wiki/templates/deps/nats.yaml | 112 ++++ helm/panda-wiki/templates/deps/postgres.yaml | 100 ++++ helm/panda-wiki/templates/deps/qdrant.yaml | 99 ++++ helm/panda-wiki/templates/deps/redis.yaml | 96 ++++ helm/panda-wiki/templates/ingress.yaml | 88 +++ helm/panda-wiki/templates/job-migrate.yaml | 49 ++ helm/panda-wiki/templates/networkpolicy.yaml | 39 ++ helm/panda-wiki/templates/secret.yaml | 67 +++ helm/panda-wiki/templates/services.yaml | 156 ++++++ helm/panda-wiki/values.schema.json | 150 ++++++ helm/panda-wiki/values.yaml | 510 ++++++++++++++++++ 29 files changed, 3356 insertions(+) create mode 100644 helm/panda-wiki/.helmignore create mode 100644 helm/panda-wiki/Chart.yaml create mode 100644 helm/panda-wiki/README.md create mode 100644 helm/panda-wiki/templates/NOTES.txt create mode 100644 helm/panda-wiki/templates/_backend.tpl create mode 100644 helm/panda-wiki/templates/_helpers.tpl create mode 100644 helm/panda-wiki/templates/_wait.tpl create mode 100644 helm/panda-wiki/templates/configmap-admin-nginx.yaml create mode 100644 helm/panda-wiki/templates/configmap-backend.yaml create mode 100644 helm/panda-wiki/templates/deployment-admin.yaml create mode 100644 helm/panda-wiki/templates/deployment-api.yaml create mode 100644 helm/panda-wiki/templates/deployment-app.yaml create mode 100644 helm/panda-wiki/templates/deployment-caddy.yaml create mode 100644 helm/panda-wiki/templates/deployment-consumer.yaml create mode 100644 helm/panda-wiki/templates/deployment-crawler.yaml create mode 100644 helm/panda-wiki/templates/deployment-raglite.yaml create mode 100644 helm/panda-wiki/templates/deps/minio.yaml create mode 100644 helm/panda-wiki/templates/deps/nats-config.yaml create mode 100644 helm/panda-wiki/templates/deps/nats.yaml create mode 100644 helm/panda-wiki/templates/deps/postgres.yaml create mode 100644 helm/panda-wiki/templates/deps/qdrant.yaml create mode 100644 helm/panda-wiki/templates/deps/redis.yaml create mode 100644 helm/panda-wiki/templates/ingress.yaml create mode 100644 helm/panda-wiki/templates/job-migrate.yaml create mode 100644 helm/panda-wiki/templates/networkpolicy.yaml create mode 100644 helm/panda-wiki/templates/secret.yaml create mode 100644 helm/panda-wiki/templates/services.yaml create mode 100644 helm/panda-wiki/values.schema.json create mode 100644 helm/panda-wiki/values.yaml diff --git a/helm/panda-wiki/.helmignore b/helm/panda-wiki/.helmignore new file mode 100644 index 000000000..b647f4b4e --- /dev/null +++ b/helm/panda-wiki/.helmignore @@ -0,0 +1,8 @@ +.DS_Store +.git/ +.gitignore +*.tmproj +.idea/ +.vscode/ +*.swp +ci/ diff --git a/helm/panda-wiki/Chart.yaml b/helm/panda-wiki/Chart.yaml new file mode 100644 index 000000000..70a646b0d --- /dev/null +++ b/helm/panda-wiki/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: panda-wiki +description: PandaWiki - an AI knowledge base system +type: application +version: 0.1.0 +appVersion: "v3.87.3" +home: https://github.com/chaitin/PandaWiki +sources: + - https://github.com/chaitin/PandaWiki +keywords: + - wiki + - knowledge-base + - rag + - ai +annotations: + category: Knowledge Management diff --git a/helm/panda-wiki/README.md b/helm/panda-wiki/README.md new file mode 100644 index 000000000..367f15b79 --- /dev/null +++ b/helm/panda-wiki/README.md @@ -0,0 +1,277 @@ +# PandaWiki Helm chart + +Deploys [PandaWiki](https://github.com/chaitin/PandaWiki) on Kubernetes: the api, +the consumer, the admin console, the public wiki sites, the Caddy gateway, and the +closed-source raglite/crawler images, plus optionally PostgreSQL, Redis, NATS, +MinIO and Qdrant. + +```bash +helm install panda-wiki ./helm/panda-wiki -n panda-wiki --create-namespace +``` + +Do not run two releases in the same namespace: see [Fixed names](#fixed-names). + +## What it deploys + +| Component | Kind | Notes | +|---|---|---| +| `panda-wiki-api` | Deployment, port 8000 | Migrations disabled; probes on 8081 | +| `panda-wiki-consumer` | Deployment, 1 replica | Scheduled jobs, no distributed lock | +| `panda-wiki-nginx` | Deployment, port 8080 TLS | Admin SPA and api proxy | +| `panda-wiki-app` | Deployment, port 3010 | Next.js wiki sites | +| `panda-wiki-caddy` | Deployment, 1 replica | Dynamic per-knowledge-base routing | +| `panda-wiki-raglite` | Deployment, port 5050 | RAG service (closed source) | +| `panda-wiki-crawler` | Deployment, port 8080 | Document parser (closed source) | +| `panda-wiki-migrate` | Job | Helm hook, runs before install/upgrade | +| postgres / redis / nats / minio / qdrant | StatefulSet | Optional, see `*.enabled` | + +## Configuration + +Everything lives in `values.yaml`, which is commented. The parts worth calling +out: + +- **Middleware** — each of `postgres`, `redis`, `nats`, `minio`, `qdrant` has an + `enabled` flag and an `external` block. Setting `enabled: false` requires the + matching `external.*` addresses, and the chart fails fast if one is missing. +- **Secrets** — leave `secrets.*` empty and the chart generates them on first + install and keeps them across upgrades, by looking up what it stored earlier. + Set `secrets.existingSecret` to a Secret you manage instead: it holds + `jwt-secret` and `admin-password`, plus the middleware keys the enabled + components read from it (`postgres-password`, `redis-password`, + `nats-password`, `s3-secret-key`, `qdrant-api-key`). A component with its own + `auth.existingSecret` takes its password from there instead. The chart validates + that Secret on install and upgrade whenever it can reach the cluster, and fails + if a key is missing or if `admin-password` is empty. + + That last check matters because of a silent failure mode: the backend only + creates the admin account when `ADMIN_PASSWORD` is non-empty, so an empty value + starts a console with no way in and no error anywhere. A missing key fails + loudly on its own, the kubelet refuses to start the container. + + The backend also re-applies `ADMIN_PASSWORD` to the admin account on every api + start, so the value in the Secret wins over a password changed in the console. + + Generation needs a cluster connection to read back what an earlier install + stored. When the chart is rendered **without** one — `helm template`, Argo CD, + Flux, `helm lint` with no values — it refuses to invent values instead of + producing new ones on every render, which would rotate the admin password and + the JWT secret, and with the bundled middleware the database password too. Pass + `secrets.existingSecret`, or the individual values, in that case. +- **Backend settings** — `backend.config` is rendered into a `config.yml` mounted + at `/app/config.yml`. Only settings without an environment variable need to go + there (the NATS user and the S3 access key are the two that matter); everything + else is passed as an environment variable so passwords stay in the Secret. + +## Fixed names + +The backend hardcodes the service names it calls (`panda-wiki-minio`, +`panda-wiki-api`, `panda-wiki-crawler`, `panda-wiki-postgres`, +`panda-wiki-redis`) in its Go source, and the admin nginx proxies to +`panda-wiki-api`. Those Service names are therefore **not** release-scoped, so +one release per namespace is a hard requirement. + +## Caddy and entry traffic + +Caddy receives the whole routing table from the backend over its admin API and +holds it in memory. Two consequences shape this chart: + +1. **Caddy runs a single replica.** The backend pushes config to one instance + right after it starts and on every knowledge base change, so a second replica + would serve a stale table. +2. **`caddy.exposure` decides how entry traffic is published.** + - `hostNetwork` (default) mirrors the official docker compose deployment. + Caddy binds host ports directly, which is what lets a knowledge base use + **any port** the admin picks in the console. Cost: the pod is pinned to one + node, that node is a single point of failure, and `caddy.nodeSelector` + should be used to choose it deliberately. + + The port has to be free on that node. Caddy starts with no server at all — + the Caddyfile in the image has every line commented out — so it only binds + its admin port until the backend pushes a routing table, and a `hostNetwork` + install therefore succeeds on a node where something else already holds 80 + and 443, such as an ingress controller. What fails instead is publishing a + knowledge base on a taken port: Caddy rejects the whole configuration rather + than binding part of it, keeps serving what it had, and the console reports + the sync failure. Give that knowledge base a free port, or use another + exposure. + - `NodePort` / `LoadBalancer` are more cloud-native but only publish the ports + listed in `caddy.servicePorts` and `caddy.extraPorts`. A knowledge base + configured on any other port will not be reachable. + + Choose `hostNetwork` unless you know every knowledge base will stay on 80/443. + +3. **`caddy.resume` is on by default, and verified.** Caddy keeps its routing + table in memory and reloads it from its autosave file, so a Caddy-only restart + no longer loses the routes the backend pushed. Without an autosave file Caddy + logs `no autosave file exists` and falls back to the Caddyfile baked into the + image, so a first install still starts. Turn it off only if you want a restart + to revert to that (empty) Caddyfile. + +**The Caddy admin API must not be reachable from outside the cluster.** It +accepts unauthenticated full config writes. The chart keeps its port on a +separate ClusterIP Service so NodePort, LoadBalancer and ClusterIP exposures +never publish it — but `hostNetwork` binds it on the node's own interfaces, and +neither a Service setting nor a NetworkPolicy (traffic arrives through the host +network namespace) can contain that. Use a host firewall, or a non-hostNetwork +exposure, if the node's network is not trusted. + +The admin console is a separate entry point from the wiki sites: point an +Ingress at `panda-wiki-nginx`, or use the `ingress.admin` block. The container +terminates TLS itself on 8080, so the Ingress must speak HTTPS to it +(`backend-protocol: HTTPS`, `proxy-ssl-verify: off`). + +## Scale and availability + +- **`api`** scales horizontally. Sessions live in Redis and the api keeps no + local state that matters: the `/data` volume only holds a telemetry machine id, + and telemetry is off by default here. +- **`consumer` must stay at one replica.** Its five scheduled jobs plus a + startup sync have no distributed lock, so extra replicas repeat them. The chart + refuses to render `replicaCount > 1`. Implementing + `consumer.cron.distributedLock` is the prerequisite for raising it. +- **`caddy` is a single point of failure** for all wiki sites, by design above. +- **`raglite`, `qdrant`, `nats`, `postgres`, `redis` and `minio` run as + single-replica StatefulSets**, which is not a highly available topology. Point + the chart at managed services through the `external` blocks for production. + +## Migrations + +The api image's entrypoint runs the migration binary before the api +(`sh -c "/app/panda-wiki-migrate && /app/panda-wiki-api"`). That would make every +pod run migrations on start, so the chart: + +- overrides the api command to `/app/panda-wiki-api`, and +- sets `PG_AUTO_MIGRATE=false` / `PG_CREATE_RAGLITE_DB=false` on the api and the + consumer, and +- runs `panda-wiki-migrate` from a Helm hook job instead, with `workingDir: /app` + because the migration files are read through the relative path `file://migration`. + +The job is a `post-install,pre-upgrade` hook rather than a `pre-install` one: Helm +runs `pre-install` hooks *before* it creates any resource, so on a fresh install +with the bundled middleware the job would wait for PostgreSQL, Redis, NATS and +MinIO that do not exist yet. Running it after the resources are created lets the +job's wait-for-dependencies container cover the gap until they accept connections. + +The job waits for PostgreSQL, Redis, NATS and MinIO first: the migration binary +builds its whole dependency graph on startup, so it fails if any of them is +unreachable. + +`panda-wiki` also needs the `CREATEDB` privilege to create the `raglite` +database. The bundled PostgreSQL grants it; a managed server usually does not, so +set `postgres.external.createRagliteDB: false` and create the database up front. + +## Images must be built from this repository + +The published `panda-wiki-api` and `panda-wiki-consumer` images **predate the +backend changes this chart depends on**. Verified against +`chaitin-registry.../chaitin/panda-wiki-api:v3.87.3`: the binary contains none of +`auto_migrate`, `create_raglite_db`, `caddy_admin_listen` or `health.port`, has no +`/healthz` or `/readyz` route, and still dials the Caddy admin API as a **unix +socket** (`caddy-admin.sock`). + +What that means in practice, with published images: + +| Setting | Effect with a published image | +|---|---| +| `api.probes.mode: http` | Never succeeds: `/healthz` does not exist, so the liveness probe kills the container in a loop. Keep the default `tcp`. | +| `CADDY_API=http://panda-wiki-caddy:2019` | Fails with `dial unix http://panda-wiki-caddy:2019: no such file or directory`, so **publishing a knowledge base fails** with "保存配置失败". The api and Caddy would have to share a unix socket volume instead. | +| `PG_AUTO_MIGRATE`, `PG_CREATE_RAGLITE_DB` | Ignored, so every pod runs migrations on start — the behaviour this chart is designed to avoid. | +| `TELEMETRY_ENABLED`, `INIT_CERT` | Ignored; telemetry stays on and the api still writes a self-signed certificate. | + +Build the api and consumer images from this repository to make the above take +effect. `caddy.exposure: hostNetwork` has the same problem from the other side: +it is what compose uses, but the shipped `CADDY_ADMIN` unix socket only works if +the api can see that socket. + +## Verified on a real cluster + +Checked on a single-node k8s v1.30 cluster, with locally rebuilt api and consumer +images (see above), by a real knowledge base creation driven through the API: + +- The routing push works end to end. Caddy's runtime config gained a server on + `:80` with the knowledge base's host rule plus the default rule, with the + upstreams taken from `CADDY_UPSTREAM_*` (`panda-wiki-api:8000`, + `panda-wiki-app:3010`, `panda-wiki-minio:9000`), and `http://:/` + with the knowledge base's Host header answered 200. +- The `admin` listener survives a full config load: the pushed config restated + `0.0.0.0:2019` and the admin API kept answering. +- `/healthz` and `/readyz` work, with readiness reporting + `{"minio":"ok","nats":"ok","postgres":"ok","redis":"ok"}`. +- **The baked `Caddyfile` is the stock template with every line commented out.** + There is no `admin` directive, which is why `CADDY_ADMIN` takes effect, and no + site definition, so a fresh Caddy has no routes at all — everything comes from + the backend's push. +- **`caddy.resume` is safe**, which is why it now defaults to true. With an + autosave present, a Caddy-only restart keeps the routing table. Without one, + Caddy logs `no autosave file exists` and falls back to the Caddyfile, so a + first install still starts. +- **No asset-prefix rewrite is needed.** Next.js emits + `/panda-wiki-app-assets/_next/static/...` and the standalone server answers all + of those with 200 through the gateway. +- HTTP health paths exist on two of the closed-source images: raglite answers + `/health`, qdrant answers `/healthz`, `/readyz` and `/livez`. Crawler answers + none of the usual paths, so it keeps a TCP probe. +- Migrations run from the hook job (32 tables created), and a knowledge base + creation reaches raglite, the database and the Caddy push. + +### A pre-existing bug this uncovered, now fixed + +Deleting the **last** knowledge base used to leave its rule in Caddy: +`SyncKBAccessSettingsToCaddy` returned early when the knowledge base list was +empty, so nothing ever told Caddy to drop that server. Reproduced directly, with +zero rows in `knowledge_bases` and Caddy still holding the deleted knowledge +base's host rule. + +It is fixed in the backend — an empty list is now pushed, which is what makes +Caddy drop the removed routes — and verified on the test cluster: creating a +knowledge base adds its server to Caddy and deleting it clears the server list. +The early return had also been hiding an unchecked `kbList[0]`, which needed a +guard of its own once the early return went away. + +## Still unverified + +- **`/run/pandawiki`**, a host path the official compose file bind-mounts, is not + mounted here; its purpose is unknown. +- **A knowledge base on a custom port.** Only host rules on the enumerated ports + were exercised; `NodePort`/`LoadBalancer` exposure cannot publish arbitrary + ports, and `hostNetwork` was not usable on the test cluster. +- **Multi-replica behaviour.** The api ran at one replica; the distributed lock + for the consumer's scheduled jobs is still unimplemented by design. +- **Adding an AI model does not register it with raglite.** Creating an embedding + model through `/api/v1/model` stored it in the panda-wiki database only; + raglite's own `ai_models` table stayed empty and dataset creation kept failing + with `no default embedding model available` until a default row was inserted + there by hand. Whether the console's own model configuration flow does that + registration was not driven, since it needs a real model credential. + +## Verifying a deployment + +```bash +helm lint helm/panda-wiki + +# Rendering offline cannot generate secrets (see Configuration above), so pass +# them; with a cluster connection helm install/upgrade does not need this. +helm template panda-wiki helm/panda-wiki \ + --set secrets.existingSecret=panda-wiki-secrets | + kubectl apply --dry-run=server -f - + +# everything ready +kubectl -n panda-wiki get pods + +# readiness reflects dependencies: stop Redis and this turns 503 without the pod +# being restarted +kubectl -n panda-wiki exec deploy/panda-wiki-api -- wget -qO- localhost:8081/readyz + +# streaming survives a rollout: start an AI answer, then +kubectl -n panda-wiki rollout restart deploy/panda-wiki-api + +# routes survive a Caddy restart because resume is on by default +kubectl -n panda-wiki delete pod -l app.kubernetes.io/component=caddy + +# the Caddy admin API is not reachable from outside the cluster +kubectl -n panda-wiki get svc panda-wiki-caddy-admin -o jsonpath='{.spec.type}' +``` + +## Requirements + +Kubernetes 1.24+ (the chart uses `policy/v1` PodDisruptionBudget), Helm 3.9+. diff --git a/helm/panda-wiki/templates/NOTES.txt b/helm/panda-wiki/templates/NOTES.txt new file mode 100644 index 000000000..310240c5e --- /dev/null +++ b/helm/panda-wiki/templates/NOTES.txt @@ -0,0 +1,82 @@ +PandaWiki has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}". + +1. Admin console +{{- if .Values.ingress.admin.enabled }} +{{- range .Values.ingress.admin.hosts }} + https://{{ .host }} +{{- end }} +{{- else }} + The admin container serves HTTPS on port {{ .Values.admin.port }}, so reach it + through a port-forward (your browser will warn about the self-signed + certificate, which is expected): + + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "panda-wiki.name" . }}-nginx 8443:{{ .Values.admin.service.port }} + open https://localhost:8443 +{{- end }} + + Username: admin +{{- if .Values.secrets.existingSecret }} + Password: from your own Secret "{{ .Values.secrets.existingSecret }}" (key admin-password) +{{- else }} + Password: + kubectl -n {{ .Release.Namespace }} get secret {{ include "panda-wiki.secretName" . }} -o jsonpath='{.data.admin-password}' | base64 -d; echo +{{- end }} + + The backend re-applies that password to the admin account on every api start, + so this value wins over a password changed in the console. + +2. Wiki sites +{{- if eq .Values.caddy.exposure "hostNetwork" }} + Caddy runs with hostNetwork, so knowledge bases are reachable on the node's + IP address at the port configured for each knowledge base: + + node IP -> {{ .Values.caddy.servicePorts | first | default dict | dig "port" 80 }} / 443 and any custom knowledge base port + + Point your DNS at the node running the caddy pod: + kubectl -n {{ .Release.Namespace }} get pod -l app.kubernetes.io/component=caddy -o wide +{{- else }} + Caddy is published through the "{{ include "panda-wiki.name" . }}-caddy" Service + (type {{ .Values.caddy.exposure }}). Only the ports listed in + caddy.servicePorts and caddy.extraPorts are reachable; a knowledge base on any + other port will not be reachable until you add it there — or switch + caddy.exposure to hostNetwork. +{{- end }} + +3. Things worth knowing + + * Run one release per namespace. The backend and the admin nginx address each + other by fixed service names, so two releases in one namespace collide. + + * The consumer runs a single replica on purpose: its scheduled jobs have no + distributed lock. Raising consumer.replicaCount without implementing + consumer.cron.distributedLock would repeat every job once per replica. + + * Migrations belong to the "{{ include "panda-wiki.name" . }}-migrate" job, + which runs on install and upgrade. The api and consumer pods start with + migrations disabled. + +{{- if not .Values.caddy.resume }} + + * caddy.resume is off, so a Caddy restart on its own drops the routing table + until the api restarts or a knowledge base changes (that is when the backend + repushes it). Turn caddy.resume on to reload the autosaved config instead. +{{- end }} + +{{- if not .Values.migration.enabled }} + + * migration.enabled is false and the api and consumer pods run with + pg.auto_migrate off, so NOTHING applies the schema. Run the migrations + yourself, or set migration.enabled back to true. +{{- end }} + +{{- if not .Values.caddy.enabled }} + + * caddy.enabled is false, so nothing publishes the wiki sites and the api has + no gateway to push routing to. Knowledge bases will fail to publish. +{{- end }} + +{{- if .Values.backend.telemetry.enabled }} + + * backend.telemetry.enabled is on: this installation reports usage back to + baizhi.cloud. Turn it off for a private deployment. +{{- end }} diff --git a/helm/panda-wiki/templates/_backend.tpl b/helm/panda-wiki/templates/_backend.tpl new file mode 100644 index 000000000..d781841a6 --- /dev/null +++ b/helm/panda-wiki/templates/_backend.tpl @@ -0,0 +1,122 @@ +{{/* +Environment shared by the api, the consumer and the migration job. + +Usage: include "panda-wiki.backendEnv" (dict "root" $ "role" "api") +role is one of api, consumer, migrate and decides the migration switches: only +the migration job is allowed to touch the schema. +*/}} +{{- define "panda-wiki.backendEnv" -}} +{{- $root := .root -}} +{{- $name := include "panda-wiki.name" $root -}} +{{- $isMigrate := eq .role "migrate" -}} +- name: TZ + value: {{ $root.Values.global.timezone | quote }} +- name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.postgresSecretName" $root }} + key: {{ include "panda-wiki.postgresSecretKey" $root }} +- name: PG_DSN + value: {{ include "panda-wiki.pgDsn" $root | quote }} +{{- if $isMigrate }} +- name: PG_AUTO_MIGRATE + value: "true" +{{- if $root.Values.postgres.enabled }} +- name: PG_CREATE_RAGLITE_DB + value: "true" +{{- else }} +# An external server may not grant CREATEDB; skip creating the raglite database +# and have a DBA create it up front instead. +- name: PG_CREATE_RAGLITE_DB + value: {{ $root.Values.postgres.external.createRagliteDB | quote }} +{{- end }} +{{- else }} +# Migrations run in the migration job, never from a serving pod. +- name: PG_AUTO_MIGRATE + value: "false" +- name: PG_CREATE_RAGLITE_DB + value: "false" +{{- end }} +- name: REDIS_ADDR + value: {{ include "panda-wiki.redisAddr" $root | quote }} +- name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.redisSecretName" $root }} + key: {{ include "panda-wiki.redisSecretKey" $root }} +- name: MQ_NATS_SERVER + value: {{ include "panda-wiki.natsServer" $root | quote }} +- name: NATS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.natsSecretName" $root }} + key: {{ include "panda-wiki.natsSecretKey" $root }} +- name: S3_ENDPOINT + value: {{ include "panda-wiki.s3Endpoint" $root | quote }} +- name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.s3SecretName" $root }} + key: {{ include "panda-wiki.s3SecretKey" $root }} +- name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.secretName" $root }} + key: jwt-secret +- name: RAG_CT_RAG_BASE_URL + value: {{ include "panda-wiki.ragBaseURL" $root | quote }} +{{- if $root.Values.caddy.enabled }} +# The admin Service is ClusterIP regardless of how entry traffic is published, +# so the unauthenticated admin API is never exposed outside the cluster. +- name: CADDY_API + value: {{ printf "http://%s-caddy-admin:%v" $name $root.Values.caddy.adminPort | quote }} +- name: CADDY_ADMIN_LISTEN + value: {{ printf "0.0.0.0:%v" $root.Values.caddy.adminPort | quote }} +- name: CADDY_UPSTREAM_API + value: {{ printf "%s-api:%v" $name $root.Values.api.port | quote }} +- name: CADDY_UPSTREAM_APP + value: {{ printf "%s-app:%v" $name $root.Values.app.port | quote }} +- name: CADDY_UPSTREAM_STATIC + value: {{ include "panda-wiki.s3Endpoint" $root | quote }} +{{- end }} +- name: TELEMETRY_ENABLED + value: {{ $root.Values.backend.telemetry.enabled | quote }} +- name: INIT_CERT + value: {{ $root.Values.backend.setup.initCert | quote }} +- name: SENTRY_ENABLED + value: {{ $root.Values.backend.sentry.enabled | quote }} +{{- with $root.Values.backend.sentry.dsn }} +- name: SENTRY_DSN + value: {{ . | quote }} +{{- end }} +{{- if eq .role "api" }} +- name: ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.secretName" $root }} + key: admin-password +{{- end }} +{{- end -}} + +{{/* +Volume mounts and volumes shared by the api, the consumer and the migration job. + +The api writes a self-signed certificate and a telemetry machine id under these +paths when their switches are on. Both are disabled by default in this chart, but +an emptyDir keeps a pod from failing to start if one is re-enabled. +*/}} +{{- define "panda-wiki.backendVolumes" -}} +- name: data + emptyDir: {} +- name: config + configMap: + name: {{ include "panda-wiki.name" . }}-config +{{- end -}} + +{{- define "panda-wiki.backendVolumeMounts" -}} +- name: data + mountPath: /data +- name: config + mountPath: /app/config.yml + subPath: config.yml +{{- end -}} diff --git a/helm/panda-wiki/templates/_helpers.tpl b/helm/panda-wiki/templates/_helpers.tpl new file mode 100644 index 000000000..de64509d9 --- /dev/null +++ b/helm/panda-wiki/templates/_helpers.tpl @@ -0,0 +1,276 @@ +{{/* +The application addresses its own components by fixed names (see the note in +values.yaml), so resource names are not release-scoped. +*/}} +{{- define "panda-wiki.name" -}}panda-wiki{{- end -}} + +{{- define "panda-wiki.labels" -}} +app.kubernetes.io/name: {{ include "panda-wiki.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: panda-wiki +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "panda-wiki.selectorLabels" -}} +app.kubernetes.io/name: {{ include "panda-wiki.name" .root }} +app.kubernetes.io/instance: {{ .root.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{/* +Build an image reference from a component's image block, falling back to the +shared tag in .Values.image.tag. +Usage: include "panda-wiki.image" (dict "root" $ "image" .Values.api.image) +*/}} +{{- define "panda-wiki.image" -}} +{{- $registry := .root.Values.global.imageRegistry -}} +{{- $tag := .image.tag | default .root.Values.image.tag -}} +{{- printf "%s/%s:%s" $registry .image.repository $tag -}} +{{- end -}} + +{{- define "panda-wiki.imagePullPolicy" -}} +{{- .image.pullPolicy | default .root.Values.image.pullPolicy -}} +{{- end -}} + +{{- define "panda-wiki.imagePullSecrets" -}} +{{- with .Values.global.imagePullSecrets }} +imagePullSecrets: +{{- toYaml . | nindent 2 }} +{{- end }} +{{- end -}} + +{{- define "panda-wiki.storageClass" -}} +{{- with .Values.global.storageClass -}} +storageClassName: {{ . | quote }} +{{- end -}} +{{- end -}} + +{{/* +Secret holding the credentials: the one the user manages when +secrets.existingSecret is set, otherwise the one the chart generates. +*/}} +{{- define "panda-wiki.secretName" -}} +{{- if .Values.secrets.existingSecret -}} +{{- .Values.secrets.existingSecret -}} +{{- else -}} +{{- printf "%s-secrets" (include "panda-wiki.name" .) -}} +{{- end -}} +{{- end -}} + +{{/* +Resolve one generated secret. + +Resolution order: the explicit value from values.yaml, then whatever is already +in the cluster (so an upgrade keeps the password the database was initialised +with), then a fresh random value. + +This is only called from the Secret template. Every other workload reads the +result through secretKeyRef, because calling it twice would generate two +different random values. + +Usage: include "panda-wiki.secretValue" (dict "root" $ "key" "postgres-password" "value" .Values.secrets.postgresPassword) +*/}} +{{- define "panda-wiki.secretValue" -}} +{{- if .value -}} +{{- .value -}} +{{- else -}} +{{- $existing := lookup "v1" "Secret" .root.Release.Namespace (include "panda-wiki.secretName" .root) -}} +{{- $data := dict -}} +{{- if $existing -}} +{{- $data = $existing.data | default dict -}} +{{- end -}} +{{- if hasKey $data .key -}} +{{- index $data .key | b64dec -}} +{{- else -}} +{{- randAlphaNum 40 -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Connection addresses. Each falls back to the in-cluster Service DNS name, which +matches the defaults the backend ships with. +*/}} +{{- define "panda-wiki.postgresHost" -}} +{{- if .Values.postgres.enabled -}} +{{- printf "%s-postgres" (include "panda-wiki.name" .) -}} +{{- else -}} +{{- required "postgres.external.host is required when postgres.enabled is false" .Values.postgres.external.host -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.postgresUser" -}} +{{- if .Values.postgres.enabled -}} +{{- .Values.postgres.auth.username -}} +{{- else -}} +{{- required "postgres.external.username is required when postgres.enabled is false" .Values.postgres.external.username -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.postgresDatabase" -}} +{{- if .Values.postgres.enabled -}} +{{- .Values.postgres.auth.database -}} +{{- else -}} +{{- required "postgres.external.database is required when postgres.enabled is false" .Values.postgres.external.database -}} +{{- end -}} +{{- end -}} + +{{/* Secret holding the PostgreSQL password, and the key inside it. */}} +{{- define "panda-wiki.postgresSecretName" -}} +{{- if .Values.postgres.enabled -}} +{{- if .Values.postgres.auth.existingSecret -}} +{{- .Values.postgres.auth.existingSecret -}} +{{- else -}} +{{- include "panda-wiki.secretName" . -}} +{{- end -}} +{{- else if .Values.postgres.external.existingSecret -}} +{{- .Values.postgres.external.existingSecret -}} +{{- else -}} +{{- include "panda-wiki.secretName" . -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.postgresSecretKey" -}} +{{- if .Values.postgres.enabled -}} +{{- if .Values.postgres.auth.existingSecret -}} +password +{{- else -}} +postgres-password +{{- end -}} +{{- else -}} +{{- if .Values.postgres.external.existingSecret -}} +password +{{- else -}} +postgres-password +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.redisAddr" -}} +{{- if .Values.redis.enabled -}} +{{- printf "%s-redis:6379" (include "panda-wiki.name" .) -}} +{{- else -}} +{{- required "redis.external.addr is required when redis.enabled is false" .Values.redis.external.addr -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.redisSecretName" -}} +{{- $custom := "" -}} +{{- if .Values.redis.enabled -}}{{- $custom = .Values.redis.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.redis.external.existingSecret -}}{{- end -}} +{{- if $custom -}}{{- $custom -}}{{- else -}}{{- include "panda-wiki.secretName" . -}}{{- end -}} +{{- end -}} + +{{- define "panda-wiki.redisSecretKey" -}} +{{- $custom := "" -}} +{{- if .Values.redis.enabled -}}{{- $custom = .Values.redis.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.redis.external.existingSecret -}}{{- end -}} +{{- if $custom -}}password{{- else -}}redis-password{{- end -}} +{{- end -}} + +{{- define "panda-wiki.natsServer" -}} +{{- if .Values.nats.enabled -}} +{{- printf "nats://%s-nats:4222" (include "panda-wiki.name" .) -}} +{{- else -}} +{{- required "nats.external.server is required when nats.enabled is false" .Values.nats.external.server -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.natsUser" -}} +{{- if .Values.nats.enabled -}} +{{- .Values.nats.auth.username -}} +{{- else -}} +{{- .Values.nats.external.username -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.natsSecretName" -}} +{{- $custom := "" -}} +{{- if .Values.nats.enabled -}}{{- $custom = .Values.nats.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.nats.external.existingSecret -}}{{- end -}} +{{- if $custom -}}{{- $custom -}}{{- else -}}{{- include "panda-wiki.secretName" . -}}{{- end -}} +{{- end -}} + +{{- define "panda-wiki.natsSecretKey" -}} +{{- $custom := "" -}} +{{- if .Values.nats.enabled -}}{{- $custom = .Values.nats.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.nats.external.existingSecret -}}{{- end -}} +{{- if $custom -}}password{{- else -}}nats-password{{- end -}} +{{- end -}} + +{{/* MinIO/S3 object storage used for uploaded files. */}} +{{- define "panda-wiki.s3Endpoint" -}} +{{- if .Values.minio.enabled -}} +{{- printf "%s-minio:9000" (include "panda-wiki.name" .) -}} +{{- else -}} +{{- required "minio.external.endpoint is required when minio.enabled is false" .Values.minio.external.endpoint -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.s3AccessKey" -}} +{{- if .Values.minio.enabled -}} +{{- .Values.minio.auth.accessKey -}} +{{- else -}} +{{- required "minio.external.accessKey is required when minio.enabled is false" .Values.minio.external.accessKey -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.s3SecretName" -}} +{{- $custom := "" -}} +{{- if .Values.minio.enabled -}}{{- $custom = .Values.minio.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.minio.external.existingSecret -}}{{- end -}} +{{- if $custom -}}{{- $custom -}}{{- else -}}{{- include "panda-wiki.secretName" . -}}{{- end -}} +{{- end -}} + +{{- define "panda-wiki.s3SecretKey" -}} +{{- $custom := "" -}} +{{- if .Values.minio.enabled -}}{{- $custom = .Values.minio.auth.existingSecret -}}{{- else -}}{{- $custom = .Values.minio.external.existingSecret -}}{{- end -}} +{{- if $custom -}}secretKey{{- else -}}s3-secret-key{{- end -}} +{{- end -}} + +{{/* Qdrant is only consumed by raglite. */}} +{{- define "panda-wiki.qdrantHost" -}} +{{- printf "%s-qdrant" (include "panda-wiki.name" .) -}} +{{- end -}} + +{{- define "panda-wiki.ragBaseURL" -}} +{{- if .Values.raglite.enabled -}} +{{- printf "http://%s-raglite:%v" (include "panda-wiki.name" .) .Values.raglite.port -}} +{{- else -}} +{{- required "raglite.external.baseURL is required when raglite.enabled is false" .Values.raglite.external.baseURL -}} +{{- end -}} +{{- end -}} + +{{/* +host:port forms, used by the migration job's wait-for-dependencies container. +*/}} +{{- define "panda-wiki.postgresAddr" -}} +{{- if .Values.postgres.enabled -}} +{{- printf "%s:5432" (include "panda-wiki.postgresHost" .) -}} +{{- else -}} +{{- printf "%s:%v" (include "panda-wiki.postgresHost" .) .Values.postgres.external.port -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.natsAddr" -}} +{{- if .Values.nats.enabled -}} +{{- printf "%s-nats:4222" (include "panda-wiki.name" .) -}} +{{- else -}} +{{- .Values.nats.external.server | trimPrefix "nats://" | trimPrefix "tls://" -}} +{{- end -}} +{{- end -}} + +{{- define "panda-wiki.s3Addr" -}} +{{- include "panda-wiki.s3Endpoint" . -}} +{{- end -}} + +{{/* +PostgreSQL DSN. The password is left as $(POSTGRES_PASSWORD) so the value is +expanded by the kubelet from the POSTGRES_PASSWORD variable declared earlier in +the same container's env list, and never appears in the pod spec. +*/}} +{{- define "panda-wiki.pgDsn" -}} +{{- $port := "5432" -}} +{{- $sslMode := "disable" -}} +{{- if not .Values.postgres.enabled -}} +{{- $port = .Values.postgres.external.port | toString -}} +{{- $sslMode = .Values.postgres.external.sslMode -}} +{{- end -}} +{{- printf "host=%s user=%s password=$(POSTGRES_PASSWORD) dbname=%s port=%s sslmode=%s TimeZone=%s" (include "panda-wiki.postgresHost" .) (include "panda-wiki.postgresUser" .) (include "panda-wiki.postgresDatabase" .) $port $sslMode .Values.global.timezone -}} +{{- end -}} diff --git a/helm/panda-wiki/templates/_wait.tpl b/helm/panda-wiki/templates/_wait.tpl new file mode 100644 index 000000000..5d5dc55f9 --- /dev/null +++ b/helm/panda-wiki/templates/_wait.tpl @@ -0,0 +1,63 @@ +{{/* +A wait-for-dependencies init container. + +The api, consumer, raglite and crawler all fail hard when a dependency is not +reachable yet: the Go services panic during wiring, and the closed-source raglite +and crawler exit without retrying. Without this they rely on the restart backoff, +which reaches five minutes, so a first install can look broken for a long time. + +`addrs` is a space-separated string of host:port values. The api image is used +because it already contains a shell and busybox nc, and it is present on the node +for every deployment that needs this. + +Usage: include "panda-wiki.waitForDeps" (dict "root" $ "addrs" "a:1 b:2") +*/}} +{{- define "panda-wiki.waitForDeps" -}} +- name: wait-for-dependencies + image: {{ include "panda-wiki.image" (dict "root" .root "image" .root.Values.api.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" .root "image" .root.Values.api.image) }} + command: + - sh + - -c + - | + set -eu + deadline=$(( $(date +%s) + {{ .root.Values.migration.waitTimeoutSeconds }} )) + for addr in $WAIT_FOR; do + echo "waiting for ${addr}" + until nc -z "${addr%:*}" "${addr##*:}"; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "timed out waiting for ${addr}" >&2 + exit 1 + fi + sleep 2 + done + echo "${addr} is reachable" + done + env: + - name: WAIT_FOR + value: {{ .addrs | quote }} +{{- end -}} + +{{/* The dependency addresses each component needs, in host:port form. */}} +{{- define "panda-wiki.postgresNatsRedisMinioAddrs" -}} +{{- include "panda-wiki.postgresAddr" . }} {{ include "panda-wiki.redisAddr" . }} {{ include "panda-wiki.natsAddr" . }} {{ include "panda-wiki.s3Addr" . }} +{{- end -}} + +{{- define "panda-wiki.natsMinioAddrs" -}} +{{- include "panda-wiki.natsAddr" . }} {{ include "panda-wiki.s3Addr" . }} +{{- end -}} + +{{- define "panda-wiki.postgresQdrantMinioNatsAddrs" -}} +{{- include "panda-wiki.postgresAddr" . }} {{ printf "%s:6333" (include "panda-wiki.qdrantHost" .) }} {{ include "panda-wiki.s3Addr" . }} {{ include "panda-wiki.natsAddr" . }} +{{- end -}} + +{{/* +The consumer additionally waits for raglite, because it subscribes to the +raglite.events.doc.update subject. That stream is created by raglite, not by the +backend's own EnsureStreams, and the consumer panics with "no stream matches +subject" if raglite got there first, so waiting for raglite to accept +connections is what keeps it from crash-looping. +*/}} +{{- define "panda-wiki.consumerAddrs" -}} +{{- include "panda-wiki.postgresNatsRedisMinioAddrs" . }}{{- if .Values.raglite.enabled }} {{ printf "%s-raglite:%v" (include "panda-wiki.name" .) .Values.raglite.port }}{{- end }} +{{- end -}} diff --git a/helm/panda-wiki/templates/configmap-admin-nginx.yaml b/helm/panda-wiki/templates/configmap-admin-nginx.yaml new file mode 100644 index 000000000..c83d8b98a --- /dev/null +++ b/helm/panda-wiki/templates/configmap-admin-nginx.yaml @@ -0,0 +1,113 @@ +{{- $s3 := splitList ":" (include "panda-wiki.s3Endpoint" .) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "panda-wiki.name" . }}-nginx + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} +data: + # Replaces the server.conf baked into the image so the upstreams follow the + # chart's service addresses, and so every streaming endpoint is unbuffered. + server.conf: | + upstream backend { + server {{ include "panda-wiki.name" . }}-api:{{ .Values.api.port }}; + } + + server { + charset utf-8; + listen {{ .Values.admin.port }} ssl http2; + + ssl_certificate /etc/nginx/ssl/panda-wiki.crt; + ssl_certificate_key /etc/nginx/ssl/panda-wiki.key; + + location /503 { + return 503; + } + + # Every endpoint that answers with text/event-stream, and only those. + # Keep this in step with web/admin/server.conf. + location ~ ^/(share/v1/chat/message|share/v1/chat/widget|share/v1/chat/completions|share/v1/app/wechat/service/answer|api/v1/creation/text|api/v1/node/summary/stream)$ { + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + + proxy_read_timeout 24h; + proxy_send_timeout 24h; + + # Forward client information + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + + proxy_pass http://backend; + } + + location = /api/v1/file/upload { + proxy_pass http://backend; + + client_max_body_size 1000m; + + # Forward client information + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + } + + location ~ ^/api { + proxy_pass http://backend; + + client_max_body_size 1000m; + + # Forward client information + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + } + + location ~ ^/share { + proxy_pass http://backend; + + # Forward client information + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + } + + location ~ ^/static-file/ { + proxy_pass http://{{ index $s3 0 }}:{{ index $s3 1 }}; + + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + send_timeout 300; + + client_max_body_size 1000m; + + if ($request_uri !~* \.pdf$) { + add_header Content-Disposition "attachment" always; + } + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + + proxy_cache off; + proxy_buffering off; + } + + location / { + root /opt/frontend/dist; + index index.html index.htm; + try_files $uri $uri/ $uri.html /index.html; + if ($request_filename ~* .*\.(htm|html)$) { + add_header Cache-Control "no-cache"; + } + } + } diff --git a/helm/panda-wiki/templates/configmap-backend.yaml b/helm/panda-wiki/templates/configmap-backend.yaml new file mode 100644 index 000000000..62ee15a57 --- /dev/null +++ b/helm/panda-wiki/templates/configmap-backend.yaml @@ -0,0 +1,40 @@ +{{/* +Always rendered: the api, consumer and migrate pods mount it unconditionally, so +gating it would leave them stuck on a missing ConfigMap. Every access goes +through a defaulted local so a partial or empty backend.config still renders. +*/}} +{{- $cfg := .Values.backend.config | default dict }} +{{- $log := $cfg.log | default dict }} +{{- $auth := $cfg.auth | default dict }} +{{- $rag := $cfg.rag | default dict }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "panda-wiki.name" . }}-config + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} +data: + # Mounted at /app/config.yml, which is on the search path the backend reads. + # Only settings without an environment variable need to live here; everything + # else is passed as an environment variable so that passwords stay in a Secret. + config.yml: | + log: + level: {{ $log.level | default 0 }} + auth: + type: {{ $auth.type | default "jwt" }} + mq: + type: nats + nats: + user: {{ include "panda-wiki.natsUser" . | quote }} + s3: + access_key: {{ include "panda-wiki.s3AccessKey" . | quote }} + rag: + provider: ct + ct_rag: + {{- with $rag.apiKey }} + api_key: {{ . | quote }} + {{- end }} + base_url: {{ include "panda-wiki.ragBaseURL" . | quote }} + {{- with $cfg.extraConfigYaml }} + {{ . | nindent 4 }} + {{- end }} diff --git a/helm/panda-wiki/templates/deployment-admin.yaml b/helm/panda-wiki/templates/deployment-admin.yaml new file mode 100644 index 000000000..d9ec89a4d --- /dev/null +++ b/helm/panda-wiki/templates/deployment-admin.yaml @@ -0,0 +1,88 @@ +{{- if .Values.admin.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-nginx + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: admin +spec: + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "admin") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: admin + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-admin-nginx.yaml") . | sha256sum }} + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: nginx + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.admin.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.admin.image) }} + ports: + - name: https + containerPort: {{ .Values.admin.port }} + # server.conf listens with TLS, so the probe has to speak HTTPS. The + # self-signed certificate is not verified by the kubelet. + startupProbe: + httpGet: + path: / + port: https + scheme: HTTPS + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: / + port: https + scheme: HTTPS + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: https + scheme: HTTPS + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.admin.resources | nindent 12 }} + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/server.conf + subPath: server.conf + {{- if .Values.admin.tls.existingSecret }} + - name: tls + mountPath: /etc/nginx/ssl + readOnly: true + {{- end }} + volumes: + - name: nginx-config + configMap: + name: {{ include "panda-wiki.name" . }}-nginx + {{- if .Values.admin.tls.existingSecret }} + - name: tls + secret: + secretName: {{ .Values.admin.tls.existingSecret }} + {{- end }} + {{- with .Values.admin.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.admin.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.admin.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deployment-api.yaml b/helm/panda-wiki/templates/deployment-api.yaml new file mode 100644 index 000000000..0155c413e --- /dev/null +++ b/helm/panda-wiki/templates/deployment-api.yaml @@ -0,0 +1,128 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-api + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicaCount }} + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "api") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: api + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-backend.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + # Has to outlast the in-process drain, which is a fixed 30s in + # backend/cmd/api/main.go. + terminationGracePeriodSeconds: {{ .Values.api.terminationGracePeriodSeconds }} + initContainers: + {{- include "panda-wiki.waitForDeps" (dict "root" . "addrs" (include "panda-wiki.postgresNatsRedisMinioAddrs" .)) | nindent 8 }} + containers: + - name: api + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.api.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.api.image) }} + # The image CMD runs the migration binary before the api. Migrations + # belong to the migration job, so the entrypoint is overridden here. + command: ["/app/panda-wiki-api"] + ports: + - name: http + containerPort: {{ .Values.api.port }} + {{- if eq .Values.api.probes.mode "http" }} + - name: health + containerPort: {{ .Values.api.healthPort }} + {{- end }} + env: + {{- include "panda-wiki.backendEnv" (dict "root" . "role" "api") | nindent 12 }} + - name: HTTP_PORT + value: {{ .Values.api.port | quote }} + {{- if eq .Values.api.probes.mode "http" }} + - name: HEALTH_PORT + value: {{ .Values.api.healthPort | quote }} + {{- end }} + {{- range .Values.api.extraEnv }} + - {{- toYaml . | nindent 14 }} + {{- end }} + {{- if eq .Values.api.probes.mode "http" }} + startupProbe: + httpGet: + path: /healthz + port: health + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /healthz + port: health + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + # Fails while a dependency is unusable, which takes the pod out of + # rotation without restarting it. + httpGet: + path: /readyz + port: health + periodSeconds: 5 + timeoutSeconds: 10 + failureThreshold: 3 + {{- else }} + startupProbe: + tcpSocket: + port: http + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + tcpSocket: + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + {{- end }} + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + volumeMounts: + {{- include "panda-wiki.backendVolumeMounts" . | nindent 12 }} + volumes: + {{- include "panda-wiki.backendVolumes" . | nindent 8 }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- if .Values.api.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "panda-wiki.name" . }}-api + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "api") | nindent 6 }} +{{- end }} diff --git a/helm/panda-wiki/templates/deployment-app.yaml b/helm/panda-wiki/templates/deployment-app.yaml new file mode 100644 index 000000000..6d6279261 --- /dev/null +++ b/helm/panda-wiki/templates/deployment-app.yaml @@ -0,0 +1,74 @@ +{{- if .Values.app.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-app + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "app") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: app + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: app + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.app.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.app.image) }} + ports: + - name: http + containerPort: {{ .Values.app.port }} + env: + - name: TZ + value: {{ .Values.global.timezone | quote }} + # Read server-side by the Next.js middleware and the SSR data + # fetchers. It is not a NEXT_PUBLIC_ variable, so it is not inlined + # into the browser bundle and must be present at runtime. + - name: TARGET + value: {{ printf "http://%s-api:%v" (include "panda-wiki.name" .) .Values.api.port | quote }} + - name: PORT + value: {{ .Values.app.port | quote }} + - name: HOSTNAME + value: "0.0.0.0" + # A TCP probe only. An HTTP probe would run the SSR path, which calls + # the api, and a slow dependency would then look like a dead pod. + startupProbe: + tcpSocket: + port: http + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + tcpSocket: + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + {{- with .Values.app.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deployment-caddy.yaml b/helm/panda-wiki/templates/deployment-caddy.yaml new file mode 100644 index 000000000..c4fa9f3af --- /dev/null +++ b/helm/panda-wiki/templates/deployment-caddy.yaml @@ -0,0 +1,157 @@ +{{- if .Values.caddy.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-caddy + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: caddy +spec: + # The backend pushes the whole routing table into one instance, so extra + # replicas would drift out of sync with each other. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "caddy") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: caddy + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + {{- if eq .Values.caddy.exposure "hostNetwork" }} + # Matches the official docker compose deployment: Caddy binds the host + # ports directly, which is what lets a knowledge base be published on any + # port the admin picks. This pins the pod to one node. + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + {{- end }} + containers: + - name: caddy + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.caddy.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.caddy.image) }} + # Spelled out so --resume can be appended. The image has no + # ENTRYPOINT, so this replaces its CMD entirely. + command: + - caddy + - run + - --config + - /etc/caddy/Caddyfile + - --adapter + - caddyfile + {{- if .Values.caddy.resume }} + - --resume + {{- end }} + env: + # Overrides Caddy's default localhost:2019 so the backend can reach + # the admin API from another pod. + - name: CADDY_ADMIN + value: {{ printf "0.0.0.0:%v" .Values.caddy.adminPort | quote }} + ports: + - name: admin + containerPort: {{ .Values.caddy.adminPort }} + {{- if eq .Values.caddy.exposure "hostNetwork" }} + # With hostNetwork Caddy binds these on the node, but they are still + # declared so the Service below has named target ports to resolve. + {{- end }} + {{- range .Values.caddy.servicePorts }} + - name: {{ .name }} + containerPort: {{ .port }} + {{- end }} + {{- range .Values.caddy.extraPorts }} + - name: {{ .name }} + containerPort: {{ .port }} + {{- end }} + # Proves the admin API is serving, which is what the backend needs in + # order to publish knowledge bases. + startupProbe: + httpGet: + path: /config/ + port: admin + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /config/ + port: admin + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /config/ + port: admin + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.caddy.resources | nindent 12 }} + volumeMounts: + - name: caddy-config + mountPath: /config + - name: caddy-data + mountPath: /data + volumes: + - name: caddy-config + {{- if .Values.caddy.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "panda-wiki.name" . }}-caddy-config + {{- else }} + emptyDir: {} + {{- end }} + - name: caddy-data + {{- if .Values.caddy.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "panda-wiki.name" . }}-caddy-data + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.caddy.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.caddy.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.caddy.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- if .Values.caddy.persistence.enabled }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "panda-wiki.name" . }}-caddy-config + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: caddy +spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 2 }} + resources: + requests: + storage: {{ .Values.caddy.persistence.size }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "panda-wiki.name" . }}-caddy-data + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: caddy +spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 2 }} + resources: + requests: + storage: {{ .Values.caddy.persistence.size }} +{{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deployment-consumer.yaml b/helm/panda-wiki/templates/deployment-consumer.yaml new file mode 100644 index 000000000..1b9de4dbb --- /dev/null +++ b/helm/panda-wiki/templates/deployment-consumer.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-consumer + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: consumer +spec: + {{- if gt (int .Values.consumer.replicaCount) 1 }} + {{- if not .Values.consumer.cron.distributedLock }} + {{- fail "consumer.replicaCount > 1 requires consumer.cron.distributedLock, which is not implemented yet: the scheduled jobs run in this process without a distributed lock and would be executed once per replica" }} + {{- end }} + {{- end }} + replicas: {{ .Values.consumer.replicaCount }} + # Recreate so an upgrade never leaves the old and the new consumer running + # the scheduled jobs at the same time. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "consumer") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: consumer + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-backend.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + terminationGracePeriodSeconds: {{ .Values.consumer.terminationGracePeriodSeconds }} + initContainers: + {{- include "panda-wiki.waitForDeps" (dict "root" . "addrs" (include "panda-wiki.consumerAddrs" .)) | nindent 8 }} + containers: + - name: consumer + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.consumer.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.consumer.image) }} + ports: + {{- if eq .Values.consumer.probes.mode "http" }} + - name: health + containerPort: {{ .Values.consumer.healthPort }} + {{- end }} + env: + {{- include "panda-wiki.backendEnv" (dict "root" . "role" "consumer") | nindent 12 }} + {{- if eq .Values.consumer.probes.mode "http" }} + - name: HEALTH_PORT + value: {{ .Values.consumer.healthPort | quote }} + {{- end }} + {{- range .Values.consumer.extraEnv }} + - {{- toYaml . | nindent 14 }} + {{- end }} + {{- if eq .Values.consumer.probes.mode "http" }} + startupProbe: + httpGet: + path: /healthz + port: health + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /healthz + port: health + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: health + periodSeconds: 5 + timeoutSeconds: 10 + failureThreshold: 3 + {{- else }} + # The consumer has no serving port, so there is nothing to probe. + # Keep the container running unless it exits on its own. + {{- end }} + resources: + {{- toYaml .Values.consumer.resources | nindent 12 }} + volumeMounts: + {{- include "panda-wiki.backendVolumeMounts" . | nindent 12 }} + volumes: + {{- include "panda-wiki.backendVolumes" . | nindent 8 }} + {{- with .Values.consumer.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.consumer.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.consumer.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/panda-wiki/templates/deployment-crawler.yaml b/helm/panda-wiki/templates/deployment-crawler.yaml new file mode 100644 index 000000000..264f9a3c9 --- /dev/null +++ b/helm/panda-wiki/templates/deployment-crawler.yaml @@ -0,0 +1,88 @@ +{{- if .Values.crawler.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-crawler + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: crawler +spec: + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "crawler") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: crawler + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + initContainers: + {{- include "panda-wiki.waitForDeps" (dict "root" . "addrs" (include "panda-wiki.natsMinioAddrs" .)) | nindent 8 }} + containers: + - name: crawler + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.crawler.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.crawler.image) }} + ports: + - name: http + containerPort: {{ .Values.crawler.port }} + # Variable names mirror the ones the official docker compose file sets. + env: + - name: GLOG_GLOBAL_LEVEL + value: info + - name: NAMESPACE + value: anydoc + - name: MQ_NATS_URL + value: {{ include "panda-wiki.natsServer" . | quote }} + - name: MQ_NATS_USER + value: {{ include "panda-wiki.natsUser" . | quote }} + - name: MQ_NATS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.natsSecretName" . }} + key: {{ include "panda-wiki.natsSecretKey" . }} + - name: OSS_MINIO_ENDPOINT + value: {{ include "panda-wiki.s3Endpoint" . | quote }} + - name: OSS_MINIO_ACCESS_KEY + value: {{ include "panda-wiki.s3AccessKey" . | quote }} + - name: OSS_MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.s3SecretName" . }} + key: {{ include "panda-wiki.s3SecretKey" . }} + # Closed-source image; the backend reaches it at + # http://panda-wiki-crawler:8080, which is what this checks. + startupProbe: + tcpSocket: + port: http + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + tcpSocket: + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + tcpSocket: + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.crawler.resources | nindent 12 }} + {{- with .Values.crawler.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.crawler.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.crawler.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deployment-raglite.yaml b/helm/panda-wiki/templates/deployment-raglite.yaml new file mode 100644 index 000000000..6574d768a --- /dev/null +++ b/helm/panda-wiki/templates/deployment-raglite.yaml @@ -0,0 +1,138 @@ +{{- if .Values.raglite.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "panda-wiki.name" . }}-raglite + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: raglite +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "raglite") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: raglite + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + initContainers: + {{- include "panda-wiki.waitForDeps" (dict "root" . "addrs" (include "panda-wiki.postgresQdrantMinioNatsAddrs" .)) | nindent 8 }} + containers: + - name: raglite + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.raglite.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.raglite.image) }} + ports: + - name: http + containerPort: {{ .Values.raglite.port }} + # Variable names mirror the ones the official docker compose file sets. + env: + - name: GIN_MODE + value: release + - name: DATABASE_POSTGRESQL_HOST + value: {{ include "panda-wiki.postgresHost" . | quote }} + - name: DATABASE_POSTGRESQL_USER + value: {{ include "panda-wiki.postgresUser" . | quote }} + - name: DATABASE_POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.postgresSecretName" . }} + key: {{ include "panda-wiki.postgresSecretKey" . }} + - name: DATABASE_QDRANT_HOST + value: {{ include "panda-wiki.qdrantHost" . | quote }} + - name: DATABASE_QDRANT_API_KEY + valueFrom: + secretKeyRef: + name: {{ if .Values.qdrant.existingSecret }}{{ .Values.qdrant.existingSecret }}{{ else }}{{ include "panda-wiki.secretName" . }}{{ end }} + key: {{ if .Values.qdrant.existingSecret }}api-key{{ else }}qdrant-api-key{{ end }} + - name: STORAGE_MINIO_ENDPOINT + value: {{ include "panda-wiki.s3Endpoint" . | quote }} + - name: STORAGE_MINIO_ACCESS_KEY_ID + value: {{ include "panda-wiki.s3AccessKey" . | quote }} + - name: STORAGE_MINIO_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.s3SecretName" . }} + key: {{ include "panda-wiki.s3SecretKey" . }} + - name: NATS_URL + value: {{ include "panda-wiki.natsServer" . | quote }} + - name: NATS_USER + value: {{ include "panda-wiki.natsUser" . | quote }} + - name: NATS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.natsSecretName" . }} + key: {{ include "panda-wiki.natsSecretKey" . }} + - name: EMBEDDER_RPM + value: {{ .Values.raglite.embedderRpm | quote }} + - name: LLM_RPM + value: {{ .Values.raglite.llmRpm | quote }} + # /health returns 200; verified on a real cluster. + startupProbe: + httpGet: + path: /health + port: http + periodSeconds: 5 + failureThreshold: 120 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.raglite.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + {{- if .Values.raglite.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "panda-wiki.name" . }}-raglite + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.raglite.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.raglite.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.raglite.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- if .Values.raglite.persistence.enabled }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "panda-wiki.name" . }}-raglite + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: raglite +spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 2 }} + resources: + requests: + storage: {{ .Values.raglite.persistence.size }} +{{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deps/minio.yaml b/helm/panda-wiki/templates/deps/minio.yaml new file mode 100644 index 000000000..4460bd524 --- /dev/null +++ b/helm/panda-wiki/templates/deps/minio.yaml @@ -0,0 +1,102 @@ +{{- if .Values.minio.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-minio + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: minio +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "minio") | nindent 4 }} + ports: + - name: s3 + port: 9000 + targetPort: s3 + - name: console + port: 9001 + targetPort: console +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "panda-wiki.name" . }}-minio + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: minio +spec: + serviceName: {{ include "panda-wiki.name" . }}-minio + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "minio") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: minio + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: minio + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.minio.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.minio.image) }} + command: ["minio", "server", "/data", "--console-address", ":9001"] + ports: + - name: s3 + containerPort: 9000 + - name: console + containerPort: 9001 + # These are the variable names the official compose file uses with this + # image, so they are the ones known to work here. + env: + - name: MINIO_ACCESS_KEY + value: {{ .Values.minio.auth.accessKey | quote }} + - name: MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.s3SecretName" . }} + key: {{ include "panda-wiki.s3SecretKey" . }} + - name: TZ + value: {{ .Values.global.timezone | quote }} + readinessProbe: + httpGet: + path: /minio/health/ready + port: s3 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /minio/health/live + port: s3 + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.minio.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- if not .Values.minio.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.minio.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 8 }} + resources: + requests: + storage: {{ .Values.minio.persistence.size }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deps/nats-config.yaml b/helm/panda-wiki/templates/deps/nats-config.yaml new file mode 100644 index 000000000..44de78295 --- /dev/null +++ b/helm/panda-wiki/templates/deps/nats-config.yaml @@ -0,0 +1,22 @@ +{{- if .Values.nats.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "panda-wiki.name" . }}-nats + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: nats +data: + nats.conf: | + port: 4222 + http: 8222 + + # The backend creates its streams with Replicas: 1, so a single server is + # what this deployment is sized for. JetStream has to be on: the task, + # scraper and raglite event streams live in it. + jetstream { + store_dir: /data + max_memory_store: 1Gb + max_file_store: 10Gb + } +{{- end }} diff --git a/helm/panda-wiki/templates/deps/nats.yaml b/helm/panda-wiki/templates/deps/nats.yaml new file mode 100644 index 000000000..3368c73b5 --- /dev/null +++ b/helm/panda-wiki/templates/deps/nats.yaml @@ -0,0 +1,112 @@ +{{- if .Values.nats.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-nats + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: nats +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "nats") | nindent 4 }} + ports: + - name: client + port: 4222 + targetPort: client + - name: monitor + port: 8222 + targetPort: monitor +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "panda-wiki.name" . }}-nats + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: nats +spec: + serviceName: {{ include "panda-wiki.name" . }}-nats + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "nats") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: nats + annotations: + checksum/config: {{ include (print $.Template.BasePath "/deps/nats-config.yaml") . | sha256sum }} + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: nats + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.nats.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.nats.image) }} + ports: + - name: client + containerPort: 4222 + - name: monitor + containerPort: 8222 + # The password is expanded by the shell so it does not appear in the + # pod spec. + command: + - sh + - -c + - exec nats-server -c /etc/nats/nats.conf --user "$NATS_USER" --pass "$NATS_PASSWORD" + env: + - name: NATS_USER + value: {{ .Values.nats.auth.username | quote }} + - name: NATS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.natsSecretName" . }} + key: {{ include "panda-wiki.natsSecretKey" . }} + - name: TZ + value: {{ .Values.global.timezone | quote }} + readinessProbe: + httpGet: + path: /healthz + port: monitor + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /healthz + port: monitor + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.nats.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + - name: config + mountPath: /etc/nats + volumes: + - name: config + configMap: + name: {{ include "panda-wiki.name" . }}-nats + {{- if not .Values.nats.persistence.enabled }} + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.nats.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 8 }} + resources: + requests: + storage: {{ .Values.nats.persistence.size }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deps/postgres.yaml b/helm/panda-wiki/templates/deps/postgres.yaml new file mode 100644 index 000000000..cb997ac7c --- /dev/null +++ b/helm/panda-wiki/templates/deps/postgres.yaml @@ -0,0 +1,100 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-postgres + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "postgres") | nindent 4 }} + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "panda-wiki.name" . }}-postgres + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + serviceName: {{ include "panda-wiki.name" . }}-postgres + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "postgres") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: postgres + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + securityContext: + fsGroup: 999 + containers: + - name: postgres + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.postgres.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.postgres.image) }} + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_USER + value: {{ .Values.postgres.auth.username | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgres.auth.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.postgresSecretName" . }} + key: {{ include "panda-wiki.postgresSecretKey" . }} + - name: TZ + value: {{ .Values.global.timezone | quote }} + # Kept in a subdirectory so a mounted volume root that already has + # contents does not make initdb refuse to run. + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + readinessProbe: + exec: + command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}] + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["pg_isready", "-U", {{ .Values.postgres.auth.username | quote }}, "-d", {{ .Values.postgres.auth.database | quote }}] + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + {{- if not .Values.postgres.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.postgres.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 8 }} + resources: + requests: + storage: {{ .Values.postgres.persistence.size }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deps/qdrant.yaml b/helm/panda-wiki/templates/deps/qdrant.yaml new file mode 100644 index 000000000..2fd5bd1e4 --- /dev/null +++ b/helm/panda-wiki/templates/deps/qdrant.yaml @@ -0,0 +1,99 @@ +{{- if .Values.qdrant.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-qdrant + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: qdrant +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "qdrant") | nindent 4 }} + ports: + - name: http + port: 6333 + targetPort: http + - name: grpc + port: 6334 + targetPort: grpc +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "panda-wiki.name" . }}-qdrant + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: qdrant +spec: + serviceName: {{ include "panda-wiki.name" . }}-qdrant + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "qdrant") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: qdrant + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: qdrant + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.qdrant.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.qdrant.image) }} + ports: + - name: http + containerPort: 6333 + - name: grpc + containerPort: 6334 + env: + - name: QDRANT__SERVICE__API_KEY + valueFrom: + secretKeyRef: + name: {{ if .Values.qdrant.existingSecret }}{{ .Values.qdrant.existingSecret }}{{ else }}{{ include "panda-wiki.secretName" . }}{{ end }} + key: {{ if .Values.qdrant.existingSecret }}api-key{{ else }}qdrant-api-key{{ end }} + - name: TZ + value: {{ .Values.global.timezone | quote }} + # /healthz, /readyz and /livez all answer 200; verified on a real + # cluster. + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /livez + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.qdrant.resources | nindent 12 }} + volumeMounts: + - name: storage + mountPath: /qdrant/storage + {{- if not .Values.qdrant.persistence.enabled }} + volumes: + - name: storage + emptyDir: {} + {{- end }} + {{- if .Values.qdrant.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: storage + spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 8 }} + resources: + requests: + storage: {{ .Values.qdrant.persistence.size }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/deps/redis.yaml b/helm/panda-wiki/templates/deps/redis.yaml new file mode 100644 index 000000000..addd7eed5 --- /dev/null +++ b/helm/panda-wiki/templates/deps/redis.yaml @@ -0,0 +1,96 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-redis + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "redis") | nindent 4 }} + ports: + - name: redis + port: 6379 + targetPort: redis +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "panda-wiki.name" . }}-redis + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + serviceName: {{ include "panda-wiki.name" . }}-redis + replicas: 1 + selector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "redis") | nindent 6 }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + containers: + - name: redis + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.redis.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.redis.image) }} + ports: + - name: redis + containerPort: 6379 + # The password is expanded by the shell so it does not appear in the + # pod spec. AOF is on to match the official compose deployment. + command: + - sh + - -c + - exec redis-server --requirepass "$REDIS_PASSWORD" --appendonly yes --appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000 + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "panda-wiki.redisSecretName" . }} + key: {{ include "panda-wiki.redisSecretKey" . }} + - name: TZ + value: {{ .Values.global.timezone | quote }} + readinessProbe: + exec: + command: ["sh", "-c", "redis-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["sh", "-c", "redis-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"] + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- if not .Values.redis.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.redis.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- include "panda-wiki.storageClass" . | nindent 8 }} + resources: + requests: + storage: {{ .Values.redis.persistence.size }} + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/ingress.yaml b/helm/panda-wiki/templates/ingress.yaml new file mode 100644 index 000000000..83767b067 --- /dev/null +++ b/helm/panda-wiki/templates/ingress.yaml @@ -0,0 +1,88 @@ +{{- if .Values.ingress.admin.enabled }} +{{- if not .Values.admin.enabled }} +{{- fail "ingress.admin.enabled requires admin.enabled: the Ingress would point at a Service that is never deployed" }} +{{- end }} +{{- if not .Values.ingress.admin.hosts }} +{{- fail "ingress.admin.enabled requires ingress.admin.hosts: an Ingress with no rules does nothing" }} +{{- end }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "panda-wiki.name" . }}-admin + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + annotations: + # The admin container terminates TLS itself on 8080 with the certificate + # from the image (or from admin.tls.existingSecret), so the Ingress has to + # speak HTTPS to it and cannot verify that certificate. + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + nginx.ingress.kubernetes.io/proxy-ssl-verify: "off" + # Uploads are allowed up to 1000m by the nginx config in front of the api. + nginx.ingress.kubernetes.io/proxy-body-size: "1000m" + {{- with .Values.ingress.admin.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.admin.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.admin.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.admin.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "panda-wiki.name" $ }}-nginx + port: + name: https + {{- end }} + {{- end }} +{{- end }} +{{- if .Values.ingress.wiki.enabled }} +{{- if not .Values.caddy.enabled }} +{{- fail "ingress.wiki.enabled requires caddy.enabled: the Ingress would point at a Service that is never deployed" }} +{{- end }} +{{- if not .Values.ingress.wiki.hosts }} +{{- fail "ingress.wiki.enabled requires ingress.wiki.hosts: an Ingress with no rules does nothing" }} +{{- end }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "panda-wiki.name" . }}-wiki + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + annotations: + {{- with .Values.ingress.wiki.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.wiki.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.wiki.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.wiki.hosts }} + - host: {{ .host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "panda-wiki.name" $ }}-caddy + port: + name: https + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/job-migrate.yaml b/helm/panda-wiki/templates/job-migrate.yaml new file mode 100644 index 000000000..c6b91118b --- /dev/null +++ b/helm/panda-wiki/templates/job-migrate.yaml @@ -0,0 +1,49 @@ +{{- if .Values.migration.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "panda-wiki.name" . }}-migrate + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: migration + annotations: + # Runs before the api is rolled out, so the schema is in place before new + # pods start serving, and before a fresh install reports success. + "helm.sh/hook": {{ .Values.migration.hook }} + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.migration.backoffLimit }} + template: + metadata: + labels: + {{- include "panda-wiki.labels" . | nindent 8 }} + app.kubernetes.io/component: migration + spec: + {{- include "panda-wiki.imagePullSecrets" . | nindent 6 }} + automountServiceAccountToken: false + restartPolicy: Never + initContainers: + # The migration binary builds its whole dependency graph on startup, + # including NATS, MinIO and Redis, so all of them have to answer before + # it is worth running. + # The migration binary builds its whole dependency graph on startup, + # including NATS, MinIO and Redis, so all of them have to answer before + # it is worth running. Shared with the api, consumer, raglite and + # crawler, which all fail hard on an unreachable dependency too. + {{- include "panda-wiki.waitForDeps" (dict "root" . "addrs" (include "panda-wiki.postgresNatsRedisMinioAddrs" .)) | nindent 8 }} + containers: + - name: migrate + image: {{ include "panda-wiki.image" (dict "root" . "image" .Values.api.image) }} + imagePullPolicy: {{ include "panda-wiki.imagePullPolicy" (dict "root" . "image" .Values.api.image) }} + # The migration files are read from file://migration, a path relative + # to the working directory, so this must stay /app. + workingDir: /app + command: ["/app/panda-wiki-migrate"] + env: + {{- include "panda-wiki.backendEnv" (dict "root" . "role" "migrate") | nindent 12 }} + volumeMounts: + {{- include "panda-wiki.backendVolumeMounts" . | nindent 12 }} + volumes: + {{- include "panda-wiki.backendVolumes" . | nindent 8 }} +{{- end }} diff --git a/helm/panda-wiki/templates/networkpolicy.yaml b/helm/panda-wiki/templates/networkpolicy.yaml new file mode 100644 index 000000000..e6edbbb17 --- /dev/null +++ b/helm/panda-wiki/templates/networkpolicy.yaml @@ -0,0 +1,39 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "panda-wiki.name" . }}-caddy + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} +spec: + # The Caddy admin API accepts unauthenticated full config writes, so only the + # backend is allowed to reach it. Entry traffic stays open. + podSelector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "caddy") | nindent 6 }} + policyTypes: + - Ingress + ingress: + - ports: + - port: {{ .Values.caddy.adminPort }} + protocol: TCP + from: + - podSelector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "api") | nindent 14 }} + - podSelector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "consumer") | nindent 14 }} + - podSelector: + matchLabels: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "migration") | nindent 14 }} + - ports: + - port: 80 + protocol: TCP + - port: 443 + protocol: TCP + {{- range .Values.caddy.extraPorts }} + - port: {{ .port }} + protocol: TCP + {{- end }} +{{- end }} diff --git a/helm/panda-wiki/templates/secret.yaml b/helm/panda-wiki/templates/secret.yaml new file mode 100644 index 000000000..04ede262c --- /dev/null +++ b/helm/panda-wiki/templates/secret.yaml @@ -0,0 +1,67 @@ +{{- if .Values.secrets.existingSecret }} +{{/* +The user manages this Secret. Validate it when the cluster is reachable, because +one failure mode is silent: a key that exists but is empty satisfies the kubelet, +and the backend then skips creating the admin user entirely (it only does so when +ADMIN_PASSWORD is non-empty), leaving a console nobody can log into and a pod that +looks perfectly healthy. Offline rendering cannot see the Secret, so this check +only runs on a real install or upgrade. +*/}} +{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.secrets.existingSecret }} +{{- if $secret }} +{{- $name := .Values.secrets.existingSecret }} +{{- $data := $secret.data | default dict }} +{{- $required := list "jwt-secret" "admin-password" }} +{{- if eq (include "panda-wiki.postgresSecretName" .) $name }}{{ $required = append $required "postgres-password" }}{{ end }} +{{- if eq (include "panda-wiki.redisSecretName" .) $name }}{{ $required = append $required "redis-password" }}{{ end }} +{{- if eq (include "panda-wiki.natsSecretName" .) $name }}{{ $required = append $required "nats-password" }}{{ end }} +{{- if eq (include "panda-wiki.s3SecretName" .) $name }}{{ $required = append $required "s3-secret-key" }}{{ end }} +{{- if and (not .Values.qdrant.existingSecret) (or .Values.qdrant.enabled .Values.raglite.enabled) }}{{ $required = append $required "qdrant-api-key" }}{{ end }} +{{- $missing := list }} +{{- range $key := $required }} +{{- if not (hasKey $data $key) }}{{ $missing = append $missing $key }}{{ end }} +{{- end }} +{{- if $missing }} +{{- fail (printf "Secret %q is missing: %s" $name (join ", " $missing)) }} +{{- end }} +{{- if not (index $data "admin-password") }} +{{- fail (printf "Secret %q has an empty admin-password. The backend only creates the admin account when ADMIN_PASSWORD is non-empty, so an empty value would start a console with no way in and no error to explain it. Set a password there, or delete the key and let the kubelet report it instead." $name) }} +{{- end }} +{{- end }} +{{- else }} +{{- if not (lookup "v1" "Namespace" "" "default") }} +{{- $missing := list }} +{{- range $key := (list "postgresPassword" "redisPassword" "natsPassword" "s3SecretKey" "qdrantApiKey" "jwtSecret" "adminPassword") }} +{{- if not (index $.Values.secrets $key) }} +{{- $missing = append $missing $key }} +{{- end }} +{{- end }} +{{- if $missing }} +{{- fail (printf `refusing to generate secrets while rendering without a cluster connection. + +Rendering offline (helm template, Argo CD, Flux, helm lint without values) cannot +look up the values an earlier install already stored, so every render would invent +new ones. Applying that rotates the admin password and the JWT secret, signing +everyone out, and with the bundled middleware it also rotates the database +password away from the one PostgreSQL was initialised with. + +Either set secrets.existingSecret to a Secret you manage, or set these values +explicitly: %s` (join ", " $missing)) }} +{{- end }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "panda-wiki.secretName" . }} + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} +type: Opaque +data: + postgres-password: {{ include "panda-wiki.secretValue" (dict "root" . "key" "postgres-password" "value" .Values.secrets.postgresPassword) | b64enc | quote }} + redis-password: {{ include "panda-wiki.secretValue" (dict "root" . "key" "redis-password" "value" .Values.secrets.redisPassword) | b64enc | quote }} + nats-password: {{ include "panda-wiki.secretValue" (dict "root" . "key" "nats-password" "value" .Values.secrets.natsPassword) | b64enc | quote }} + s3-secret-key: {{ include "panda-wiki.secretValue" (dict "root" . "key" "s3-secret-key" "value" .Values.secrets.s3SecretKey) | b64enc | quote }} + qdrant-api-key: {{ include "panda-wiki.secretValue" (dict "root" . "key" "qdrant-api-key" "value" .Values.secrets.qdrantApiKey) | b64enc | quote }} + jwt-secret: {{ include "panda-wiki.secretValue" (dict "root" . "key" "jwt-secret" "value" .Values.secrets.jwtSecret) | b64enc | quote }} + admin-password: {{ include "panda-wiki.secretValue" (dict "root" . "key" "admin-password" "value" .Values.secrets.adminPassword) | b64enc | quote }} +{{- end }} diff --git a/helm/panda-wiki/templates/services.yaml b/helm/panda-wiki/templates/services.yaml new file mode 100644 index 000000000..9e04d3617 --- /dev/null +++ b/helm/panda-wiki/templates/services.yaml @@ -0,0 +1,156 @@ +{{/* +ClusterIP services. The names are fixed and match what the backend and the admin +nginx address directly, so they are not derived from the release name. +*/}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-api + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} + ports: + - name: http + port: {{ .Values.api.port }} + targetPort: http +{{- if .Values.admin.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-nginx + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: admin +spec: + type: {{ .Values.admin.service.type }} + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "admin") | nindent 4 }} + ports: + - name: https + port: {{ .Values.admin.service.port }} + targetPort: https +{{- end }} +{{- if .Values.app.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-app + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "app") | nindent 4 }} + ports: + - name: http + port: {{ .Values.app.port }} + targetPort: http +{{- end }} +{{- if .Values.raglite.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-raglite + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: raglite +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "raglite") | nindent 4 }} + ports: + - name: http + port: {{ .Values.raglite.port }} + targetPort: http +{{- end }} +{{- if .Values.crawler.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-crawler + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: crawler +spec: + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "crawler") | nindent 4 }} + ports: + - name: http + port: {{ .Values.crawler.port }} + targetPort: http +{{- end }} +{{- if .Values.caddy.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-caddy + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: caddy +spec: + # Entry traffic only. The admin API is deliberately NOT a port on this + # Service: with NodePort or LoadBalancer exposure that would publish an + # endpoint which accepts unauthenticated full config writes to the network. + type: {{ if eq .Values.caddy.exposure "hostNetwork" }}ClusterIP{{ else }}{{ .Values.caddy.exposure }}{{ end }} + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "caddy") | nindent 4 }} + {{- if eq .Values.caddy.exposure "LoadBalancer" }} + {{- with .Values.caddy.loadBalancer.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} + ports: + # Published for every exposure: with hostNetwork the traffic reaches Caddy on + # the node directly, but the Service still has to exist and carry the ports + # for Ingress references and for the NetworkPolicy to be meaningful. + {{- range .Values.caddy.servicePorts }} + - name: {{ .name }} + port: {{ .port }} + targetPort: {{ .name }} + {{- if and (eq $.Values.caddy.exposure "NodePort") .nodePort }} + nodePort: {{ .nodePort }} + {{- else if and (eq $.Values.caddy.exposure "NodePort") (eq .name "http") }} + nodePort: {{ $.Values.caddy.nodePort.http }} + {{- else if and (eq $.Values.caddy.exposure "NodePort") (eq .name "https") }} + nodePort: {{ $.Values.caddy.nodePort.https }} + {{- end }} + {{- end }} + {{- range .Values.caddy.extraPorts }} + - name: {{ .name }} + port: {{ .port }} + targetPort: {{ .name }} + {{- if and (eq $.Values.caddy.exposure "NodePort") .nodePort }} + nodePort: {{ .nodePort }} + {{- end }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "panda-wiki.name" . }}-caddy-admin + labels: + {{- include "panda-wiki.labels" . | nindent 4 }} + app.kubernetes.io/component: caddy +spec: + # ClusterIP whatever the exposure is, so the backend can push configuration + # without the admin API ever being reachable from outside the cluster. + type: ClusterIP + selector: + {{- include "panda-wiki.selectorLabels" (dict "root" . "component" "caddy") | nindent 4 }} + ports: + - name: admin + port: {{ .Values.caddy.adminPort }} + targetPort: admin +{{- end }} diff --git a/helm/panda-wiki/values.schema.json b/helm/panda-wiki/values.schema.json new file mode 100644 index 000000000..2b08e8d3d --- /dev/null +++ b/helm/panda-wiki/values.schema.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "PandaWiki chart values", + "type": "object", + "properties": { + "global": { + "type": "object", + "properties": { + "imageRegistry": { "type": "string", "minLength": 1 }, + "imagePullSecrets": { "type": "array" }, + "timezone": { "type": "string", "minLength": 1 }, + "storageClass": { "type": "string" } + } + }, + "image": { + "type": "object", + "properties": { + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "api": { + "type": "object", + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "healthPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "probes": { "$ref": "#/definitions/probes" } + } + }, + "consumer": { + "type": "object", + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "healthPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "probes": { "$ref": "#/definitions/probes" }, + "cron": { + "type": "object", + "properties": { + "distributedLock": { "type": "boolean" } + } + } + } + }, + "admin": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "app": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "caddy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "exposure": { + "type": "string", + "enum": ["hostNetwork", "NodePort", "LoadBalancer", "ClusterIP"] + }, + "resume": { "type": "boolean" }, + "adminPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "servicePorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "required": ["name", "port"] + } + }, + "extraPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "required": ["name", "port"] + } + } + } + }, + "raglite": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "external": { + "type": "object", + "properties": { "baseURL": { "type": "string" } } + } + } + }, + "crawler": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "migration": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "hook": { "type": "string" }, + "waitTimeoutSeconds": { "type": "integer", "minimum": 1 }, + "backoffLimit": { "type": "integer", "minimum": 0 } + } + }, + "postgres": { "$ref": "#/definitions/middleware" }, + "redis": { "$ref": "#/definitions/middleware" }, + "nats": { "$ref": "#/definitions/middleware" }, + "minio": { "$ref": "#/definitions/middleware" }, + "qdrant": { "$ref": "#/definitions/middleware" } + }, + "definitions": { + "probes": { + "type": "object", + "properties": { + "mode": { "type": "string", "enum": ["tcp", "http"] } + } + }, + "middleware": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "persistence": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "size": { "type": "string" } + } + }, + "external": { "type": "object" } + } + } + } +} diff --git a/helm/panda-wiki/values.yaml b/helm/panda-wiki/values.yaml new file mode 100644 index 000000000..3680e60be --- /dev/null +++ b/helm/panda-wiki/values.yaml @@ -0,0 +1,510 @@ +# PandaWiki Helm values. +# +# IMPORTANT: deploy one release per namespace. +# The backend and the admin nginx address each other through fixed service +# names (panda-wiki-api, panda-wiki-minio, panda-wiki-redis, +# panda-wiki-postgres, panda-wiki-nats, panda-wiki-crawler) that are hardcoded +# in the Go source, so those Service names are not release-scoped and two +# releases in one namespace would collide. + +global: + # All default images live in this registry, including the middleware, so the + # chart does not need Docker Hub access. + imageRegistry: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/chaitin + imagePullSecrets: [] + timezone: Asia/Shanghai + # Leave empty to use the cluster default StorageClass. + storageClass: "" + +# Tag shared by the four components built from this repository. The middleware +# and the closed-source raglite/crawler images pin their own versions below. +image: + tag: v3.87.3 + pullPolicy: IfNotPresent + +# Secrets. Leave a value empty and the chart generates a random one on first +# install, then keeps it across upgrades by looking the existing Secret up. +# +# Note that the backend re-applies ADMIN_PASSWORD to the admin account on every +# start, so this value wins over a password changed in the console; rotate it here +# to change the login. It must not be empty: the backend only creates the admin +# account when it is set, so an empty one would start a console with no way in and +# no error to explain it. The chart checks for that when you supply your own Secret. +secrets: + postgresPassword: "" + redisPassword: "" + natsPassword: "" + s3SecretKey: "" + qdrantApiKey: "" + jwtSecret: "" + # Password for the admin console login. + adminPassword: "" + # Name of a Secret you manage instead of having the chart generate one. It must + # hold jwt-secret and admin-password, plus the middleware keys the enabled + # components actually read from it: postgres-password, redis-password, + # nats-password, s3-secret-key and qdrant-api-key. A component with its own + # auth.existingSecret takes its password from there instead. The chart validates + # this Secret on install and upgrade, whenever it can reach the cluster. + existingSecret: "" + +api: + replicaCount: 2 + image: + repository: panda-wiki-api + tag: "" + pullPolicy: "" + port: 8000 + # Port for the dedicated liveness/readiness endpoint. Only used when + # probes.mode is http, and it has to differ from api.port or the health server + # cannot bind, leaving the pod with failing probes and only a log line to say + # why. + healthPort: 8081 + probes: + # tcp checks that the port accepts connections and works with any image. + # http additionally checks /healthz and /readyz, which report whether the + # dependencies are usable, but those endpoints only exist in images built + # from this repository: the published panda-wiki-api image predates them, so + # an http probe against it never succeeds and the liveness probe then kills + # the container in a loop. + mode: tcp # tcp | http + terminationGracePeriodSeconds: 60 + # The api drains in-flight requests for a fixed 30s inside the process + # (shutdownTimeout in backend/cmd/api/main.go), so this grace period has to + # stay larger than that or streams are cut off by SIGKILL. It is not a chart + # value because changing it would require a code change as well. + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + podDisruptionBudget: + enabled: true + minAvailable: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + extraEnv: [] + +consumer: + # MUST stay 1: the scheduled jobs run in this process with no distributed + # lock, so extra replicas would repeat them. See cron.distributedLock below. + replicaCount: 1 + image: + repository: panda-wiki-consumer + tag: "" + pullPolicy: "" + healthPort: 8081 + probes: + # tcp gives the consumer NO probes at all: it serves nothing, so there is no + # port to check, and the published image has no health endpoint to ask. It is + # then only restarted if the process exits, so a wedged consumer stays + # "Ready". Use http with an image built from this repository to get real + # liveness and readiness. + mode: tcp # tcp | http + terminationGracePeriodSeconds: 60 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + cron: + # Not implemented yet. When true, extra replicas may be run once the + # scheduled jobs are guarded by a distributed lock. + distributedLock: false + nodeSelector: {} + tolerations: [] + affinity: {} + extraEnv: [] + +# Admin console: nginx serving the built admin SPA and proxying the API. +admin: + # The console SPA, and the proxy that serves its /api, /share and + # /static-file traffic. Without it there is no UI and nothing routes to the + # api from outside the cluster. + enabled: true + image: + repository: panda-wiki-nginx + tag: "" + pullPolicy: "" + # server.conf listens on 8080 with TLS enabled using the certificate baked + # into the image. Point an Ingress at it with an HTTPS backend, or provide + # your own certificate through tls.existingSecret. + port: 8080 + tls: + # Name of a TLS Secret whose keys are panda-wiki.crt and panda-wiki.key. + # Leave empty to use the self-signed certificate from the image. + existingSecret: "" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + service: + type: ClusterIP + port: 8080 + nodeSelector: {} + tolerations: [] + affinity: {} + +# Public wiki sites: Next.js server. +app: + # Serves the wiki sites Caddy routes to. Without it Caddy's default route has + # no upstream, so every knowledge base returns 502. + enabled: true + image: + repository: panda-wiki-app + tag: "" + pullPolicy: "" + port: 3010 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + nodeSelector: {} + tolerations: [] + affinity: {} + +# Dynamic gateway. The backend pushes the per-knowledge-base routing table into +# Caddy's admin API, so this runs as a single replica holding in-memory config. +caddy: + # The gateway that publishes the wiki sites and receives their routing table + # from the backend. Without it a knowledge base cannot be published: the api + # logs a sync failure and the sites are unreachable. + enabled: true + image: + repository: panda-wiki-caddy + tag: 2.10-alpine + pullPolicy: "" + # hostNetwork (default) matches the official docker compose deployment: it + # lets a knowledge base be published on any port the admin picks, which the + # product allows. NodePort/LoadBalancer can only expose ports listed in + # servicePorts, so arbitrary knowledge base ports stop working with them. + # + # hostNetwork pins the pod to one node and makes that node a single point of + # failure; use nodeSelector to choose which node carries the entry traffic. + # The port also has to be free on that node: Caddy starts with no server (the + # Caddyfile in the image is entirely commented out) and only binds the admin + # port until the backend pushes routes, so an install succeeds even where 80 + # and 443 are taken — it is publishing a knowledge base on a taken port that + # fails, because Caddy rejects the whole configuration rather than part of it. + # + # SECURITY: with hostNetwork, Caddy binds its admin port on the host's + # interfaces, so the admin API — which accepts unauthenticated full config + # writes — becomes reachable by anything that can reach that node. The chart + # keeps the admin port off the Service so the other exposure modes never + # publish it, but hostNetwork cannot be fixed from the manifest: traffic + # arrives through the host network namespace, so the chart's NetworkPolicy does + # not apply to it either. A host firewall is the only way to restrict the port, + # and using a non-hostNetwork exposure avoids the problem entirely. + exposure: hostNetwork # hostNetwork | NodePort | LoadBalancer | ClusterIP + # Ports the Service publishes, in every exposure mode. With NodePort each entry + # may set its own nodePort; the http and https entries fall back to the values + # below, and any further entry left without one gets a port picked by + # Kubernetes, so adding a third one never collides. + servicePorts: + - name: http + port: 80 + - name: https + port: 443 + # Extra knowledge base ports, for example [{name: kb-8081, port: 8081}]. + # nodePort is optional on each entry, for NodePort exposure. + extraPorts: [] + nodePort: + http: 30080 + https: 30443 + loadBalancer: + annotations: {} + # Port the backend uses to push configuration. Keep it on the cluster + # network only: the admin API accepts unauthenticated full config writes. + adminPort: 2019 + # Start Caddy with --resume so it reloads the autosaved config after a restart + # instead of reverting to the Caddyfile baked into the image. + # + # Safe, and verified on a real cluster: the baked Caddyfile is the stock + # template with every line commented out, so a fresh Caddy has no routes at all + # and everything comes from the backend's push. With --resume and no autosave + # yet, Caddy logs "no autosave file exists" and falls back to that Caddyfile, so + # a first install still starts. With an autosave present, a Caddy-only restart + # keeps the routing table instead of losing it until the api restarts. + resume: true + persistence: + # Keeps the autosave file and ACME certificates across restarts, and is what + # makes caddy.resume work when it is enabled. + enabled: true + size: 1Gi + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + nodeSelector: {} + tolerations: [] + affinity: {} + +# Closed-source RAG service. Needs PostgreSQL, Qdrant, MinIO and NATS. +raglite: + # The RAG service behind AI search, Q&A and creation. Turning it off requires + # raglite.external.baseURL, and without it those features do not work. + enabled: true + image: + repository: raglite + tag: v2.17.0 + pullPolicy: "" + port: 5050 + embedderRpm: 800 + llmRpm: 50 + persistence: + enabled: true + size: 5Gi + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 4Gi + nodeSelector: {} + tolerations: [] + affinity: {} + # Used when enabled is false: the base URL of a raglite deployed elsewhere, + # for example http://raglite.internal:5050. + external: + baseURL: "" + +# Closed-source document parser. Talks to NATS and MinIO only. +crawler: + # Parses imported documents. Without it, URL, sitemap, RSS and offline-file + # imports have no worker behind them. + enabled: true + image: + repository: anydoc + tag: v0.9.11 + pullPolicy: "" + port: 8080 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + nodeSelector: {} + tolerations: [] + affinity: {} + +migration: + # The api and consumer run with migrations off, so leaving this on is what + # applies the schema. Turning it off means nothing does: run the migrations + # yourself in that case, or the api starts against an empty database. + enabled: true + # post-install, not pre-install: Helm runs pre-install hooks *before* it + # creates anything, so on a fresh install with the bundled middleware the job + # would wait for PostgreSQL, Redis, NATS and MinIO that do not exist yet and + # time out. Running after the resources are created means the job's + # wait-for-dependencies container covers the gap until they accept + # connections, and on an upgrade the dependencies are already there anyway. + hook: post-install,pre-upgrade + # Extra time for the migrate binary to wait for its dependencies. + waitTimeoutSeconds: 300 + backoffLimit: 6 + +ingress: + admin: + # Needs admin.enabled and at least one host; the chart refuses to render an + # Ingress that would point at nothing or match nothing. + enabled: false + className: "" + annotations: {} + hosts: [] + # - host: panda-wiki.example.com + # paths: + # - path: / + # pathType: Prefix + tls: [] + wiki: + # Needs caddy.enabled and at least one host. Only useful with a Caddy + # exposure that publishes 80/443 on a Service. + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +networkPolicy: + # Restricts the Caddy admin API to the backend pods. + enabled: false + +backend: + # Values rendered into config.yml and mounted at /app/config.yml. Anything set + # here overrides the built-in defaults; passwords belong in secrets instead. + config: + log: + level: 0 + auth: + type: jwt + apm: + enabled: false + rag: + # Only needed when the raglite deployment in use requires an API key. + apiKey: "" + # Raw YAML appended to the generated config.yml, for settings that have + # neither an environment variable nor a value of their own. + extraConfigYaml: "" + telemetry: + # Reports usage to baizhi.cloud. Turn off for private deployments; multiple + # replicas would each report separately. + enabled: false + sentry: + enabled: false + dsn: "" + setup: + # The self-signed certificate is only useful when the api and the admin + # nginx share a volume, which is a docker compose arrangement. In Kubernetes + # the certificate comes from the image or from a Secret. + # + # Note: this switch lives in the open-source entrypoint. An image built from + # the pro entrypoint always creates the certificate, which is harmless where + # the filesystem is writable. + initCert: false + +postgres: + enabled: true + image: + repository: postgres-zhparser + tag: 17.6-bookworm + pullPolicy: "" + # The backend creates a "raglite" database unless + # backend.config.pg.create_raglite_db is false, so the user needs CREATEDB. + auth: + username: panda-wiki + database: panda-wiki + password: "" + existingSecret: "" + persistence: + enabled: true + size: 10Gi + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + # Used when enabled is false. + external: + host: "" + port: 5432 + username: "" + database: "" + sslMode: disable + existingSecret: "" + # Set when the external server does not grant CREATEDB. + createRagliteDB: false + +redis: + enabled: true + image: + repository: panda-wiki-redis + tag: 7.4.2-alpine + pullPolicy: "" + auth: + password: "" + existingSecret: "" + persistence: + enabled: true + size: 2Gi + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 1Gi + external: + addr: "" + existingSecret: "" + +nats: + enabled: true + image: + repository: panda-wiki-nats + tag: 2.11.3-alpine + pullPolicy: "" + auth: + username: panda-wiki + password: "" + existingSecret: "" + # JetStream stores streams on disk. + persistence: + enabled: true + size: 10Gi + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + external: + server: "" + username: "" + existingSecret: "" + +minio: + enabled: true + image: + repository: panda-wiki-minio + tag: RELEASE.2025-04-22T22-12-26Z-cpuv1 + pullPolicy: "" + auth: + # The backend defaults to this access key. + accessKey: s3panda-wiki + secretKey: "" + existingSecret: "" + bucket: static-file + persistence: + enabled: true + size: 20Gi + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + external: + endpoint: "" + accessKey: "" + secretKey: "" + bucket: "" + existingSecret: "" + +qdrant: + enabled: true + image: + repository: panda-wiki-qdrant + tag: v1.14.1 + pullPolicy: "" + apiKey: "" + existingSecret: "" + persistence: + enabled: true + size: 10Gi + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi From d5de27cfe1da2fe33f6cfd1fd7757ee64fa47b5f Mon Sep 17 00:00:00 2001 From: wangchuan Date: Thu, 8 Oct 2026 09:20:56 +0000 Subject: [PATCH 4/4] test: cover the health checker's probe aggregation The new health package had none. These four cases run without a database, cache or object store client, since they only exercise Register and Run: every probe is reported, an all-passing set is healthy, a hanging probe is bounded by its own deadline instead of holding readiness open, and Register is safe while Run is serving. The last one is what the mutex added in the previous commit is for, and it passes under -race. --- backend/health/checker_test.go | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 backend/health/checker_test.go diff --git a/backend/health/checker_test.go b/backend/health/checker_test.go new file mode 100644 index 000000000..275a69be7 --- /dev/null +++ b/backend/health/checker_test.go @@ -0,0 +1,97 @@ +package health + +import ( + "context" + "errors" + "testing" + "time" +) + +// newTestChecker builds a Checker without the real database, cache and object +// store clients, so the aggregation logic can be exercised on its own. +func newTestChecker() *Checker { + return &Checker{} +} + +func TestRunReportsEveryProbe(t *testing.T) { + c := newTestChecker() + c.Register("ok", func(context.Context) error { return nil }) + c.Register("broken", func(context.Context) error { return errors.New("connection refused") }) + + reports, healthy := c.Run(context.Background()) + + if healthy { + t.Error("Run reported healthy with a failing probe") + } + if got := reports["ok"]; got != "ok" { + t.Errorf("ok probe reported %q, want %q", got, "ok") + } + if got := reports["broken"]; got != "connection refused" { + t.Errorf("broken probe reported %q, want its error", got) + } +} + +func TestRunHealthyWhenEveryProbePasses(t *testing.T) { + c := newTestChecker() + c.Register("a", func(context.Context) error { return nil }) + c.Register("b", func(context.Context) error { return nil }) + + reports, healthy := c.Run(context.Background()) + + if !healthy { + t.Errorf("Run reported unhealthy with no failing probe: %v", reports) + } + if len(reports) != 2 { + t.Errorf("got %d reports, want 2", len(reports)) + } +} + +// A probe that never returns must not hold the endpoint open: each one gets its +// own deadline, which is what keeps readiness answering when a dependency hangs. +func TestRunBoundsEachProbe(t *testing.T) { + c := newTestChecker() + c.Register("hanging", func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }) + + done := make(chan struct{}) + var healthy bool + go func() { + _, healthy = c.Run(context.Background()) + close(done) + }() + + select { + case <-done: + case <-time.After(perCheckTimeout * 3): + t.Fatal("Run did not return; a hanging probe was not bounded") + } + if healthy { + t.Error("Run reported healthy although the only probe timed out") + } +} + +// Register is safe to call while readiness is being served: the api registers +// the message queue probe during startup, which can overlap with a probe. +func TestRegisterIsSafeWhileRunning(t *testing.T) { + c := newTestChecker() + c.Register("initial", func(context.Context) error { return nil }) + + stop := make(chan struct{}) + go func() { + for { + select { + case <-stop: + return + default: + c.Run(context.Background()) + } + } + }() + + for range 50 { + c.Register("concurrent", func(context.Context) error { return nil }) + } + close(stop) +}