Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
552e5fc
docs(mentorship-sync): add implementation plan
mlehotskylf Jun 12, 2026
49a0cb1
feat(mentorship-sync): add MentorshipProgram domain model and reposit…
mlehotskylf Jun 12, 2026
16ee1bc
feat(mentorship-sync): add FixtureSource for DEV environment testing
mlehotskylf Jun 12, 2026
994e4e3
feat(mentorship-sync): add Snowflake client with mock-driver unit test
mlehotskylf Jun 12, 2026
8f4016d
feat(mentorship-sync): add MentorshipRepository with upsert and benef…
mlehotskylf Jun 12, 2026
5873635
feat(mentorship-sync): add fixture data file for DEV environment testing
mlehotskylf Jun 12, 2026
5d10c50
feat(mentorship-sync): implement syncer and CronJob entry point
mlehotskylf Jun 12, 2026
959f06d
feat(mentorship-sync): add Dockerfile for CronJob container image
mlehotskylf Jun 12, 2026
0235cb3
fix(mentorship-sync): document FetchPrograms beneficiary limitation t…
mlehotskylf Jun 12, 2026
dc20ecb
fix(mentorship-sync): align Snowflake query with actual MENTORSHIP_PR…
mlehotskylf Jun 12, 2026
7b48d07
feat(mentorship-sync): add Helm CronJob template and values
mlehotskylf Jun 13, 2026
6241180
fix(review): address PR #141 review feedback
mlehotskylf Jun 13, 2026
09cd611
fix(review): address PR #141 second-round review feedback
mlehotskylf Jun 13, 2026
6af85a4
fix(review): address PR #141 third-round review feedback
mlehotskylf Jun 13, 2026
2cd46b8
feat(mentorship-sync): add owner_lf_username from Snowflake gold model
mlehotskylf Jun 13, 2026
ea4cafa
Merge branch 'main' into feat/mentorship-sync
mlehotskylf Jun 13, 2026
9ede94a
fix(mentorship-sync): address code review findings
mlehotskylf Jun 13, 2026
466d83a
fix(review): address PR #141 fifth-round review feedback
mlehotskylf Jun 13, 2026
ca6d366
Merge branch 'main' into feat/mentorship-sync
mlehotskylf Jun 14, 2026
b154c89
feat(mentorship-sync): implement full sync from Snowflake to PostgreSQL
lewisojile Jun 15, 2026
7b9842d
ci(checkov): fix helm chart rendering failures in MegaLinter
lewisojile Jun 15, 2026
53e0227
Merge branch 'main' into feat/mentorship-sync
lewisojile Jun 15, 2026
35dec7e
fix: address PR #141 review comments
lewisojile Jun 15, 2026
16fa4aa
Merge branch 'main' into feat/mentorship-sync
lewisojile Jun 15, 2026
a5d241e
fix: add HEALTHCHECK NONE to mentorship-sync Dockerfile
lewisojile Jun 15, 2026
4bf6e6f
fix: provide Checkov helm values to unblock chart rendering
lewisojile Jun 15, 2026
f4f836c
Potential fix for pull request finding
lewisojile Jun 15, 2026
bd68357
fix: remove unused UPDATED_AT column from Snowflake query
lewisojile Jun 15, 2026
c85110d
fix: remove invalid helm-values-file from .checkov.yml
lewisojile Jun 15, 2026
e5841a9
feat: filter Snowflake query to programs updated in last 30 days
lewisojile Jun 15, 2026
f799d90
feat: filter Snowflake query to programs updated in last 30 days
lewisojile Jun 15, 2026
019b133
Merge branch 'main' into feat/mentorship-sync
lewisojile Jun 15, 2026
c7c0a3f
fix: include jobspring_project_id in upsert DO UPDATE clause
lewisojile Jun 15, 2026
45967c6
feat: default initiative list endpoints to published status
lewisojile Jun 15, 2026
5bf9682
fix(chart): require mentorshipSyncCronJob.image.tag when CronJob is e…
lewisojile Jun 15, 2026
f9633fb
fix(mentorship-sync): trim OwnerLFUsername before validation and upsert
lewisojile Jun 15, 2026
73020b4
Potential fix for pull request finding
lewisojile Jun 15, 2026
b59165b
Potential fix for pull request finding
lewisojile Jun 15, 2026
005979f
Potential fix for pull request finding
lewisojile Jun 15, 2026
da30a52
chore: merge main into feat/mentorship-sync, resolve go.sum conflict
lewisojile Jun 16, 2026
bca7d7c
feat(mentorship-sync): add owner profile fields from Snowflake
lewisojile Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .checkov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,10 @@ skip-check:
# CKV_K8S_22: The frontend Nuxt/Nitro container requires a writable root
# filesystem (writes tmp files at runtime). Scope the skip to the specific
# file so the backend Deployment continues to be checked.
#
# validate.yaml files only emit {{- fail ... }} directives — they produce no
# Kubernetes resources and intentionally abort `helm template` when required
# values are absent. Checkov cannot render or check them meaningfully.
skip-path:
- frontend/charts/lfx-crowdfunding-frontend/templates/deployment.yaml
- backend/charts/lfx-crowdfunding-backend/templates/validate.yaml
36 changes: 36 additions & 0 deletions backend/Dockerfile.mentorship-sync
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright The Linux Foundation and each contributor to LFX.
# SPDX-License-Identifier: MIT

