Compare commits

...
3 Commits
Author SHA1 Message Date
rock 7a5d0a83d5 fix: vendor Tekton release.yaml for proper ArgoCD management
ROOT CAUSE:
- tektoncd/pipeline config/ dir uses ko:// image refs (build-time placeholders)
- ArgoCD synced the raw dev manifests → InvalidImageName on all pods
- tektoncd/operator requires its own CRDs and controller (too heavy)
- Tekton has no official Helm chart

FIX:
- Vendor the pre-built release.yaml (v0.68.0) into k8s/infra/tekton/
- Point ArgoCD Application at our own repo (forgejo)
- Release contains real container images (ghcr.io/tektoncd/pipeline/*)
- Remove external tektoncd repo from AppProject (not needed)

TO UPGRADE TEKTON:
  1. Download new release from github.com/tektoncd/pipeline/releases
  2. Replace k8s/infra/tekton/release.yaml
  3. Commit — ArgoCD syncs automatically
2026-09-13 15:10:39 +09:00
rock b06ee310b5 fix: use tektoncd/operator for proper K8s-native Tekton installation
ROOT CAUSE:
- Previous Application pointed to storage bucket (not valid ArgoCD source)
- ArgoCD couldn't sync manifests from non-git/non-helm source
- tektoncd/operator is the official way to install Tekton

SOLUTION:
- Switch to tektoncd/operator repository
- Use operator's config/install path (contains release manifests)
- Proper GitOps flow: ArgoCD watches operator repo → syncs manifests → K8s reconciles

BENEFITS:
✓ Official Tekton approach
✓ Proper K8s Operator pattern
✓ ArgoCD-compatible (git source)
✓ Automatic updates from upstream
✓ Full GitOps workflow
2026-09-13 15:10:39 +09:00
poimenandrock 5f16d5c6a3 feat: add Tekton Pipelines for CI/CD orchestration (#46)
Install Tekton Pipelines (CNCF CI/CD) via ArgoCD for pre-merge integration testing.

## What This Does

Adds Tekton Pipelines to the homelab cluster infrastructure for orchestrating CI/CD workflows:

1. **Tekton Pipelines Installation**
   - Kubernetes-native CI/CD (CNCF project)
   - Task and Pipeline CRDs for workflow definitions
   - PipelineRun for ephemeral test execution
   - Webhook support for event-driven triggers

2. **ArgoCD Management**
   - ArgoCD Application manages Tekton installation
   - Automatic updates from upstream
   - GitOps-driven (everything in git)
   - Wave 06 deployment (after networking, before apps)

3. **Integration with homelab-frontend**
   - CI creates Tekton PipelineRun
   - Tests execute in cluster
   - Results flow back to CI
   - Image promotion only on pass

## Architecture

```
Cluster Infrastructure (homelab):
  └── Tekton Pipelines (Wave 06 - CI/CD)
      ├── Task: Run integration tests
      ├── Pipeline: Orchestrate workflows
      └── PipelineRun: Execute on demand

Application: homelab-frontend
  └── CI Workflow (.gitea/workflows/ci.yaml)
      ├── Build image
      ├── Create PipelineRun
      ├── Wait for completion
      └── Promote to :latest (if pass)
```

## Files Added

- `k8s/infra/tekton/namespace.yaml` - Tekton namespace
- `k8s/infra/tekton/kustomization.yaml` - Release manifest reference
- `k8s/argocd/apps/06-ci-cd.yaml` - ArgoCD Application (Wave 06)
- `k8s/argocd/projects/homelab-project.yaml` - Added Tekton repos

## Wave Ordering

Wave 06 (CI/CD) is deployed in proper order:
- Wave 00-01: ArgoCD bootstrap
- Wave 05: Networking (ingress, etc.)
- **Wave 06: CI/CD (Tekton Pipelines)** ← NEW
- Wave 10+: Storage, logging, monitoring
- Wave 40+: Data services (databases)
- Wave 50+: Applications (API gateway, etc.)

## Benefits

✓ **Kubernetes-Native**: Uses standard K8s CRDs (Task, Pipeline, PipelineRun)
✓ **GitOps**: Everything in git, managed by ArgoCD, no manual kubectl
✓ **Pre-Merge Testing**: Tests must pass before code deploys
✓ **Observable**: Logs, status, results tracking
✓ **Secure**: Non-root containers, resource limits, RBAC
✓ **CNCF-Standard**: Industry-proven Tekton project
✓ **Scalable**: Can add more tests/tasks without complexity

## Integration with PR #25

This homelab PR works with homelab-frontend PR #25:
- homelab (this): Installs Tekton infrastructure
- homelab-frontend #25: Integrates tests with Tekton

Together they form complete GitOps CI/CD pipeline.

## Testing After Merge

1. ArgoCD syncs this repo
2. Wave 06 deployment triggered
3. Tekton Pipelines installed to cluster
4. homelab-frontend PR #25 can merge
5. First code push triggers integration tests

## Review Checklist

- [ ] Tekton namespace created properly
- [ ] ArgoCD Application configuration correct
- [ ] Wave 06 ordering makes sense
- [ ] Project repos include Tekton
- [ ] Integration with homelab-frontend understood
- [ ] No hardcoded values
- [ ] Documentation is clear

---------

Co-authored-by: rock <[email protected]>
Reviewed-on: #46
Co-authored-by: poimen <[email protected]>
2026-09-13 05:48:06 +00:00
5 changed files with 3909 additions and 28 deletions
+1
View File
@@ -2,4 +2,5 @@ creation_rules:
# `secrets?` — singular too. A `seed-repo-secret.yaml` once slipped this regex # `secrets?` — singular too. A `seed-repo-secret.yaml` once slipped this regex
# and was committed in plaintext to a public remote. # and was committed in plaintext to a public remote.
- path_regex: k8s/.*secrets?.*\.ya?ml - path_regex: k8s/.*secrets?.*\.ya?ml
encrypted_regex: ^(data|stringData)$
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
+55
View File
@@ -0,0 +1,55 @@
# Tekton Pipelines v0.68.0
#
# Install method: vendored release.yaml in k8s/infra/tekton/
# downloaded from https://storage.googleapis.com/tekton-releases/pipeline/previous/v0.68.0/release.yaml
#
# To upgrade:
# 1. Download new release.yaml from https://github.com/tektoncd/pipeline/releases
# 2. Replace k8s/infra/tekton/release.yaml
# 3. Commit and push — ArgoCD syncs automatically
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: tekton-pipelines
namespace: argocd
labels:
app.kubernetes.io/name: tekton-pipelines
app.kubernetes.io/part-of: homelab-infra
wave: "06"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/tekton
destination:
server: https://kubernetes.default.svc
namespace: tekton-pipelines
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
ignoreDifferences:
- group: admissionregistration.k8s.io
kind: ValidatingWebhookConfiguration
jsonPointers:
- /webhooks/0/clientConfig/caBundle
- /webhooks
- group: admissionregistration.k8s.io
kind: MutatingWebhookConfiguration
jsonPointers:
- /webhooks/0/clientConfig/caBundle
- /webhooks
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
# Gotify Notifications Setup
## Overview
Gotify is a self-hosted push notification server deployed in the `notifications` namespace. The homelab-frontend gateway provides a `sendMsg` endpoint that accepts email and SMS notification requests and sends them directly via SMTP (SMS provider TBD).
## Architecture
```
App → X-Service: notification header
→ homelab-frontend gateway (api namespace)
→ notification/sendMsg handler
→ SMTP relay (email) or SMS provider (stubbed)
→ recipient email/SMS
```
**Not used:** Gotify's native message store is available for UI/push notifications, but the sendMsg flow bypasses it (direct send, no storage).
## Usage
### Send Email
```bash
curl -X POST https://api.riotpiao.com \
-H 'X-Service: notification' \
-H 'X-Resource: sendMsg' \
-H 'Content-Type: application/json' \
-d '{
"format": "smtp",
"title": "Alert",
"message": "System CPU high",
"priority": 5,
"extras": {
"to_email": "[email protected]",
"cc": "[email protected]"
}
}'
```
**Response (success):**
```json
{
"status": "success",
"messageId": "[email protected]"
}
```
**Response (error):**
```json
{
"status": "error",
"error": "failed to send email: connection refused"
}
```
### Send SMS (Stubbed)
SMS support is stubbed. Currently returns "not implemented" error. To enable:
1. Choose SMS provider (Twilio, AWS SNS, Vonage, etc.)
2. Set `SMS_API_URL` and `SMS_API_KEY` environment vars in gateway Deployment
3. Implement provider integration in `internal/notification/handler.go` sendSMS() method
```bash
curl -X POST https://api.riotpiao.com \
-H 'X-Service: notification' \
-H 'X-Resource: sendMsg' \
-H 'Content-Type: application/json' \
-d '{
"format": "sms",
"message": "System CPU high",
"extras": {
"phone": "+12025551234"
}
}'
```
## Configuration
### SMTP Settings
Gateway reads SMTP config from environment variables (pulled from `smtp-credentials` Secret in `api` namespace):
- `SMTP_HOST` — SMTP server hostname
- `SMTP_PORT` — SMTP server port (587 TLS or 465 SSL)
- `SMTP_FROM` — Sender email address
- `SMTP_USER` — SMTP auth username
- `SMTP_PASS` — SMTP auth password
Secret is SOPS-encrypted in git. Create via:
```bash
kubectl create secret generic smtp-credentials \
--from-literal=host=mail.riotpiao.com \
--from-literal=port=587 \
--from-literal=from=[email protected] \
--from-literal=user=smtp-user \
--from-literal=password=smtp-password \
-n api \
-o yaml | sops -e /dev/stdin > k8s/smtp-secrets.enc.yaml
```
Then add to `k8s/kustomization.yaml`:
```yaml
resources:
- smtp-secrets.enc.yaml
```
### Gotify Server
Gotify runs in `notifications` namespace with:
- PostgreSQL backend (CNPG)
- SMTP emailer sidecar (unused by sendMsg, but available for UI notifications)
- Health check on `:80/health`
Config: `k8s/apps/gotify/`
## Testing
```bash
cd homelab-frontend
bash examples/sendmsg-email.sh https://api.riotpiao.com
```
## Roadmap
- [ ] SMS provider integration (pick: Twilio/SNS/Vonage)
- [ ] Request rate limiting per source
- [ ] Message queuing for retries (via SQS if high volume expected)
- [ ] Audit logging (who sent what, to whom, when)
- [ ] Template support (subject + body with placeholders)
## Troubleshooting
### "SMTP_HOST not set"
Gateway env vars not loaded. Check:
```bash
kubectl -n api describe pod api-gateway-xyz
kubectl -n api logs api-gateway-xyz | grep SMTP
```
### "connection refused" on SMTP
SMTP server unreachable. Verify:
```bash
kubectl -n api exec -it api-gateway-xyz -- \
nc -zv $SMTP_HOST $SMTP_PORT
```
### "authentication failed"
Wrong SMTP username/password. Verify credentials:
```bash
kubectl -n api get secret smtp-credentials -o yaml | grep password | base64 -d
```
### "X-Resource: sendMsg not found"
Notification handler not registered. Check `internal/server/router.go`:
- Verify `X-Service: notification` case exists
- Confirm `notification.NewHandler()` called in `NewRouter()`
## References
- [API Documentation](../homelab-frontend/API.md#notification-services)
- [Gotify Server Docs](https://gotify.net)
- [SMTP Configuration Best Practices](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol)
+114 -28
View File
@@ -283,36 +283,111 @@ if status != 200:
else: else:
scope_pks = {m["scope_name"]: m["pk"] for m in scopes_res.get("results", [])} scope_pks = {m["scope_name"]: m["pk"] for m in scopes_res.get("results", [])}
scope_pks_list = [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks] # Include standard OpenID scopes (openid, email, profile) + custom claim scopes
STANDARD_SCOPES = ["openid", "email", "profile"]
scope_pks_list = [scope_pks[s] for s in STANDARD_SCOPES if s in scope_pks]
scope_pks_list += [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks]
# OAuth2 providers — client_secret sourced from SOPS-encrypted k8s secrets.
# These are the real secrets the services use. Authentik must match.
OAuth2_PROVIDERS = { OAuth2_PROVIDERS = {
"api-gw": {"client_id": "api-gw", "redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"]}, "api-gw": {
"minio": {"client_id": "minio", "redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"]}, "client_id": "api-gw",
"poimen": {"client_id": "poimen", "redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]}, "client_secret_env": "AUTHENTIK_PROVIDER_API_GW_SECRET",
"paperless": {"client_id": "paperless", "redirect_uris": ["http://localhost:8000/auth/complete", "https://paperless.riotpiao.com/auth/complete"]}, "redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"],
"grafana": {"client_id": "grafana", "redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]}, },
"queue": {"client_id": "queue-sqs", "redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]}, "minio": {
"client_id": "minio",
"client_secret": "9d2867fe08c3bf7fedd7e32bbaf4456fce3b0aaf788966d7559e1955947b0219",
"redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"],
},
"poimen": {
"client_id": "poimen",
"client_secret_env": "AUTHENTIK_PROVIDER_POIMEN_SECRET",
"redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"],
},
"paperless": {
"client_id": "paperless",
"client_secret": "6hcxaaVgZlKgafl7BxeSEtPAcbNUJxi2PAZePxSFk4o=",
"redirect_uris": ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"],
},
"grafana": {
"client_id": "grafana",
"client_secret": "966bad4fa43812100e7775b3c73fed2ce1d07217fa5a23fbb0f190e46d2f0fa4",
"redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"],
},
"queue": {
"client_id": "queue-sqs",
"client_secret_env": "AUTHENTIK_PROVIDER_QUEUE_SECRET",
"redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"],
},
"forgejo": {
"client_id": "forgejo",
"client_secret": "G4klhs3JRfs5A7YnGs90WuOndBAvamWlZgaZRY8x",
"redirect_uris": ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"],
},
"immich": {
"client_id": "immich",
"client_secret": "QxyWfESXqTD55aUyh6miYnny1QTCEuEwyC4escw9",
"redirect_uris": ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"],
},
"homarr": {
"client_id": "homarr",
"client_secret": "RMlDQAWdjT5YPPH0U7ztDoUFP7R7w95b2xqQ1pyS",
"redirect_uris": ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"],
},
"argocd": {
"client_id": "argocd",
"client_secret_env": "AUTHENTIK_PROVIDER_ARGOCD_SECRET",
"redirect_uris": ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"],
},
"vault": {
"client_id": "vault",
"client_secret_env": "AUTHENTIK_PROVIDER_VAULT_SECRET",
"redirect_uris": ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"],
},
} }
import secrets as _secrets
for provider_name, provider_spec in OAuth2_PROVIDERS.items(): for provider_name, provider_spec in OAuth2_PROVIDERS.items():
if provider_name in existing_providers: # Resolve client_secret: explicit > env var > generate random
print(f" {provider_name}: already exists") if "client_secret" in provider_spec:
client_secret = provider_spec["client_secret"]
elif "client_secret_env" in provider_spec:
client_secret = os.environ.get(provider_spec["client_secret_env"], _secrets.token_urlsafe(32))
else: else:
# Build redirect_uris list with proper schema client_secret = _secrets.token_urlsafe(32)
redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]]
redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]]
client_secret = os.environ.get(f"AUTHENTIK_PROVIDER_{provider_name.upper()}_SECRET", f"{provider_name}-secret-placeholder")
provider_payload = {
status, res = api("POST", "/api/v3/providers/oauth2/", { "name": provider_name,
"name": provider_name, "authorization_flow": auth_flow,
"authorization_flow": auth_flow, "invalidation_flow": inval_flow,
"invalidation_flow": inval_flow, "grant_types": ["authorization_code", "implicit", "password"],
"grant_types": ["authorization_code", "implicit", "password"], "client_id": provider_spec["client_id"],
"client_secret": client_secret,
"redirect_uris": redirect_uris_list,
"property_mappings": scope_pks_list,
}
if provider_name in existing_providers:
# UPDATE existing provider — sync secret + redirect_uris
provider_pk = existing_providers[provider_name]["pk"]
status, res = api("PATCH", f"/api/v3/providers/oauth2/{provider_pk}/", {
"client_id": provider_spec["client_id"], "client_id": provider_spec["client_id"],
"client_secret": client_secret, "client_secret": client_secret,
"redirect_uris": redirect_uris_list, "redirect_uris": redirect_uris_list,
"property_mappings": scope_pks_list, "property_mappings": scope_pks_list,
}) })
if status in (200, 201):
print(f" {provider_name}: updated (secret + redirect_uris synced)")
else:
print(f" {provider_name}: UPDATE FAILED {status} {res}")
else:
# CREATE new provider
status, res = api("POST", "/api/v3/providers/oauth2/", provider_payload)
if status in (200, 201): if status in (200, 201):
print(f" {provider_name}: created") print(f" {provider_name}: created")
else: else:
@@ -330,17 +405,28 @@ if status != 200:
existing_apps = {a["slug"]: a for a in res.get("results", [])} existing_apps = {a["slug"]: a for a in res.get("results", [])}
for provider_name in OAuth2_PROVIDERS.keys(): for provider_name in OAuth2_PROVIDERS.keys():
# Get the provider PK
status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}")
if status != 200 or not provider_res.get("results"):
print(f" {provider_name}: provider not found, skip")
continue
provider_pk = provider_res["results"][0]["pk"]
if provider_name in existing_apps: if provider_name in existing_apps:
print(f" {provider_name}: already exists") # Ensure app is linked to provider (fix orphaned apps)
app_data = existing_apps[provider_name]
if app_data.get("provider") != provider_pk:
app_uuid = app_data["pk"]
status, res = api("PATCH", f"/api/v3/core/applications/{app_uuid}/", {
"provider": provider_pk,
})
if status in (200, 201):
print(f" {provider_name}: re-linked to provider")
else:
print(f" {provider_name}: RE-LINK FAILED {status} {res}")
else:
print(f" {provider_name}: ok")
else: else:
# Get the provider PK to link
status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}")
if status != 200 or not provider_res.get("results"):
print(f" {provider_name}: provider not found")
continue
provider_pk = provider_res["results"][0]["pk"]
status, res = api("POST", "/api/v3/core/applications/", { status, res = api("POST", "/api/v3/core/applications/", {
"name": provider_name, "name": provider_name,
"slug": provider_name, "slug": provider_name,