feat(server): add production-safe platform-template seed job

A fresh production environment only ran migrations, so the four built-in
platform templates were never inserted unless someone manually ran
dev-seed (which also writes broad demo data). Extract the platform
template upsert into a reusable repository.EnsurePlatformTemplates and
add a standalone cmd/seed-platform-templates that calls it. Bake the
binary into the migrate image and chain it after migrate up in both
docker-compose and the k3s migration job (mounting app config so the
binary can dial the right database).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 16:01:55 +08:00
parent c89683862e
commit 9c58fe2312
8 changed files with 153 additions and 29 deletions
+8
View File
@@ -23,15 +23,23 @@ RUN --mount=type=cache,target=/go/pkg/mod \
go build -trimpath -ldflags="-s -w" \
-o /bin/service ./cmd/${SERVICE}
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=1 GOOS="${TARGETOS:-$(go env GOOS)}" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \
go build -trimpath -ldflags="-s -w" \
-o /bin/seed-platform-templates ./cmd/seed-platform-templates
# ─── Stage 2: Migration image (bundles SQL files + migrate binary) ─────────────
FROM migrate/migrate:v4.19.1 AS migrate-tool
FROM alpine:3.19 AS migrate
RUN apk --no-cache add ca-certificates
COPY --from=migrate-tool /usr/local/bin/migrate /usr/local/bin/migrate
COPY --from=builder /bin/seed-platform-templates /usr/local/bin/seed-platform-templates
COPY migrations/ /migrations/
COPY migrations_monitoring/ /migrations_monitoring/
COPY migrations_ops/ /migrations_ops/
COPY configs/ /app/configs/
ENTRYPOINT ["/usr/local/bin/migrate"]
# ─── Stage 3: Runtime image ────────────────────────────────────────────────────
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: dev-init dev-api dev-worker-generate dev-scheduler dev-ops-api migrate-up migrate-down migrate-monitoring-up migrate-monitoring-down migrate-ops-up migrate-ops-down migrate-create migrate-ops-create sqlc-generate tenant-scope-guard lint test tidy seed
.PHONY: dev-init dev-api dev-worker-generate dev-scheduler dev-ops-api migrate-up migrate-down migrate-monitoring-up migrate-monitoring-down migrate-ops-up migrate-ops-down migrate-create migrate-ops-create sqlc-generate tenant-scope-guard lint test tidy seed seed-platform-templates
DB_URL ?= postgres://geo:geo_dev@localhost:5432/geo?sslmode=disable
MONITORING_DB_URL ?= postgres://geo:geo_dev@localhost:5433/geo_monitoring?sslmode=disable
@@ -61,6 +61,9 @@ tenant-scope-guard: ## Ensure tenant-scoped SQL includes tenant_id filters
seed: ## Insert development seed data
go run ./cmd/dev-seed
seed-platform-templates: ## Insert or update built-in platform templates
go run ./cmd/seed-platform-templates
lint: ## Run linter
golangci-lint run ./...
+2 -27
View File
@@ -485,33 +485,8 @@ func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64, total int
}
func ensureTemplates(ctx context.Context, tx pgx.Tx) error {
for _, tpl := range prompts.PlatformTemplateSeeds() {
_, err := tx.Exec(ctx, `
INSERT INTO article_templates (scope, origin_type, template_key, template_name, prompt_template, prompt_visibility, card_config_json, status)
VALUES ('platform', 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active')
ON CONFLICT DO NOTHING
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
if err != nil {
return wrapSeedErr("insert template", err)
}
_, err = tx.Exec(ctx, `
UPDATE article_templates
SET template_name = $2,
prompt_template = $3,
card_config_json = $4::jsonb,
prompt_visibility = 'visible',
status = 'active',
updated_at = NOW()
WHERE scope = 'platform'
AND template_key = $1
AND deleted_at IS NULL
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
if err != nil {
return wrapSeedErr("update template", err)
}
}
return nil
_, err := repository.EnsurePlatformTemplates(ctx, tx)
return wrapSeedErr("ensure platform templates", err)
}
func ensureBrand(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) {
@@ -0,0 +1,60 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func main() {
configPath := "configs/config.yaml"
if p := strings.TrimSpace(os.Getenv("CONFIG_PATH")); p != "" {
configPath = p
}
cfg, err := config.Load(configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
if password := strings.TrimSpace(os.Getenv("POSTGRES_PASSWORD")); password != "" {
cfg.Database.Password = password
}
prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml"))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
if err != nil {
log.Fatalf("connect database: %v", err)
}
defer pool.Close()
tx, err := pool.Begin(ctx)
if err != nil {
log.Fatalf("begin transaction: %v", err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
count, err := repository.EnsurePlatformTemplates(ctx, tx)
if err != nil {
log.Fatalf("ensure platform templates: %v", err)
}
if err := tx.Commit(ctx); err != nil {
log.Fatalf("commit platform templates: %v", err)
}
fmt.Printf("Platform templates ensured: %d\n", count)
}
@@ -0,0 +1,43 @@
package repository
import (
"context"
"fmt"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
func EnsurePlatformTemplates(ctx context.Context, db generated.DBTX) (int, error) {
seeds := prompts.PlatformTemplateSeeds()
for _, tpl := range seeds {
_, err := db.Exec(ctx, `
INSERT INTO article_templates (
scope,
tenant_id,
origin_type,
template_key,
template_name,
prompt_template,
prompt_visibility,
card_config_json,
status,
version_no
)
VALUES ('platform', NULL, 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active', 1)
ON CONFLICT (scope, template_key, version_no)
WHERE deleted_at IS NULL AND tenant_id IS NULL
DO UPDATE SET
template_name = EXCLUDED.template_name,
prompt_template = EXCLUDED.prompt_template,
card_config_json = EXCLUDED.card_config_json,
prompt_visibility = 'visible',
status = 'active',
updated_at = NOW()
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
if err != nil {
return 0, fmt.Errorf("upsert platform template %q: %w", tpl.Key, err)
}
}
return len(seeds), nil
}