# ── Build stage ──────────────────────────────────────────────────────────────
FROM golang:1.25-alpine AS builder

WORKDIR /build

# Cache dependencies separately from source code
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" \
-o /build/mentorship-sync ./cmd/mentorship-sync

# ── Final stage ───────────────────────────────────────────────────────────────
FROM alpine:3.20

# ca-certificates is required for outbound TLS (Auth0 JWKS, Stripe, Ledger, OTLP).
# Security: run as non-root user
RUN apk add --no-cache ca-certificates \
&& addgroup -S appgroup && adduser -S appuser -G appgroup -u 65532

WORKDIR /app
COPY --from=builder /build/mentorship-sync ./mentorship-sync
COPY cmd/mentorship-sync/testdata/ /app/testdata/

USER appuser

# This is a CronJob — it runs to completion and exits. Health checks are not
# applicable; HEALTHCHECK NONE explicitly satisfies the Checkov CKV_DOCKER_2 check.
HEALTHCHECK NONE

ENTRYPOINT ["/app/mentorship-sync"]
14 changes: 14 additions & 0 deletions backend/charts/lfx-crowdfunding-backend/ci/checkov-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright The Linux Foundation and each contributor to LFX.
# SPDX-License-Identifier: MIT
---
# Checkov CI values — used by Checkov's helm runner to render the chart for
# static analysis. These values satisfy required guards in validate.yaml so
# that `helm template` can complete without aborting. They are never used in
# a real deployment; ArgoCD overrides all values via environment-specific
# values files and ExternalSecrets.

image:
tag: "ci-test"

