records, outbox, lifecycle requests, relay, push, read-back, and FedWiki lifecycle workflows - Add core.provider_connections table and unique provider connection per provider - Add core.target_mappings table linking connections to resource keys and targets - Add core.desired_state_records table storing desired entitlements per connection and recipient with generation and content hash - Add core.observed_projections table for application-reported state per connection and recipient - Add core.lifecycle_requests table recording lifecycle requests by instance and generation - Add core.person_cut_offs table and setter function to record persons cut off everywhere - Add core.desired_state_outbox table with triggers on entitlement, membership, organization, connection, mapping, and cut-off tables to write change notices - Enforce constraints on recipients, generations, and statuses across tables - Implement desired_state_content SQL function computing record content per recipient rules - Implement relay workflow to claim outbox rows, expand to records, recompute records, and dispatch pushes - Implement push workflow to push records, handle transport answers, and record push results - Implement read-back workflow to page through application state, update projections, and raise records as needed - Add lifecycle request writers with idempotent recording, finishing, and acknowledgment - Add FedWiki integration lifecycle workflows for create, set status, swap, delete, and reconcile - Add FedWiki web handlers and forms for lifecycle requests, including create form with instance and generation - Add tests for schema, triggers, relay, push, read-back, lifecycle requests, FedWiki workflows, and operator pages - Add documentation updates for provider integration, entitlements, identity, organizations, lifecycle, and FedWiki integration - Add benchmarks for delivery and push workflows - Ensure schema-per-provider for in-tree integrations; connector state lives in generic core tables keyed by connection - Secure functions and triggers with SECURITY DEFINER and restrict core_writer role privileges accordingly - Handle Temporal client integration for workflow execution and scheduling - Provide a recording transport for integration tests - Close Issues 32 and 33 by implementing provider connections and desired-state records with lifecycle requests and relay mechanisms
116 lines
4.8 KiB
Go
116 lines
4.8 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
|
|
)
|
|
|
|
// ensureBootDataState performs the data-state work `start` does after
|
|
// migrations and before serving, and `seed-demo` does before seeding, so a
|
|
// database that was reset, migrated, and reseeded (test/reset-app-db.sh,
|
|
// the screen-capture utility's first step) holds exactly what a booted
|
|
// deployment holds:
|
|
//
|
|
// - every installed integration's provider record, after its key is
|
|
// checked against its manifest (integration-registration);
|
|
// - the System tenant (system-tenant);
|
|
// - expired pending domain claims swept (claim-expiry-and-carve-guards
|
|
// D1/D3): core registry work with no integration in it, run BEFORE the
|
|
// reconciliation below because a stranded pending external claim at a
|
|
// configured farm domain is exactly what makes EnsureOperatorRoot fail;
|
|
// logged, not fatal, since an unswept claim holds one name where an
|
|
// unensurable root leaves every hosted name unallocatable;
|
|
// - monthly RANGE partitions for core.webhook_events for the months ahead
|
|
// (webhook-partition-maintenance); logged, not fatal;
|
|
// - each integration's domains reconciliation (design D8): operator roots
|
|
// ensured from deployment configuration and placements backfilled for
|
|
// resources that predate the registry. Unconditional, not an
|
|
// integration's Startup hook, which runs only when Temporal is
|
|
// configured. Fatal: a registry that does not fit the deployment's own
|
|
// namespace configuration is a misconfiguration.
|
|
func ensureBootDataState(ctx context.Context, database *sql.DB, logger *slog.Logger, integs []integrations.Integration) error {
|
|
providerSources := make([]integration.ProviderSource, 0, len(integs))
|
|
for _, integ := range integs {
|
|
ps := integ.Provider()
|
|
if err := integration.AssertKeyMatch(integ.Key(), ps.ProviderManifest()); err != nil {
|
|
return fmt.Errorf("integration key mismatch: %w", err)
|
|
}
|
|
providerSources = append(providerSources, ps)
|
|
}
|
|
if err := integration.RegisterProviders(ctx, database, providerSources); err != nil {
|
|
return fmt.Errorf("register providers: %w", err)
|
|
}
|
|
|
|
if _, err := systemtenant.Ensure(ctx, database); err != nil {
|
|
return fmt.Errorf("ensure system tenant: %w", err)
|
|
}
|
|
|
|
if result, err := domains.NewRegistry(database).SweepExpiredClaims(ctx); err != nil {
|
|
logger.Error("domain claim expiry sweep failed",
|
|
slog.Int("candidates", result.Candidates),
|
|
slog.Int("expired", result.Expired),
|
|
slog.Int("failed", result.Failed),
|
|
slog.Any("error", err))
|
|
} else if result.Candidates > 0 {
|
|
logger.Info("domain claim expiry sweep completed",
|
|
slog.Int("candidates", result.Candidates),
|
|
slog.Int("expired", result.Expired))
|
|
}
|
|
|
|
if err := db.EnsureWebhookEventPartitions(ctx, database, time.Now(), db.DefaultWebhookPartitionMonthsAhead); err != nil {
|
|
logger.Warn("webhook_events partition ensure failed", slog.Any("error", err))
|
|
}
|
|
|
|
for _, integ := range integs {
|
|
dr, ok := integ.(domainsReconciler)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := dr.ReconcileDomains(ctx, database, logger); err != nil {
|
|
return fmt.Errorf("reconcile domains registry for %s: %w", integ.Key(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// bindIntegrationConnections runs after ensureBootDataState has registered
|
|
// providers (provider-connections, design D28): it looks up, once, the
|
|
// connection boot registration ensured for every integration whose manifest
|
|
// owns resource keys, fails when one is missing, and hands the identifier to
|
|
// an integration that caches it (integrations.ConnectionBinder) before its
|
|
// routes and workflows are registered. A missing row means registration and
|
|
// the database disagree, which no request should discover later.
|
|
func bindIntegrationConnections(ctx context.Context, database *sql.DB, integs []integrations.Integration) error {
|
|
q := integration.New(database)
|
|
for _, integ := range integs {
|
|
manifest := integ.Provider().ProviderManifest()
|
|
if len(manifest.ResourceKeys) == 0 {
|
|
continue
|
|
}
|
|
conn, err := q.GetConnectionByProvider(ctx, manifest.Key)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("provider %q owns resource keys but has no connection", manifest.Key)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("look up the connection of provider %q: %w", manifest.Key, err)
|
|
}
|
|
if binder, ok := integ.(integrations.ConnectionBinder); ok {
|
|
binder.BindConnection(conn.ConnectionID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|