config:
STRIPE_RETURN_URL: "https://crowdfunding.linuxfoundation.org/payment/complete"
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright The Linux Foundation and each contributor to LFX.
# SPDX-License-Identifier: MIT
{{- if .Values.mentorshipSyncCronJob.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "lfx-v2-initiatives-service.fullname" . }}-mentorship-sync
namespace: {{ .Release.Namespace }}
labels:
{{- include "lfx-v2-initiatives-service.labels" . | nindent 4 }}
app.kubernetes.io/component: mentorship-sync
spec:
schedule: {{ .Values.mentorshipSyncCronJob.schedule | quote }}
concurrencyPolicy: {{ .Values.mentorshipSyncCronJob.concurrencyPolicy }}
successfulJobsHistoryLimit: {{ .Values.mentorshipSyncCronJob.successfulJobsHistoryLimit }}
failedJobsHistoryLimit: {{ .Values.mentorshipSyncCronJob.failedJobsHistoryLimit }}
jobTemplate:
spec:
template:
metadata:
labels:
app.kubernetes.io/component: mentorship-sync
spec:
restartPolicy: OnFailure
serviceAccountName: {{ include "lfx-v2-initiatives-service.serviceAccountName" . }}
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 12 }}
containers:
- name: mentorship-sync
securityContext:
{{- toYaml .Values.securityContext | nindent 16 }}
image: "{{ .Values.mentorshipSyncCronJob.image.repository }}:{{ .Values.mentorshipSyncCronJob.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/app/mentorship-sync"]
{{- if or .Values.env .Values.mentorshipSyncCronJob.fixtureFile }}
env:
{{- with .Values.env }}
{{- toYaml . | nindent 16 }}
{{- end }}
{{- if .Values.mentorshipSyncCronJob.fixtureFile }}
- name: MENTORSHIP_SYNC_FIXTURE_FILE
value: {{ .Values.mentorshipSyncCronJob.fixtureFile | quote }}
{{- end }}
Comment thread
mlehotskylf marked this conversation as resolved.
{{- end }}
envFrom:
- configMapRef:
name: {{ include "lfx-v2-initiatives-service.fullname" . }}-config
- secretRef:
name: {{ .Values.secretName }}
resources:
Comment thread
lewisojile marked this conversation as resolved.
{{- toYaml .Values.mentorshipSyncCronJob.resources | nindent 16 }}
{{- end }}
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ No Kubernetes resource is rendered; this file only produces errors.
{{- if not .Values.config.STRIPE_RETURN_URL }}
{{- fail (printf "\n\nvalues.yaml: config.STRIPE_RETURN_URL is required.\nSet it to the frontend URL Stripe redirects to after a 3DS challenge, e.g.:\n config:\n STRIPE_RETURN_URL: \"https://crowdfunding.linuxfoundation.org/payment/complete\"\nLeaving it empty causes 3DS redirect-based payment challenges to fail in production.") }}
{{- end }}

{{- if and .Values.mentorshipSyncCronJob.enabled (not .Values.mentorshipSyncCronJob.image.tag) }}
{{- fail (printf "\n\nvalues.yaml: mentorshipSyncCronJob.image.tag is required when mentorshipSyncCronJob.enabled is true.\nSet it to the mentorship-sync image tag to deploy, e.g.:\n mentorshipSyncCronJob:\n image:\n tag: \"abc1234\"\nFalling back to the main API image tag would pull from the wrong image repository.") }}
{{- end }}
30 changes: 30 additions & 0 deletions backend/charts/lfx-crowdfunding-backend/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,36 @@ secretName: lfx-crowdfunding-backend-secrets
# Secret it did not create and the sync will fail silently.
createPlaceholderSecret: false

# ── CronJob: mentorship-sync ──────────────────────────────────────────────────
# Runs daily to pull Mentorship program data from Snowflake and upsert into
# initiatives (initiative_type=mentorship) and initiative_beneficiaries.
#
# DEV: set fixtureFile to /app/testdata/programs.json — no Snowflake config needed.
# Staging/prod: leave fixtureFile empty.
# Non-secret config (ACCOUNT, USER, WAREHOUSE, DATABASE, ROLE) → env: in ArgoCD values.
# Secret (SNOWFLAKE_PRIVATE_KEY only) → external secret (lfx-crowdfunding-backend-secrets).
mentorshipSyncCronJob:
enabled: false
schedule: "0 2 * * *" # daily at 02:00 UTC
# fixtureFile: path inside the container to a JSON fixture file.
# When set, the CronJob skips Snowflake and reads from this file instead.
# Override to "/app/testdata/programs.json" in DEV.
fixtureFile: ""
image:
# Required — use the dedicated mentorship-sync image, not the main API image.
repository: ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync
tag: ""
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
concurrencyPolicy: Forbid

# ── CronJob: ledger-stats-sync ────────────────────────────────────────────────
# Runs hourly to sync financial data from the ledger service into initiative_ledger_stats.
cronJob:
Expand Down
14 changes: 14 additions & 0 deletions backend/cmd/mentorship-sync/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

package main

import "log/slog"

func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(nopWriter{}, nil))
}

type nopWriter struct{}

func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
147 changes: 147 additions & 0 deletions backend/cmd/mentorship-sync/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

// mentorship-sync pulls Mentorship program data from Snowflake (or a JSON
// fixture in DEV) and upserts initiative_type=mentorship rows into CF Postgres.
//
// See backend/docs/rewrite/10-mentorship-sync-dev-testing.md for the DEV testing strategy.
//
// Usage: run as a K8s CronJob (daily schedule). Exits 0 on success,
// non-zero on any error. K8s uses the exit code to track CronJob health.
package main

import (
"context"
"fmt"
"log/slog"
"os"
"time"

"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/db"
"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

if err := run(logger); err != nil {
logger.Error("mentorship-sync failed", "error", err)
os.Exit(1)
}
}

func run(logger *slog.Logger) error {
ctx := context.Background()
start := time.Now()

cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("config: %w", err)
}

pool, err := db.NewPool(ctx, db.PoolConfig{
DSN: cfg.DatabaseURL,
MaxConns: 5,
MinConns: 1,
ConnMaxLifetime: cfg.DBConnMaxLifetime,
})
Comment thread
lewisojile marked this conversation as resolved.
if err != nil {
return fmt.Errorf("database pool: %w", err)
}
defer pool.Close()

var src mentorshipSource
if cfg.FixtureFile != "" {
logger.Info("using fixture source", "path", cfg.FixtureFile)
src = snowflake.NewFixtureSource(cfg.FixtureFile)
} else {
client, err := snowflake.NewClient(snowflake.ClientConfig{
Account: cfg.SnowflakeAccount,
User: cfg.SnowflakeUser,
Warehouse: cfg.SnowflakeWarehouse,
Database: cfg.SnowflakeDatabase,
Role: cfg.SnowflakeRole,
PrivateKey: cfg.SnowflakePrivateKey,
})
if err != nil {
return fmt.Errorf("snowflake client: %w", err)
}
defer client.Close()
src = client
}

repo := db.NewMentorshipRepository(pool)
syncer := newSyncer(repo, src, logger)

logger.Info("mentorship-sync starting")

result, err := syncer.Run(ctx)
if err != nil {
return fmt.Errorf("sync run: %w", err)
}

logger.Info("mentorship-sync complete",
"duration", time.Since(start).String(),
"total", result.total,
"upserted", result.upserted,
"errors", result.errors,
)
if result.errors > 0 {
return fmt.Errorf("sync completed with %d error(s) out of %d programs", result.errors, result.total)
}
return nil
}

type config struct {
DatabaseURL string
DBConnMaxLifetime time.Duration
FixtureFile string
SnowflakeAccount string
SnowflakeUser string
SnowflakeWarehouse string
SnowflakeDatabase string
SnowflakeRole string
SnowflakePrivateKey string
}

func loadConfig() (*config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}

cfg := &config{
DatabaseURL: dbURL,
DBConnMaxLifetime: 5 * time.Minute,
FixtureFile: os.Getenv("MENTORSHIP_SYNC_FIXTURE_FILE"),
}

if cfg.FixtureFile == "" {
cfg.SnowflakeAccount = os.Getenv("SNOWFLAKE_ACCOUNT")
if cfg.SnowflakeAccount == "" {
return nil, fmt.Errorf("SNOWFLAKE_ACCOUNT is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
cfg.SnowflakeUser = os.Getenv("SNOWFLAKE_USER")
if cfg.SnowflakeUser == "" {
return nil, fmt.Errorf("SNOWFLAKE_USER is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
cfg.SnowflakeWarehouse = os.Getenv("SNOWFLAKE_WAREHOUSE")
if cfg.SnowflakeWarehouse == "" {
return nil, fmt.Errorf("SNOWFLAKE_WAREHOUSE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
cfg.SnowflakeDatabase = os.Getenv("SNOWFLAKE_DATABASE")
if cfg.SnowflakeDatabase == "" {
return nil, fmt.Errorf("SNOWFLAKE_DATABASE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
cfg.SnowflakeRole = os.Getenv("SNOWFLAKE_ROLE")
if cfg.SnowflakeRole == "" {
return nil, fmt.Errorf("SNOWFLAKE_ROLE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
cfg.SnowflakePrivateKey = os.Getenv("SNOWFLAKE_PRIVATE_KEY")
if cfg.SnowflakePrivateKey == "" {
return nil, fmt.Errorf("SNOWFLAKE_PRIVATE_KEY is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set")
}
}

return cfg, nil
}
Loading
Loading