Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fb8042265 | |||
| 9b59e4bdbc | |||
| e2c4475867 | |||
| e1103f2b7d | |||
| b0a431f2a4 | |||
| 62664c93ff | |||
| 6572f63157 |
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Kubebuilder DevContainer",
|
|
||||||
"image": "golang:1.26",
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
|
||||||
"moby": false,
|
|
||||||
"dockerDefaultAddressPool": "base=172.30.0.0/16,size=24"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/git:1": {},
|
|
||||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
|
||||||
"upgradePackages": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"runArgs": ["--privileged", "--init"],
|
|
||||||
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"settings": {
|
|
||||||
"terminal.integrated.shell.linux": "/bin/bash"
|
|
||||||
},
|
|
||||||
"extensions": [
|
|
||||||
"ms-kubernetes-tools.vscode-kubernetes-tools",
|
|
||||||
"ms-azuretools.vscode-docker"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"remoteEnv": {
|
|
||||||
"GO111MODULE": "on"
|
|
||||||
},
|
|
||||||
|
|
||||||
"onCreateCommand": "bash .devcontainer/post-install.sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "===================================="
|
|
||||||
echo "Kubebuilder DevContainer Setup"
|
|
||||||
echo "===================================="
|
|
||||||
|
|
||||||
# Verify running as root (required for installing to /usr/local/bin and /etc)
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "ERROR: This script must be run as root"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Detecting system architecture..."
|
|
||||||
# Detect architecture using uname
|
|
||||||
MACHINE=$(uname -m)
|
|
||||||
case "${MACHINE}" in
|
|
||||||
x86_64)
|
|
||||||
ARCH="amd64"
|
|
||||||
;;
|
|
||||||
aarch64|arm64)
|
|
||||||
ARCH="arm64"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "WARNING: Unsupported architecture ${MACHINE}, defaulting to amd64"
|
|
||||||
ARCH="amd64"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo "Architecture: ${ARCH}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "------------------------------------"
|
|
||||||
echo "Setting up bash completion..."
|
|
||||||
echo "------------------------------------"
|
|
||||||
|
|
||||||
BASH_COMPLETIONS_DIR="/usr/share/bash-completion/completions"
|
|
||||||
|
|
||||||
# Enable bash-completion in root's .bashrc (devcontainer runs as root)
|
|
||||||
if ! grep -q "source /usr/share/bash-completion/bash_completion" ~/.bashrc 2>/dev/null; then
|
|
||||||
echo 'source /usr/share/bash-completion/bash_completion' >> ~/.bashrc
|
|
||||||
echo "Added bash-completion to .bashrc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "------------------------------------"
|
|
||||||
echo "Installing development tools..."
|
|
||||||
echo "------------------------------------"
|
|
||||||
|
|
||||||
# Install kind
|
|
||||||
if ! command -v kind &> /dev/null; then
|
|
||||||
echo "Installing kind..."
|
|
||||||
curl -Lo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/latest/kind-linux-${ARCH}"
|
|
||||||
chmod +x /usr/local/bin/kind
|
|
||||||
echo "kind installed successfully"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate kind bash completion
|
|
||||||
if command -v kind &> /dev/null; then
|
|
||||||
if kind completion bash > "${BASH_COMPLETIONS_DIR}/kind" 2>/dev/null; then
|
|
||||||
echo "kind completion installed"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to generate kind completion"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install kubebuilder
|
|
||||||
if ! command -v kubebuilder &> /dev/null; then
|
|
||||||
echo "Installing kubebuilder..."
|
|
||||||
curl -Lo /usr/local/bin/kubebuilder "https://go.kubebuilder.io/dl/latest/linux/${ARCH}"
|
|
||||||
chmod +x /usr/local/bin/kubebuilder
|
|
||||||
echo "kubebuilder installed successfully"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate kubebuilder bash completion
|
|
||||||
if command -v kubebuilder &> /dev/null; then
|
|
||||||
if kubebuilder completion bash > "${BASH_COMPLETIONS_DIR}/kubebuilder" 2>/dev/null; then
|
|
||||||
echo "kubebuilder completion installed"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to generate kubebuilder completion"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install kubectl
|
|
||||||
if ! command -v kubectl &> /dev/null; then
|
|
||||||
echo "Installing kubectl..."
|
|
||||||
KUBECTL_VERSION=$(curl -Ls https://dl.k8s.io/release/stable.txt)
|
|
||||||
curl -Lo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl"
|
|
||||||
chmod +x /usr/local/bin/kubectl
|
|
||||||
echo "kubectl installed successfully"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate kubectl bash completion
|
|
||||||
if command -v kubectl &> /dev/null; then
|
|
||||||
if kubectl completion bash > "${BASH_COMPLETIONS_DIR}/kubectl" 2>/dev/null; then
|
|
||||||
echo "kubectl completion installed"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to generate kubectl completion"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate Docker bash completion
|
|
||||||
if command -v docker &> /dev/null; then
|
|
||||||
if docker completion bash > "${BASH_COMPLETIONS_DIR}/docker" 2>/dev/null; then
|
|
||||||
echo "docker completion installed"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to generate docker completion"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "------------------------------------"
|
|
||||||
echo "Configuring Docker environment..."
|
|
||||||
echo "------------------------------------"
|
|
||||||
|
|
||||||
# Wait for Docker to be ready
|
|
||||||
echo "Waiting for Docker to be ready..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if docker info >/dev/null 2>&1; then
|
|
||||||
echo "Docker is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$i" -eq 30 ]; then
|
|
||||||
echo "WARNING: Docker not ready after 30s"
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Create kind network (ignore if already exists)
|
|
||||||
if ! docker network inspect kind >/dev/null 2>&1; then
|
|
||||||
if docker network create kind >/dev/null 2>&1; then
|
|
||||||
echo "Created kind network"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to create kind network (may already exist)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "------------------------------------"
|
|
||||||
echo "Verifying installations..."
|
|
||||||
echo "------------------------------------"
|
|
||||||
kind version
|
|
||||||
kubebuilder version
|
|
||||||
kubectl version --client
|
|
||||||
docker --version
|
|
||||||
go version
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "===================================="
|
|
||||||
echo "DevContainer ready!"
|
|
||||||
echo "===================================="
|
|
||||||
echo "All development tools installed successfully."
|
|
||||||
echo "You can now start building Kubernetes operators."
|
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.6-bookworm
|
image: golang:1.26.9-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.6-bookworm
|
image: golang:1.26.9-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.6-bookworm
|
image: golang:1.26.9-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
# terdut-operator - AI Agent Guide
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
**Single-group layout (default):**
|
|
||||||
```
|
|
||||||
cmd/main.go Manager entry (registers controllers/webhooks)
|
|
||||||
api/<version>/*_types.go CRD schemas (+kubebuilder markers)
|
|
||||||
api/<version>/zz_generated.* Auto-generated (DO NOT EDIT)
|
|
||||||
internal/controller/* Reconciliation logic
|
|
||||||
internal/webhook/* Validation/defaulting (if present)
|
|
||||||
config/crd/bases/* Generated CRDs (DO NOT EDIT)
|
|
||||||
config/rbac/role.yaml Generated RBAC (DO NOT EDIT)
|
|
||||||
config/samples/* Example CRs (edit these)
|
|
||||||
Makefile Build/test/deploy commands
|
|
||||||
PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Multi-group layout** (for projects with multiple API groups):
|
|
||||||
```
|
|
||||||
api/<group>/<version>/*_types.go CRD schemas by group
|
|
||||||
internal/controller/<group>/* Controllers by group
|
|
||||||
internal/webhook/<group>/<version>/* Webhooks by group and version (if present)
|
|
||||||
```
|
|
||||||
|
|
||||||
Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`.
|
|
||||||
|
|
||||||
**To convert to multi-group layout:**
|
|
||||||
1. Run: `kubebuilder edit --multigroup=true`
|
|
||||||
2. Move APIs: `mkdir -p api/<group> && mv api/<version> api/<group>/`
|
|
||||||
3. Move controllers: `mkdir -p internal/controller/<group> && mv internal/controller/*.go internal/controller/<group>/`
|
|
||||||
4. Move webhooks (if present): `mkdir -p internal/webhook/<group> && mv internal/webhook/<version> internal/webhook/<group>/`
|
|
||||||
5. Update import paths in all files
|
|
||||||
6. Fix `path` in `PROJECT` file for each resource
|
|
||||||
7. Update test suite CRD paths (add one more `..` to relative paths)
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
### Never Edit These (Auto-Generated)
|
|
||||||
- `config/crd/bases/*.yaml` - from `make manifests`
|
|
||||||
- `config/rbac/role.yaml` - from `make manifests`
|
|
||||||
- `config/webhook/manifests.yaml` - from `make manifests`
|
|
||||||
- `**/zz_generated.*.go` - from `make generate`
|
|
||||||
- `PROJECT` - from `kubebuilder [OPTIONS]`
|
|
||||||
|
|
||||||
### Never Remove Scaffold Markers
|
|
||||||
Do NOT delete `// +kubebuilder:scaffold:*` comments. CLI injects code at these markers.
|
|
||||||
|
|
||||||
### Keep Project Structure
|
|
||||||
Do not move files around. The CLI expects files in specific locations.
|
|
||||||
|
|
||||||
### Always Use CLI Commands
|
|
||||||
Always use `kubebuilder create api` and `kubebuilder create webhook` to scaffold. Do NOT create files manually.
|
|
||||||
|
|
||||||
### E2E Tests Require an Isolated Kind Cluster
|
|
||||||
The e2e tests are designed to validate the solution in an isolated environment (similar to GitHub Actions CI).
|
|
||||||
Ensure you run them against a dedicated [Kind](https://kind.sigs.k8s.io/) cluster (not your “real” dev/prod cluster).
|
|
||||||
|
|
||||||
## After Making Changes
|
|
||||||
|
|
||||||
**After editing `*_types.go` or markers:**
|
|
||||||
```
|
|
||||||
make manifests # Regenerate CRDs/RBAC from markers
|
|
||||||
make generate # Regenerate DeepCopy methods
|
|
||||||
```
|
|
||||||
|
|
||||||
**After editing `*.go` files:**
|
|
||||||
```
|
|
||||||
make lint-fix # Auto-fix code style
|
|
||||||
make test # Run unit tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI Commands Cheat Sheet
|
|
||||||
|
|
||||||
### Create API (your own types)
|
|
||||||
```bash
|
|
||||||
kubebuilder create api --group <group> --version <version> --kind <Kind>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy Image Plugin (scaffold to deploy/manage ANY container image)
|
|
||||||
|
|
||||||
Generate a controller that deploys and manages a container image (nginx, redis, memcached, your app, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: deploying memcached
|
|
||||||
kubebuilder create api --group example.com --version v1alpha1 --kind Memcached \
|
|
||||||
--image=memcached:alpine \
|
|
||||||
--plugins=deploy-image.go.kubebuilder.io/v1-alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
Scaffolds good-practice code: reconciliation logic, status conditions, finalizers, RBAC. Use as a reference implementation.
|
|
||||||
|
|
||||||
|
|
||||||
### Create Webhooks
|
|
||||||
```bash
|
|
||||||
# Validation + defaulting
|
|
||||||
kubebuilder create webhook --group <group> --version <version> --kind <Kind> \
|
|
||||||
--defaulting --programmatic-validation
|
|
||||||
|
|
||||||
# Conversion webhook (for multi-version APIs)
|
|
||||||
kubebuilder create webhook --group <group> --version v1 --kind <Kind> \
|
|
||||||
--conversion --spoke v2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Controller for Core Kubernetes Types
|
|
||||||
```bash
|
|
||||||
# Watch Pods
|
|
||||||
kubebuilder create api --group core --version v1 --kind Pod \
|
|
||||||
--controller=true --resource=false
|
|
||||||
|
|
||||||
# Watch Deployments
|
|
||||||
kubebuilder create api --group apps --version v1 --kind Deployment \
|
|
||||||
--controller=true --resource=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Controller for External Types (e.g., from other operators)
|
|
||||||
|
|
||||||
Watch resources from external APIs (cert-manager, Argo CD, Istio, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: watching cert-manager Certificate resources
|
|
||||||
kubebuilder create api \
|
|
||||||
--group cert-manager --version v1 --kind Certificate \
|
|
||||||
--controller=true --resource=false \
|
|
||||||
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
|
|
||||||
--external-api-domain=io \
|
|
||||||
--external-api-module=github.com/cert-manager/cert-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Use `--external-api-module=<module>@<version>` only if you need a specific version. Otherwise, omit `@<version>` to use what's in go.mod.
|
|
||||||
|
|
||||||
### Webhook for External Types
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: validating external resources
|
|
||||||
kubebuilder create webhook \
|
|
||||||
--group cert-manager --version v1 --kind Issuer \
|
|
||||||
--defaulting \
|
|
||||||
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
|
|
||||||
--external-api-domain=io \
|
|
||||||
--external-api-module=github.com/cert-manager/cert-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing & Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test # Run unit tests (uses envtest: real K8s API + etcd)
|
|
||||||
make run # Run locally (uses current kubeconfig context)
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests use **Ginkgo + Gomega** (BDD style). Check `suite_test.go` for setup.
|
|
||||||
|
|
||||||
## Deployment Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Regenerate manifests
|
|
||||||
make manifests generate
|
|
||||||
|
|
||||||
# 2. Build & deploy
|
|
||||||
export IMG=<registry>/<project>:tag
|
|
||||||
make docker-build docker-push IMG=$IMG # Or: kind load docker-image $IMG --name <cluster>
|
|
||||||
make deploy IMG=$IMG
|
|
||||||
|
|
||||||
# 3. Test
|
|
||||||
kubectl apply -k config/samples/
|
|
||||||
|
|
||||||
# 4. Debug
|
|
||||||
kubectl logs -n <project>-system deployment/<project>-controller-manager -c manager -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Design
|
|
||||||
|
|
||||||
**Key markers for** `api/<version>/*_types.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// +kubebuilder:object:root=true
|
|
||||||
// +kubebuilder:subresource:status
|
|
||||||
// +kubebuilder:resource:scope=Namespaced
|
|
||||||
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status"
|
|
||||||
|
|
||||||
// On fields:
|
|
||||||
// +kubebuilder:validation:Required
|
|
||||||
// +kubebuilder:validation:Minimum=1
|
|
||||||
// +kubebuilder:validation:MaxLength=100
|
|
||||||
// +kubebuilder:validation:Pattern="^[a-z]+$"
|
|
||||||
// +kubebuilder:default="value"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Use** `metav1.Condition` for status (not custom string fields)
|
|
||||||
- **Use predefined types**: `metav1.Time` instead of `string` for dates
|
|
||||||
- **Follow K8s API conventions**: Standard field names (`spec`, `status`, `metadata`)
|
|
||||||
|
|
||||||
### Controller Design
|
|
||||||
|
|
||||||
**RBAC markers in** `internal/controller/*_controller.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
|
|
||||||
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/status,verbs=get;update;patch
|
|
||||||
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/finalizers,verbs=update
|
|
||||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
|
||||||
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation rules:**
|
|
||||||
- **Idempotent reconciliation**: Safe to run multiple times
|
|
||||||
- **Re-fetch before updates**: `r.Get(ctx, req.NamespacedName, obj)` before `r.Update` to avoid conflicts
|
|
||||||
- **Structured logging**: `log := log.FromContext(ctx); log.Info("msg", "key", val)`
|
|
||||||
- **Owner references**: Enable automatic garbage collection (`SetControllerReference`)
|
|
||||||
- **Watch secondary resources**: Use `.Owns()` or `.Watches()`, not just `RequeueAfter`
|
|
||||||
- **Finalizers**: Clean up external resources (buckets, VMs, DNS entries)
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
**Follow Kubernetes logging message style guidelines:**
|
|
||||||
|
|
||||||
- Start from a capital letter
|
|
||||||
- Do not end the message with a period
|
|
||||||
- Active voice: subject present (`"Deployment could not create Pod"`) or omitted (`"Could not create Pod"`)
|
|
||||||
- Past tense: `"Could not delete Pod"` not `"Cannot delete Pod"`
|
|
||||||
- Specify object type: `"Deleted Pod"` not `"Deleted"`
|
|
||||||
- Balanced key-value pairs
|
|
||||||
|
|
||||||
```go
|
|
||||||
log.Info("Starting reconciliation")
|
|
||||||
log.Info("Created Deployment", "name", deploy.Name)
|
|
||||||
log.Error(err, "Failed to create Pod", "name", name)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reference:** https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines
|
|
||||||
|
|
||||||
### Webhooks
|
|
||||||
- **Create all types together**: `--defaulting --programmatic-validation --conversion`
|
|
||||||
- **When`--force`is used**: Backup custom logic first, then restore after scaffolding
|
|
||||||
- **For multi-version APIs**: Use hub-and-spoke pattern (`--conversion --spoke v2`)
|
|
||||||
- Hub version: Usually oldest stable version (v1)
|
|
||||||
- Spoke versions: Newer versions that convert to/from hub (v2, v3)
|
|
||||||
- Example: `--group crew --version v1 --kind Captain --conversion --spoke v2` (v1 is hub, v2 is spoke)
|
|
||||||
|
|
||||||
### Learning from Examples
|
|
||||||
|
|
||||||
The **deploy-image plugin** scaffolds a complete controller following good practices. Use it as a reference implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubebuilder create api --group example --version v1alpha1 --kind MyApp \
|
|
||||||
--image=<your-image> --plugins=deploy-image.go.kubebuilder.io/v1-alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated code includes: status conditions (`metav1.Condition`), finalizers, owner references, events, idempotent reconciliation.
|
|
||||||
|
|
||||||
## Distribution Options
|
|
||||||
|
|
||||||
### Option 1: YAML Bundle (Kustomize)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate dist/install.yaml from Kustomize manifests
|
|
||||||
make build-installer IMG=<registry>/<project>:tag
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
- The `dist/install.yaml` is generated from Kustomize manifests (CRDs, RBAC, Deployment)
|
|
||||||
- Commit this file to your repository for easy distribution
|
|
||||||
- Users only need `kubectl` to install (no additional tools required)
|
|
||||||
|
|
||||||
**Example:** Users install with a single command:
|
|
||||||
```bash
|
|
||||||
kubectl apply -f https://raw.githubusercontent.com/<org>/<repo>/<tag>/dist/install.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Helm Chart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubebuilder edit --plugins=helm/v2-alpha # Generates dist/chart/ (default)
|
|
||||||
kubebuilder edit --plugins=helm/v2-alpha --output-dir=charts # Generates charts/chart/
|
|
||||||
```
|
|
||||||
|
|
||||||
**For development:**
|
|
||||||
```bash
|
|
||||||
make helm-deploy IMG=<registry>/<project>:<tag> # Deploy manager via Helm
|
|
||||||
make helm-deploy IMG=$IMG HELM_EXTRA_ARGS="--set ..." # Deploy with custom values
|
|
||||||
make helm-status # Show release status
|
|
||||||
make helm-uninstall # Remove release
|
|
||||||
make helm-history # View release history
|
|
||||||
make helm-rollback # Rollback to previous version
|
|
||||||
```
|
|
||||||
|
|
||||||
**For end users/production:**
|
|
||||||
```bash
|
|
||||||
helm install my-release ./<output-dir>/chart/ --namespace <ns> --create-namespace
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** If you add webhooks or modify manifests after initial chart generation:
|
|
||||||
1. Backup any customizations in `<output-dir>/chart/values.yaml` and `<output-dir>/chart/manager/manager.yaml`
|
|
||||||
2. Re-run: `kubebuilder edit --plugins=helm/v2-alpha --force` (use same `--output-dir` if customized)
|
|
||||||
3. Manually restore your custom values from the backup
|
|
||||||
|
|
||||||
### Publish Container Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export IMG=<registry>/<project>:<version>
|
|
||||||
make docker-build docker-push IMG=$IMG
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
### Essential Reading
|
|
||||||
- **Kubebuilder Book**: https://book.kubebuilder.io (comprehensive guide)
|
|
||||||
- **controller-runtime FAQ**: https://github.com/kubernetes-sigs/controller-runtime/blob/main/FAQ.md (common patterns and questions)
|
|
||||||
- **Good Practices**: https://book.kubebuilder.io/reference/good-practices.html (why reconciliation is idempotent, status conditions, etc.)
|
|
||||||
- **Logging Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines (message style, verbosity levels)
|
|
||||||
|
|
||||||
### API Design & Implementation
|
|
||||||
- **API Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md
|
|
||||||
- **Operator Pattern**: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
|
|
||||||
- **Markers Reference**: https://book.kubebuilder.io/reference/markers.html
|
|
||||||
|
|
||||||
### Tools & Libraries
|
|
||||||
- **controller-runtime**: https://github.com/kubernetes-sigs/controller-runtime
|
|
||||||
- **controller-tools**: https://github.com/kubernetes-sigs/controller-tools
|
|
||||||
- **Kubebuilder Repo**: https://github.com/kubernetes-sigs/kubebuilder
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# terdut-operator
|
# terdut-operator
|
||||||
|
|
||||||
Kubebuilder/controller-runtime operator for terdut-server. See `DESIGN.md` for the
|
Kubebuilder/controller-runtime operator for terdut-server. See `DESIGN.md` for the
|
||||||
settled design (CRD catalog, reconciliation semantics, bootstrap/auth, RBAC) and
|
design (start with its revision section: credentials and the CRD catalog) and
|
||||||
`ROADMAP.md` for the staged build plan this repo is following. `README.md` stays the
|
`ROADMAP.md` for status and deferred work. `README.md` stays the
|
||||||
short pitch.
|
short pitch.
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
@@ -16,15 +16,12 @@ required beyond Go itself and network access to `proxy.golang.org`/`storage.goog
|
|||||||
`security` runs `make security-go`/`security-secrets` (govulncheck/gitleaks), same as
|
`security` runs `make security-go`/`security-secrets` (govulncheck/gitleaks), same as
|
||||||
terdut-server's own `security` job.
|
terdut-server's own `security` job.
|
||||||
|
|
||||||
The kubebuilder-scaffolded `make test-e2e` (a disposable, generic smoke test) is separate
|
There is no CI end-to-end job: the golden-path `kind` pass is manual, via
|
||||||
from the real golden-path `kind` e2e pass ROADMAP.md's Stage 5 describes (create every CRD
|
`examples/demo/run-demo.sh` (see ROADMAP.md).
|
||||||
kind, verify against terdut-server's own API, delete, verify gone) — the latter is a
|
|
||||||
manual pass run and recorded in ROADMAP.md, not a CI job, matching Stage 1-4's own
|
|
||||||
precedent of validating against a real cluster outside CI.
|
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
Wired as of Stage 5 (ROADMAP.md): `.release.conf`, `make release-vars`/`helm-lint`/
|
Wired: `.release.conf`, `make release-vars`/`helm-lint`/
|
||||||
`push`/`helm-package`/`helm-push`/`release`, and `.gitea/workflows/release.yaml`
|
`push`/`helm-package`/`helm-push`/`release`, and `.gitea/workflows/release.yaml`
|
||||||
(`test` → `image`/`chart` → `scan-image`) all follow terdut-server's established shape —
|
(`test` → `image`/`chart` → `scan-image`) all follow terdut-server's established shape —
|
||||||
see that repo's Makefile/`.release.conf` for the shared reasoning, not restated here.
|
see that repo's Makefile/`.release.conf` for the shared reasoning, not restated here.
|
||||||
|
|||||||
@@ -1,146 +1,83 @@
|
|||||||
# terdut-operator design
|
# terdut-operator design
|
||||||
|
|
||||||
This is the design reference for implementing terdut-operator. It exists so
|
The design of terdut-operator as built. terdut-server is the system of record; this operator
|
||||||
implementation can start from settled decisions instead of re-litigating them
|
makes one install of it — the server, its teams, their escalation ladders, dead man's switches
|
||||||
mid-PR. It is deliberately more detailed than the README; the README stays as
|
and alert-source integrations — describable as Kubernetes objects and manageable through gitops.
|
||||||
the short pitch and now points here instead of carrying open questions.
|
|
||||||
|
|
||||||
Written against terdut-server as of the Postgres-only, per-team-resources
|
|
||||||
version (teams, escalation policies, dead man's switches and integrations are
|
|
||||||
all rows scoped to a team, managed over `internal/api/*` — not env/config-file
|
|
||||||
driven; see that repo's `charts/terdut-server` for the current deploy story
|
|
||||||
this operator supersedes).
|
|
||||||
|
|
||||||
## 1. Goals & non-goals
|
## 1. Goals & non-goals
|
||||||
|
|
||||||
**Goal:** let a terdut-server install — the server itself, its teams, their
|
**Goal:** a terdut-server install fully described as Kubernetes objects, following
|
||||||
escalation policies, dead man's switches and alert-source integrations — be
|
|
||||||
fully described as Kubernetes objects and managed through gitops, following
|
|
||||||
controller-runtime / Kubebuilder conventions.
|
controller-runtime / Kubebuilder conventions.
|
||||||
|
|
||||||
**The operator creates and owns every `TerdutServer` it manages. It never
|
**The operator creates and owns every `TerdutServer` it manages. It never adopts a pre-existing,
|
||||||
adopts a pre-existing, independently-deployed terdut-server** — whether
|
independently-deployed terdut-server**, whether deployed by hand or by `charts/terdut-server`.
|
||||||
deployed by hand or by `charts/terdut-server`. There is no migration path
|
There is no migration path from a chart-based install (§10): starting with the operator means
|
||||||
from an existing chart-based install, and none is planned (§10): starting
|
applying a fresh `TerdutServer`.
|
||||||
with the operator means applying a fresh `TerdutServer` CR, not converting
|
|
||||||
one. This is the root a few things downstream hang off of — notably §6's
|
|
||||||
bootstrap flow, which only has to handle the operator bootstrapping a server
|
|
||||||
it just created, never a server something else already bootstrapped first.
|
|
||||||
|
|
||||||
**Non-goals (v1):**
|
**Non-goals (v1):**
|
||||||
- Not a general-purpose Postgres operator. It *consumes* a database that
|
- Not a Postgres operator. It consumes a database that the Zalando `postgres-operator` or
|
||||||
either the Zalando `postgres-operator` or something else already provides.
|
something else provides (§8).
|
||||||
- Not managing Alertmanager itself, or the routing rules that decide which
|
- Not managing Alertmanager or its routing, only the terdut-server side (creating the
|
||||||
alerts reach which integration webhook — only the terdut-server side
|
integration and handing back its URL/key).
|
||||||
(creating the integration and handing back its URL/key).
|
- Not OLM packaging: plain Kubebuilder manifests and a Helm chart, like terdut-server.
|
||||||
- Not OLM packaging. Plain Kubebuilder manifests + Helm chart for
|
- Cross-namespace references are limited to one edge: `TerdutTeam.spec.serverRef` may name a
|
||||||
install, matching how terdut-server itself ships.
|
`TerdutServer` in another namespace, gated by that server's `spec.allowedTeams` (§4.6). A
|
||||||
- Cross-namespace references are limited to exactly one edge:
|
`TerdutAlertSource` lives beside its `TerdutTeam`.
|
||||||
`TerdutTeam.spec.serverRef` may name a `TerdutServer` in a different
|
- No admission webhooks. CEL validation covers what it can; anything needing a live look at
|
||||||
namespace, gated by that `TerdutServer`'s own `spec.allowedTeams` consent
|
another object is a status condition, not an admission rejection.
|
||||||
field (§4.1, §4.2) — this is the multi-tenant shape the operator exists
|
- **Never manages external exposure for a `TerdutServer`** (Ingress, HTTPRoute, VirtualService) —
|
||||||
for (one platform team owns a `TerdutServer`; other teams self-service a
|
a permanent non-goal. The operator creates a plain `ClusterIP` Service; see `examples/networking`.
|
||||||
`TerdutTeam` against it without needing write access to the server's
|
|
||||||
namespace). Every other reference (`teamRef` on the escalation
|
|
||||||
rule/dead-man-switch/alert-source CRDs) stays same-namespace-as-its-`TerdutTeam`
|
|
||||||
only, in v1 — those manage a specific team's own resources and are
|
|
||||||
expected to live alongside it.
|
|
||||||
- No validating/mutating admission webhooks in v1. CEL validation rules on
|
|
||||||
the CRDs (OpenAPI `x-kubernetes-validations`) cover what they can; anything
|
|
||||||
that needs a live look at another object (e.g. "does this teamRef exist")
|
|
||||||
is a status condition, not an admission rejection — keeps v1 to a
|
|
||||||
controller-only deployment with no cert-manager/webhook dependency.
|
|
||||||
- **Never manages external exposure/ingress for `TerdutServer`, in any
|
|
||||||
form — a permanent non-goal, not a staged one.** The operator creates a
|
|
||||||
plain `ClusterIP` Service (§4.1) and stops there: no `Ingress`, no
|
|
||||||
Gateway API `HTTPRoute`, no Istio `VirtualService`, nothing. Some
|
|
||||||
installs won't expose `TerdutServer` outside the cluster at all; others
|
|
||||||
will use whichever of those mechanisms already fits their cluster. That
|
|
||||||
choice belongs to whoever deploys it, not to this operator — see
|
|
||||||
`examples/networking` for worked (but not operator-managed) examples.
|
|
||||||
|
|
||||||
## 2. The README's open questions, resolved
|
## 2. Credentials and CRD shape (the 2026-10 redesign)
|
||||||
|
|
||||||
> How do team-crd connect with server-crd?
|
What the design is, and why — each point replaced something heavier:
|
||||||
|
|
||||||
Explicit `spec.serverRef: {name, namespace}` on `TerdutTeam` — same reasoning
|
- **No bootstrap handshake.** The operator generates a key into a Secret named
|
||||||
as before (explicit, greppable, trivially validated), but **`namespace` is
|
`<TerdutServer>-operator-key`, in the TerdutServer's own namespace and owned by
|
||||||
deliberately part of the reference**: one team can run and own a
|
it (a pod can only mount Secrets of its own namespace, and an owner reference
|
||||||
`TerdutServer`, and other teams — in their own namespaces, without any write
|
replaces the old finalizer and `credentials.deletionPolicy`). The Deployment
|
||||||
access to the server-owning team's namespace — self-service a `TerdutTeam`
|
hands it to terdut-server as `TERDUT_OPERATOR_KEY`; the server creates or
|
||||||
against it. `namespace` defaults to the `TerdutTeam`'s own namespace when
|
re-keys its instance-scoped service account `terdut-operator` from it at every
|
||||||
omitted, so the common single-tenant case (`serverRef: {name: terdut}`) is
|
start. A replaced Secret rolls the pods (key-hash annotation). `/api/bootstrap`
|
||||||
unchanged.
|
stays free for the first human administrator. Gone with it: the checkpoint
|
||||||
|
Secret, `BootstrapStateLost`, `DatabaseReady`/`Bootstrapped` conditions.
|
||||||
|
- **One credential per server.** An instance-scoped account acts as owner of every
|
||||||
|
team's configuration (not a member, so it reads no incidents). The per-team
|
||||||
|
service accounts, Secrets and `status.credentialsSecretRef`/`serverEndpoint` on
|
||||||
|
`TerdutTeam` are gone.
|
||||||
|
- **Team identity is `external_id`.** The operator creates a team with
|
||||||
|
`external_id: <namespace>/<name>` of its CR; the server returns the existing team
|
||||||
|
for a known id (200) instead of creating one, so crash recovery, a lost status
|
||||||
|
and a deleted team all heal by repeating the same call, and a display name that
|
||||||
|
belongs to another team is a 409 (`TeamNameTaken`) instead of an adoption.
|
||||||
|
- **Three CRDs.** `TerdutServer`, `TerdutTeam` and `TerdutAlertSource`.
|
||||||
|
`TerdutEscalationRule` and `TerdutDeadmanSwitch` are `spec.escalation` and
|
||||||
|
`spec.deadmanSwitches[]` (matched by name, unique per team server-side; ones not
|
||||||
|
listed are removed) on the team: they were one-to-one children with the team's
|
||||||
|
lifecycle, and folding them removes `teamRef`, the two-rules-clobber footgun and
|
||||||
|
three controllers. `TerdutAlertSource` stays separate because it owns a webhook
|
||||||
|
Secret in its own namespace.
|
||||||
|
- **Usernames are resolved by the server** (`PUT .../escalation` accepts
|
||||||
|
`username`), so an unknown user is a `UnknownUser` condition, not a list-and-match.
|
||||||
|
- **Invites are removed.** Membership is not modelled; people get in through the
|
||||||
|
server's own signup/OIDC.
|
||||||
|
- **Env mirrors the chart's where it matters:** OIDC claim names and `trustEmail`
|
||||||
|
are spec fields; `TERDUT_DEADMAN_*` no longer exist on the server.
|
||||||
|
|
||||||
This cross-namespace edge needs the target namespace's explicit consent —
|
## 3. CRD catalog
|
||||||
otherwise any namespace in the cluster could point a `TerdutTeam` at
|
|
||||||
someone else's `TerdutServer` and have the operator provision a team on
|
|
||||||
its behalf, which is a namespace-boundary violation, not a gitops
|
|
||||||
convenience. (This consent gate is about which `TerdutTeam`s the operator
|
|
||||||
will act on, not about credential exposure — no terdut-server credential
|
|
||||||
is ever placed in a `TerdutTeam`'s own namespace regardless of this
|
|
||||||
setting; see §6.) Kubernetes has two established patterns for this kind
|
|
||||||
of consent, and Gateway API itself uses both, for two different
|
|
||||||
relationships:
|
|
||||||
|
|
||||||
- **`ReferenceGrant`** (used for a Route reaching into an arbitrary
|
Group `terdut.ryuvia.com`, version `v1alpha1`; module `git.ryuvia.com/niklas/terdut-operator`.
|
||||||
Service/Secret): a separate object, living in the *target* namespace,
|
|
||||||
enumerating exact `{fromNamespace, fromKind} → {toKind, toName}` pairs.
|
|
||||||
No wildcard, no selector — every permitted namespace is spelled out.
|
|
||||||
- **An inline field on the parent** (used for a `ListenerSet` attaching to
|
|
||||||
a shared `Gateway`, GA since Gateway API v1.5): the parent carries
|
|
||||||
`spec.allowedListeners.namespaces: {from: None|Same|All|Selector,
|
|
||||||
selector}` directly, no separate CRD.
|
|
||||||
|
|
||||||
`TerdutTeam` attaching to a shared `TerdutServer` is structurally the
|
| Kind | Purpose |
|
||||||
second case, not the first — a bounded set of expected children attaching
|
|---|---|
|
||||||
to a parent they were deliberately made shareable, not an arbitrary
|
| `TerdutServer` | One terdut-server install: Deployment, Service, database wiring, operator key, cross-namespace team consent. |
|
||||||
backend reference — so this design follows the `ListenerSet` precedent:
|
| `TerdutTeam` | One team on a server, possibly in another namespace: name, OIDC groups, escalation ladder, dead man's switches. |
|
||||||
`TerdutServer.spec.allowedTeams` (§4.1), no extra CRD. §4.2 covers how
|
| `TerdutAlertSource` | One alert-ingest integration on a team; owns a webhook Secret. |
|
||||||
`TerdutTeam` resolves against it. No other reference in this design
|
|
||||||
(`teamRef` on the child CRDs) crosses a namespace boundary, so this is the
|
|
||||||
only place cross-namespace consent is needed at all (§1, §5, §6, §9).
|
|
||||||
|
|
||||||
> How does escalationrules, switches and alertsources connect to a team?
|
|
||||||
|
|
||||||
Explicit `spec.teamRef: {name}` on each of `TerdutEscalationRule`,
|
|
||||||
`TerdutDeadmanSwitch`, `TerdutAlertSource` — same reasoning, and it mirrors
|
|
||||||
terdut-server's own data model, where every one of these rows carries a
|
|
||||||
`team_id` foreign key already. A matcher/selector on `TerdutTeam` would be
|
|
||||||
inventing a second source of truth for an association the server already
|
|
||||||
models as a plain reference.
|
|
||||||
|
|
||||||
> Support for both postgres-operator (Zalando) and bring-your-own, how do we
|
|
||||||
> design that to be user friendly?
|
|
||||||
|
|
||||||
`TerdutServer.spec.database` is a oneOf, mirroring the chart's existing
|
|
||||||
`database.dsn` / `database.passwordSecret` contract (see §8):
|
|
||||||
- `dsn` + `passwordSecretRef` — bring-your-own, exactly today's chart inputs.
|
|
||||||
- `postgresClusterRef` — points at a Zalando `postgresql.acid.zalan.do` CR;
|
|
||||||
the operator derives the DSN and resolves the generated credentials Secret
|
|
||||||
itself (see §8). CloudNativePG support is a natural follow-up using the
|
|
||||||
same shape and is called out as deferred (§13), not designed in detail now.
|
|
||||||
|
|
||||||
## 3. API group, versions, CRD catalog
|
|
||||||
|
|
||||||
- Group: `terdut.ryuvia.com`, version: `v1alpha1` (matches the `ryuvia.com`
|
|
||||||
domain terdut-server already uses; bump to `v1beta1`/`v1` per the normal
|
|
||||||
Kubernetes API graduation criteria once the shapes below have proven
|
|
||||||
stable against a real install).
|
|
||||||
- Module: `git.ryuvia.com/niklas/terdut-operator`, scaffolded with
|
|
||||||
Kubebuilder (controller-runtime), matching terdut-server's Go toolchain
|
|
||||||
and house style.
|
|
||||||
|
|
||||||
| Kind | Scope | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `TerdutServer` | Namespaced | One terdut-server install: Deployment, Service, database wiring, bootstrap, operator credentials, cross-namespace team consent. |
|
|
||||||
| `TerdutTeam` | Namespaced | One team on a `TerdutServer`, possibly in another namespace: name, OIDC group mapping. |
|
|
||||||
| `TerdutEscalationRule` | Namespaced | A team's escalation policy (levels, targets, repeat). |
|
|
||||||
| `TerdutDeadmanSwitch` | Namespaced | One dead man's switch on a team. |
|
|
||||||
| `TerdutAlertSource` | Namespaced | One alert-ingest integration on a team (currently: Alertmanager webhook). |
|
|
||||||
|
|
||||||
## 4. Per-CRD spec
|
## 4. Per-CRD spec
|
||||||
|
|
||||||
|
(The escalation and dead man's switch specs that used to be §4.3 and §4.4 are part of §4.2; the section numbers are kept because code comments cite them.)
|
||||||
|
|
||||||
### 4.1 `TerdutServer`
|
### 4.1 `TerdutServer`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -165,10 +102,6 @@ spec:
|
|||||||
sweeper:
|
sweeper:
|
||||||
staleAfter: 6h
|
staleAfter: 6h
|
||||||
archiveAfter: 168h
|
archiveAfter: 168h
|
||||||
deadman:
|
|
||||||
matchers: "alertname=Watchdog"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
notify:
|
notify:
|
||||||
ntfyURL: "http://ntfy.ntfy.svc.cluster.local"
|
ntfyURL: "http://ntfy.ntfy.svc.cluster.local"
|
||||||
fallbackTopic: ""
|
fallbackTopic: ""
|
||||||
@@ -183,6 +116,8 @@ spec:
|
|||||||
scopes: "openid profile email"
|
scopes: "openid profile email"
|
||||||
allowedGroups: []
|
allowedGroups: []
|
||||||
adminGroup: ""
|
adminGroup: ""
|
||||||
|
usernameClaim: preferred_username # also emailClaim, groupsClaim
|
||||||
|
trustEmail: false
|
||||||
sessionMaxAge: 12h
|
sessionMaxAge: 12h
|
||||||
passwordLogin: true
|
passwordLogin: true
|
||||||
# Consent for TerdutTeams in OTHER namespaces to set serverRef at this
|
# Consent for TerdutTeams in OTHER namespaces to set serverRef at this
|
||||||
@@ -210,10 +145,9 @@ spec:
|
|||||||
disruptionBudget:
|
disruptionBudget:
|
||||||
minAvailable: 1 # mutually exclusive with maxUnavailable
|
minAvailable: 1 # mutually exclusive with maxUnavailable
|
||||||
status:
|
status:
|
||||||
conditions: [...] # Ready, DatabaseReady, Bootstrapped
|
conditions: [...] # Ready
|
||||||
observedGeneration: 3
|
observedGeneration: 3
|
||||||
serviceName: terdut
|
credentialsSecretRef: {name: terdut-operator-key, key: token} # see §6; pure output, in this TerdutServer's own namespace
|
||||||
credentialsSecretRef: {name: terdut.platform-oncall-instance-credentials, key: token} # see §6; pure output -- generated by the controller's own self-registration flow, always in the OPERATOR's namespace (always, implicitly -- not stored here, since it's never anything else), under a fixed key ("token").
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Field-for-field this is the chart's `values.yaml` reshaped as a spec — not
|
Field-for-field this is the chart's `values.yaml` reshaped as a spec — not
|
||||||
@@ -258,89 +192,37 @@ same way as `spec.database`'s own `dsn`/`postgresClusterRef` rule.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
spec:
|
spec:
|
||||||
serverRef:
|
serverRef: {name: terdut, namespace: platform-oncall} # namespace optional; cross-namespace needs allowedTeams
|
||||||
name: terdut
|
displayName: Platform
|
||||||
namespace: platform-oncall # optional; defaults to this TerdutTeam's own namespace.
|
oidc: {memberGroup: terdut-platform-members, ownerGroup: terdut-platform-owners}
|
||||||
# Cross-namespace requires that TerdutServer's spec.allowedTeams
|
escalation:
|
||||||
# (§4.1) to admit this namespace — otherwise Ready: False, reason: RefNotPermitted.
|
repeatCount: 2
|
||||||
displayName: "Platform" # -> POST /api/teams {"name": ...}; server assigns the ID
|
fallbackTopic: platform-oncall
|
||||||
oidc:
|
levels:
|
||||||
memberGroup: "terdut-platform-members"
|
- timeout: 5m
|
||||||
ownerGroup: "terdut-platform-owners"
|
targets: [{kind: oncall}, {kind: user, username: alice}]
|
||||||
|
- timeout: 15m
|
||||||
|
targets: [{kind: oncall}]
|
||||||
|
deadmanSwitches:
|
||||||
|
- {name: watchdog, matcher: "alertname=Watchdog,cluster=prod", timeout: 15m, severity: critical}
|
||||||
status:
|
status:
|
||||||
conditions: [...]
|
conditions: [...] # Ready; reasons: ServerRefNotFound, RefNotPermitted, WaitingForServer,
|
||||||
teamID: 42 # the server-side ID; needed by every child object's controller
|
# TeamNameTaken, UnknownUser, InvalidSpec, Adopted
|
||||||
credentialsSecretRef: {name: platform-oncall.platform-team-credentials, key: token} # see §6; this team's own scoped key, always in the OPERATOR's own namespace (not stored here — same reasoning as TerdutServer's, §4.1)
|
teamID: 42
|
||||||
serverEndpoint: "http://terdut.platform-oncall.svc:8080" # resolved once, here, from spec.serverRef -- see §5: this is what actually makes "a child never needs to chain up to TerdutServer" true, not just a stated intent. Set alongside teamID/credentialsSecretRef, same reconcile.
|
|
||||||
observedGeneration: 1
|
observedGeneration: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
Team *membership* (which users belong, `team_members`) is explicitly **not**
|
The team is created under the identity `<namespace>/<name>` of its CR (`external_id`), so a retry,
|
||||||
modeled as a CRD field in v1: terdut-server already manages membership via
|
a lost status or a team deleted behind the operator's back all heal by repeating the same call; a
|
||||||
OIDC group sync at login for SSO installs, and manual membership for
|
`displayName` that belongs to a different team is `TeamNameTaken`, never an adoption. Every
|
||||||
password-login installs is a people-management action, not infrastructure —
|
reconcile applies, in order: create-or-find, rename, OIDC groups, the escalation ladder (one PUT
|
||||||
forcing it through gitops would mean a human's team change goes through a PR
|
of the whole ladder; absent `spec.escalation` clears it), and the switches. Switches are matched
|
||||||
review. Flagged in §13 as revisitable if a real gitops-membership need shows up.
|
by name (unique per team on the server): missing ones are created, changed ones updated in
|
||||||
|
place, and any not listed are deleted, since in operator mode nobody else can add one. Durations
|
||||||
|
are validated by a CRD pattern and re-checked (`InvalidSpec`). A username the server does not
|
||||||
|
know is `UnknownUser` until that person exists.
|
||||||
|
|
||||||
### 4.3 `TerdutEscalationRule`
|
Team *membership* is not modelled: the server manages it through OIDC group sync and its own UI.
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
teamRef: {name: platform-team}
|
|
||||||
repeatCount: 2
|
|
||||||
fallbackTopic: "platform-oncall"
|
|
||||||
levels:
|
|
||||||
- timeout: 5m
|
|
||||||
targets:
|
|
||||||
- kind: oncall # "oncall" or "user"
|
|
||||||
- kind: user
|
|
||||||
username: alice # resolved to a user ID by the controller at apply time
|
|
||||||
- timeout: 15m
|
|
||||||
targets:
|
|
||||||
- kind: oncall
|
|
||||||
status:
|
|
||||||
conditions: [...]
|
|
||||||
observedGeneration: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
One `TerdutEscalationRule` per team — the server itself models a policy as
|
|
||||||
one row (`escalation_policies`) with an owned list of levels, so a
|
|
||||||
one-CRD-to-one-policy mapping (not one-CRD-per-level) matches the server's
|
|
||||||
own aggregate and lets the whole thing be reconciled with the single
|
|
||||||
`PUT /api/teams/{teamID}/escalation` the API actually exposes (see §5). Not
|
|
||||||
enforced at admission (no webhooks in v1, §1) — two `TerdutEscalationRule`s
|
|
||||||
naming the same team would both `PUT` it and clobber each other every
|
|
||||||
reconcile; a footgun worth this one sentence, not a technical guard.
|
|
||||||
|
|
||||||
A `username` target is resolved to the `user_id` `PUT /api/teams/{teamID}/escalation`
|
|
||||||
actually requires (confirmed against source: `escalationTargetJSON.UserID
|
|
||||||
*int64`, no username field at all) via `GET /api/users` — confirmed open to
|
|
||||||
any authenticated caller, not gated by team membership or admin
|
|
||||||
(`internal/api/router.go`'s own comment: "readable by anyone signed in"), so
|
|
||||||
the team-scoped credential this controller already holds is enough; no
|
|
||||||
extra RBAC-equivalent server-side needed. Re-resolved every reconcile rather
|
|
||||||
than cached, in case a username is renamed. An unresolvable username is
|
|
||||||
`Ready: False, reason: UnknownUser`, naming which one.
|
|
||||||
|
|
||||||
### 4.4 `TerdutDeadmanSwitch`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
teamRef: {name: platform-team}
|
|
||||||
name: "prod-watchdog"
|
|
||||||
matcher: "alertname=Watchdog,cluster=prod"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
status:
|
|
||||||
conditions: [...]
|
|
||||||
switchID: 7
|
|
||||||
```
|
|
||||||
|
|
||||||
No unique-name constraint server-side (§5's table row, confirmed against
|
|
||||||
source) — the controller's own idempotent-create step is a `GET`-list and
|
|
||||||
name match, not a conflict to recover from. `name` is optional, same as the
|
|
||||||
API: left empty, terdut-server derives it from `matcher`'s own canonical
|
|
||||||
form, and that's what the lookup matches against too.
|
|
||||||
|
|
||||||
### 4.5 `TerdutAlertSource`
|
### 4.5 `TerdutAlertSource`
|
||||||
|
|
||||||
@@ -357,7 +239,7 @@ status:
|
|||||||
|
|
||||||
The integration key is shown by the API exactly once, at creation
|
The integration key is shown by the API exactly once, at creation
|
||||||
(`Integration.Key`/`URL` in terdut-server's own model) — never re-readable,
|
(`Integration.Key`/`URL` in terdut-server's own model) — never re-readable,
|
||||||
same shape as the bootstrap admin key. The controller writes it straight into
|
a one-shot value. The controller writes it straight into
|
||||||
a generated, owner-referenced Secret on the create it caused and never logs
|
a generated, owner-referenced Secret on the create it caused and never logs
|
||||||
or stores it anywhere else; the CR's `status` carries only the Secret
|
or stores it anywhere else; the CR's `status` carries only the Secret
|
||||||
reference, matching how e.g. cert-manager's `Certificate` exposes
|
reference, matching how e.g. cert-manager's `Certificate` exposes
|
||||||
@@ -377,8 +259,7 @@ of both patterns).
|
|||||||
`serverRef` into this `TerdutServer`. Same-namespace `TerdutTeam`s are
|
`serverRef` into this `TerdutServer`. Same-namespace `TerdutTeam`s are
|
||||||
always allowed regardless of this field.
|
always allowed regardless of this field.
|
||||||
- `from: Same` — equivalent to `None` in effect (same-namespace is already
|
- `from: Same` — equivalent to `None` in effect (same-namespace is already
|
||||||
unrestricted) but kept for parity with the upstream enum and to make the
|
unrestricted); kept for parity with the upstream enum.
|
||||||
policy self-documenting in a diff.
|
|
||||||
- `from: All` — any namespace in the cluster may reference in. Appropriate
|
- `from: All` — any namespace in the cluster may reference in. Appropriate
|
||||||
for a genuinely shared, cluster-wide `TerdutServer`; the audit trail is
|
for a genuinely shared, cluster-wide `TerdutServer`; the audit trail is
|
||||||
"check `allowedTeams` plus who has RBAC to create a `TerdutTeam`
|
"check `allowedTeams` plus who has RBAC to create a `TerdutTeam`
|
||||||
@@ -401,257 +282,64 @@ of both patterns).
|
|||||||
it used to authorize finds itself no longer permitted, flips
|
it used to authorize finds itself no longer permitted, flips
|
||||||
`Ready: False, reason: RefNotPermitted`, and — deliberately — does
|
`Ready: False, reason: RefNotPermitted`, and — deliberately — does
|
||||||
**not** delete the team server-side on revocation alone; it stops
|
**not** delete the team server-side on revocation alone; it stops
|
||||||
reconciling further changes until access is restored or the `TerdutTeam`
|
reconciling further changes until access is restored. Deleting the
|
||||||
CR itself is deleted (whose finalizer still needs the credentials
|
`TerdutTeam` CR still deletes the team: consent gates what the operator
|
||||||
Secret described in §6 to clean up, so blocking *new* changes rather
|
starts acting on, not whether it may clean up after itself.
|
||||||
than forcing an immediate, possibly credential-less deletion is the
|
|
||||||
safer failure mode).
|
|
||||||
|
|
||||||
## 5. Reconciliation semantics
|
## 5. Reconciliation semantics
|
||||||
|
|
||||||
terdut-server's REST surface (`internal/api/router.go`) does not give every
|
| Resource | Server verbs | Strategy |
|
||||||
resource a full update verb, so reconciliation strategy is per-resource:
|
|
||||||
|
|
||||||
| Resource | Verbs available | Strategy |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Team | POST create, PUT rename, DELETE, PUT oidc-groups, GET by name | Real update-in-place: diff spec vs. last-applied, PUT the changed pieces. `GET /api/teams?name=` (terdut-server's `TEAM-LOOKUP.md`, landed 2026-10-01) is what makes the idempotent-create general rule below actually true for Team — confirmed by checking: until that endpoint existed, an instance-scoped service account had no way to recover a team's id after a 409, unlike every other resource in this table, where the adopt-on-conflict rule had a real lookup to call. |
|
| Team | POST (idempotent on `external_id`), PUT rename, PUT oidc-groups, DELETE | Repeat the idempotent POST, then PUT the rest, every reconcile. Delete runs in a finalizer; the server refuses (409) while the team has open incidents, which is retried. |
|
||||||
| Escalation policy | GET/PUT whole-policy | Update-in-place: PUT the full desired policy every reconcile that finds drift; cheap because whole-policy is small and already loaded whole server-side. |
|
| Escalation ladder | PUT whole ladder | PUT the full desired ladder every reconcile; the server resolves usernames. |
|
||||||
| Dead man's switch | POST create, **PUT update-in-place** (added in terdut-server v0.33.0), DELETE, GET-list | Real update-in-place, same shape as Team/Escalation: PUT the whole switch every reconcile once its id is known. No unique-name constraint server-side (confirmed against source — `handleCreateTeamDeadman` has no conflict handling at all, unlike Team/service-account creation), so idempotent-create here can't rely on a 409 to adopt from: before POSTing, `GET /api/teams/{teamID}/deadman/switches` and match by `name` first: found → adopt its id; not found → POST. `handleUpdateTeamDeadman`'s own doc comment (terdut-server) confirms the motivation directly: "Added alongside create/delete so an automated caller (terdut-operator) can reconcile a spec change without deleting and recreating the switch, which would otherwise ... needlessly rotate its id for no reason a reconciler's diff should ever manufacture." An earlier draft of this row, written before that endpoint existed, described delete-and-recreate; corrected here, confirmed against source rather than left stale. |
|
| Dead man's switch | list, POST, PUT, DELETE; names unique per team | Diff by name against the list. |
|
||||||
| Integration (alert source) | POST create, PATCH rename, DELETE | Rename via PATCH; any other spec change (kind) is delete-and-recreate, which **rotates the webhook key** — called out loudly in the CRD's field docs and in a `Warning` event, since it breaks whatever sends to the old URL/key until the new Secret is picked up. (See the general rule below for what happens if the Secret is lost with *no* spec change.) |
|
| Integration (alert source) | POST, PATCH rename, DELETE | Rename via PATCH; a kind change is delete-and-recreate, which **rotates the webhook key** (Warning event). An integration deleted on the server is recreated with a new key (Warning event). |
|
||||||
|
|
||||||
General rules for every controller:
|
General rules:
|
||||||
- **Idempotent create**: before POSTing, check `status.<serverSideID>` is
|
- **Periodic resync** (5 minutes) besides watch-triggered reconciles, to catch someone changing
|
||||||
unset; if the server already has a same-named object from a previous
|
state directly against the server: the CR wins.
|
||||||
partial reconcile (e.g. after a crash between POST and status-write), treat
|
- **Finalizers** on `TerdutTeam` and `TerdutAlertSource` call the server's DELETE first. A 404 is
|
||||||
a 409/name-conflict as "adopt" — GET-by-name and populate status, rather
|
success; any other failure blocks removal and surfaces as an event rather than orphaning a row.
|
||||||
than erroring forever. terdut-server's list endpoints in each of these
|
`TerdutServer` needs none: its Secret is owned and its database is never touched.
|
||||||
areas return objects by name, so this is a straightforward correlation.
|
- **A webhook Secret that is lost fails closed.** The key is never re-readable from the server, so
|
||||||
- **Periodic resync** in addition to watch-triggered reconciles (Kubebuilder
|
the controller does not mint a replacement for a live URL with no spec change to explain it:
|
||||||
default `RequeueAfter` on success, e.g. every 5–10 minutes) to catch drift
|
`Ready: False, reason: WebhookSecretLost`; delete and recreate the `TerdutAlertSource`.
|
||||||
from **someone changing state directly against the server's API/UI**,
|
- **Children resolve through the team.** A `TerdutAlertSource` finds its `TerdutTeam` (same
|
||||||
since gitops correctness means the CR wins, not "first write wins".
|
namespace), requires it Ready, then reads the server named by the team's `serverRef` and that
|
||||||
- **Finalizers** on every CRD that has a server-side counterpart, so deletion
|
server's operator key. A `TerdutTeam` re-reconciles when its `TerdutServer` changes.
|
||||||
calls the corresponding DELETE before the Kubernetes object disappears.
|
- **Cross-namespace consent is re-checked every reconcile** (§4.6), so revocation takes effect on
|
||||||
Failure to delete server-side (e.g. server unreachable) blocks finalizer
|
the team's next pass.
|
||||||
removal and surfaces as a `Degraded` condition + event, rather than
|
|
||||||
silently orphaning a row.
|
|
||||||
- **Generated Secrets holding unrecoverable server-issued material are
|
|
||||||
watched, and their loss is fail-closed, not self-healed.** Currently this
|
|
||||||
is just `TerdutAlertSource`'s webhook Secret (§4.5): the controller adds
|
|
||||||
it to its `Owns()` watches, not just the CR. If it disappears while
|
|
||||||
`status.integrationID` is still set, the controller does **not** attempt
|
|
||||||
to recreate it — the key is genuinely gone (§4.5: never stored anywhere
|
|
||||||
but that one Secret), so silently minting a replacement would rotate a
|
|
||||||
live production webhook URL with no corresponding spec change to explain
|
|
||||||
why. Instead it flips `Ready: False, reason: WebhookSecretLost` and fires
|
|
||||||
a `Warning` event telling the operator to delete and recreate the
|
|
||||||
`TerdutAlertSource`. No new mechanism is needed for recovery: deleting the
|
|
||||||
CR runs the existing finalizer (DELETE the still-live integration
|
|
||||||
server-side, above), and recreating it runs the existing idempotent-create
|
|
||||||
path (this same section) — a fresh POST, a new key, a new Secret. This is
|
|
||||||
deliberately the same recovery motion as the kind-change rotation above,
|
|
||||||
just human-triggered instead of spec-triggered.
|
|
||||||
- **Owner chain for status resolution, not API calls**: `TerdutTeam`'s
|
|
||||||
controller does not call any other controller; every child CRD's
|
|
||||||
controller independently resolves its own `teamRef` → `TerdutTeam.status`
|
|
||||||
for the `teamID`, `credentialsSecretRef`, and `serverEndpoint` it needs to
|
|
||||||
call the API (§6) — it never needs to chain further up to `TerdutServer`
|
|
||||||
at all, not just as a stated intent but literally: `serverEndpoint` is
|
|
||||||
resolved once, by `TerdutTeam`'s own controller, and stored in its status
|
|
||||||
specifically so no child ever needs its own `TerdutServer` RBAC (`get`/
|
|
||||||
`list`/`watch` on `terdutservers`) to find out where to send a request —
|
|
||||||
the team's own scoped credential plus that one status field is everything
|
|
||||||
a child resource's controller requires. If the referenced `TerdutTeam`
|
|
||||||
isn't `Ready` yet (which includes not having a `credentialsSecretRef` set),
|
|
||||||
the child requeues with backoff and reports `Ready: False, reason:
|
|
||||||
WaitingForTeam` — no cross-controller RPC.
|
|
||||||
- **Cross-namespace `serverRef` is re-checked every reconcile, not just at
|
|
||||||
creation**: `TerdutTeam`'s controller reads the target `TerdutServer`'s
|
|
||||||
`spec.allowedTeams` (and, under `Selector`, a `Get` on its own `Namespace`
|
|
||||||
object for labels) on every pass before touching a cross-namespace
|
|
||||||
`TerdutServer` — revocation (§4.6) takes effect on the team's very next
|
|
||||||
reconcile, not just when the CR is first applied.
|
|
||||||
|
|
||||||
## 6. Bootstrap & authentication to terdut-server's API
|
## 6. Authentication to terdut-server's API
|
||||||
|
|
||||||
terdut-server's scoped service-account credential type
|
The operator talks to the server over HTTP with one bearer key per `TerdutServer`:
|
||||||
(`terdut-server`'s `SERVICE-ACCOUNTS.md`) has shipped — confirmed against
|
|
||||||
source: `internal/api/service_accounts.go`, migration
|
|
||||||
`014_service_accounts.sql`, and `internal/api/router.go` wiring it in under
|
|
||||||
`AuthMiddleware`. This section is no longer blocked on it; the "v1-blocking"
|
|
||||||
framing here was accurate when this section was first written and is stale
|
|
||||||
now.
|
|
||||||
|
|
||||||
**`GET /api/service-accounts?name=` is not an unauthenticated lookup —
|
1. The `TerdutServer` controller creates the Secret `<name>-operator-key` (data key `token`,
|
||||||
confirmed against `internal/api/middleware.go`'s `AuthMiddleware`, which
|
`tdsa_` + 48 hex characters) in the server's own namespace, owned by the `TerdutServer`. It
|
||||||
hard-rejects any request carrying neither a Bearer token nor a session
|
is created once and never overwritten while it exists: the running server was seeded with it.
|
||||||
cookie with `401` before any handler ever runs.** This would matter a great
|
2. The Deployment hands it to the pods as `TERDUT_OPERATOR_KEY` through a `secretKeyRef` (the key
|
||||||
deal if the operator's `/api/bootstrap` call could ever lose a race to
|
never appears in the pod spec), plus an annotation holding a hash of it so a replaced Secret
|
||||||
something else bootstrapping the same server first — a credential-less
|
rolls the pods.
|
||||||
loser would have no authenticated way to recover. It doesn't matter here,
|
3. At every start the server creates or re-keys its instance-scoped service account
|
||||||
by construction (§1): **the operator only ever calls `/api/bootstrap`
|
`terdut-operator` from that value. An instance-scoped account acts as owner of every team's
|
||||||
against a `TerdutServer` it just created**, so there is nothing else in a
|
configuration but is not a member of any team, so it reads no incidents and is never an
|
||||||
position to race it. An earlier draft of this section added a
|
administrator.
|
||||||
`spec.credentialsSecretRef` bring-your-own input specifically to work around
|
4. `status.credentialsSecretRef` points at the Secret; `TerdutTeam` and `TerdutAlertSource`
|
||||||
that race, for a world where the operator might adopt a server something
|
controllers read it from the server's namespace.
|
||||||
else had already bootstrapped. That world doesn't exist (§1), so the field
|
|
||||||
was removed rather than kept as unused flexibility — self-registration
|
|
||||||
(point 1, below) is simply the only path, not one of two.
|
|
||||||
|
|
||||||
**Every credential the operator holds — the one instance-scoped key per
|
There is no bootstrap handshake: `/api/bootstrap` stays free for the first human administrator.
|
||||||
`TerdutServer`, and one team-scoped key per `TerdutTeam` — lives in a Secret
|
Deleting the `TerdutServer` deletes the key with it; a recreated one gets a new key and the server
|
||||||
in the *operator's own* namespace, never in the namespace of the CR it
|
re-seeds on its next start. Rotating by hand means deleting the Secret: the next reconcile makes
|
||||||
authenticates for.** Reconciliation happens entirely inside the operator's
|
a new one and rolls the pods.
|
||||||
controller loop, which is a single Deployment/ServiceAccount already
|
|
||||||
watching every namespace it's granted (§9); nothing about calling
|
|
||||||
terdut-server's API on a CR's behalf requires the credential to be
|
|
||||||
physically located near that CR, and no CR owner (human or otherwise) ever
|
|
||||||
needs to see, hold, or have RBAC to read a terdut-server credential. This is
|
|
||||||
a straight simplification of an earlier draft of this section, which mirrored
|
|
||||||
a shared credential into each consenting namespace instead — that version
|
|
||||||
conflated "the CR's owner never needs to see this" (true, and preserved
|
|
||||||
here) with "so the credential must live in the CR's namespace" (a
|
|
||||||
non-sequitur once you don't need to grant *anyone else* namespace-local
|
|
||||||
read access). Dropping that assumption also removes an entire class of
|
|
||||||
complexity: no on-demand mirroring, no garbage-collecting an orphaned copy
|
|
||||||
when `allowedTeams` narrows, no "OwnerReferences can't cross namespaces so
|
|
||||||
track it in status instead" workaround — none of that machinery is needed
|
|
||||||
when nothing ever crosses into a tenant namespace in the first place.
|
|
||||||
|
|
||||||
1. **First reconcile, confirmed against source**
|
|
||||||
(`internal/api/users.go`'s `handleBootstrap`): `/api/bootstrap` is
|
|
||||||
single-shot *per install*, gated on `SELECT COUNT(*) FROM users` — once
|
|
||||||
non-zero, every call `403`s regardless of identity. There are two crash
|
|
||||||
windows between "get an admin key" and "have a lasting, usable
|
|
||||||
credential" — getting from `/api/bootstrap`'s key to a minted
|
|
||||||
service-account key, and getting from that key to a persisted Secret —
|
|
||||||
and both get a checkpoint rather than being left as a theoretical gap,
|
|
||||||
the same rigor §5's general idempotent-create rule already applies
|
|
||||||
elsewhere:
|
|
||||||
- `status.credentialsSecretRef` already set: done, nothing to do.
|
|
||||||
- Otherwise, check for an intermediate
|
|
||||||
`<namespace>.<name>-bootstrap-admin` Secret in
|
|
||||||
the operator's own namespace first. If it exists, its key is a still-
|
|
||||||
valid admin credential from an earlier, interrupted attempt — skip
|
|
||||||
`/api/bootstrap` entirely and reuse it. If not, call `/api/bootstrap`
|
|
||||||
once the Deployment this `TerdutServer` created has a ready replica;
|
|
||||||
on `201`, immediately checkpoint its response's raw admin key
|
|
||||||
(`{"user": ..., "api_key": {"key": "<raw>", ...}}`) into that Secret
|
|
||||||
before doing anything else with it. A `403` with neither
|
|
||||||
`status.credentialsSecretRef` nor this checkpoint Secret present is
|
|
||||||
the one genuinely pathological case left (the checkpoint deleted out
|
|
||||||
from under a reconcile already past this point) — handled the same
|
|
||||||
way the design already handles unrecoverable server-issued material
|
|
||||||
elsewhere (§5's webhook-Secret-loss rule): fail closed,
|
|
||||||
`Ready: False, reason: BootstrapStateLost`, with the same recovery as
|
|
||||||
that case, delete and recreate the `TerdutServer` (its finalizer tears
|
|
||||||
down the Deployment/database-backing and server-side rows; a fresh
|
|
||||||
create starts clean) — not a workaround peculiar to this one path.
|
|
||||||
- With an admin key in hand (fresh or checkpointed): `POST
|
|
||||||
/api/service-accounts {name: "terdut-operator", scope: "instance"}`.
|
|
||||||
A `409` here means a prior attempt got this far before being
|
|
||||||
interrupted — adopt rather than error, per §5's general rule:
|
|
||||||
`GET /api/service-accounts?name=terdut-operator` (authenticated with
|
|
||||||
the checkpointed admin key, not an unauthenticated lookup) to find its
|
|
||||||
id, then `POST /api/service-accounts/{id}/keys` to mint a fresh key —
|
|
||||||
an orphaned first key some interrupted attempt minted and never used
|
|
||||||
is inert, not a cleanup obligation.
|
|
||||||
2. The resulting instance-scoped key — not the checkpointed admin key, which
|
|
||||||
is deleted once this step succeeds — is written to a generated Secret in
|
|
||||||
the **operator's own namespace** (e.g.
|
|
||||||
`<serverRef.namespace>.<serverRef.name>-instance-credentials`,
|
|
||||||
under a fixed data key, `token`), referenced back from
|
|
||||||
`TerdutServer.status.credentialsSecretRef: {name, key}` (§4.1). No
|
|
||||||
`OwnerReference` (those can't cross namespaces, and this Secret doesn't
|
|
||||||
share a namespace with the `TerdutServer` that caused it); the `TerdutServer`'s
|
|
||||||
finalizer deletes this Secret directly as part of its own teardown,
|
|
||||||
the same way it already has to clean up the server-side resources it
|
|
||||||
created (§5's general finalizer rule extends naturally to this Secret).
|
|
||||||
3. When a `TerdutTeam` first becomes `Ready` (its `serverRef` resolved,
|
|
||||||
`allowedTeams` satisfied if cross-namespace), its controller uses the
|
|
||||||
`TerdutServer`'s instance-scoped credential (read from the operator's own
|
|
||||||
namespace, resolved via the owner chain in §5) to mint a **team-scoped**
|
|
||||||
service account for itself: `POST /api/service-accounts` with
|
|
||||||
`scope: team, teamID: <status.teamID>`. The resulting key is written to
|
|
||||||
its own generated Secret, again in the **operator's own namespace**
|
|
||||||
(e.g. `<teamNamespace>.<teamName>-team-credentials`), referenced from
|
|
||||||
`TerdutTeam.status.credentialsSecretRef` (§4.2). Same finalizer pattern as
|
|
||||||
point 2: the `TerdutTeam`'s finalizer deletes this Secret as part of its
|
|
||||||
own teardown.
|
|
||||||
4. Every child controller (`TerdutEscalationRule`, `TerdutDeadmanSwitch`,
|
|
||||||
`TerdutAlertSource`) reads its team's `credentialsSecretRef` — resolved
|
|
||||||
through its `teamRef` → `TerdutTeam.status` (§5) — and never touches the
|
|
||||||
instance-scoped credential at all. Since child CRDs stay same-namespace-
|
|
||||||
as-their-`TerdutTeam` in v1 (§1), and the credential itself lives in the
|
|
||||||
operator's namespace regardless of where the `TerdutTeam` or its children
|
|
||||||
are, this works identically whether the `TerdutTeam` is same-namespace or
|
|
||||||
cross-namespace relative to its `TerdutServer` — there is no separate
|
|
||||||
cross-namespace case to handle here at all, unlike the mirroring design
|
|
||||||
this replaced.
|
|
||||||
5. **Blast radius**: a team-scoped key can only touch its own `team_id`'s
|
|
||||||
escalation policy, dead-man switches, integrations, schedule and OIDC
|
|
||||||
group bindings server-side (enforced by terdut-server itself, per
|
|
||||||
`SERVICE-ACCOUNTS.md`) — compromising one such Secret (e.g. a bug that
|
|
||||||
leaks operator-namespace Secrets, or an overly broad RBAC grant on that
|
|
||||||
one namespace) exposes exactly one team, never the whole server. This is
|
|
||||||
the real fix for what an earlier draft of this section called out as its
|
|
||||||
weak point (every mirrored copy being server-admin-equivalent); it falls
|
|
||||||
out of team-scoped credentials existing at all, independent of where
|
|
||||||
they're stored — the operator-private storage described above closes the
|
|
||||||
RBAC-footprint half of the problem, team scoping closes the credential-
|
|
||||||
privilege half.
|
|
||||||
6. **Rotation**: `POST /api/service-accounts/{id}/keys` mints a new key on
|
|
||||||
the existing account without recreating it; the old key is revoked via
|
|
||||||
`DELETE /api/service-accounts/{id}/keys/{keyID}`; the operator's local
|
|
||||||
Secret is updated in place. No DB-level workaround, no re-triggering a
|
|
||||||
single-shot endpoint that can't fire twice (which is what made rotation
|
|
||||||
unworkable under the old `/api/bootstrap`-only design).
|
|
||||||
7. **Operator mode** (`TERDUT_OPERATOR_MODE`, terdut-server's own
|
|
||||||
deploy-time flag, off by default) is the complementary half of this
|
|
||||||
trust model: it makes terdut-server itself refuse a *human* write (a
|
|
||||||
session or a user's own API key) on a route, while a service account's
|
|
||||||
— this operator's — still goes through. Confirmed against source
|
|
||||||
(`internal/api/middleware.go`'s `OperatorModeBlock`,
|
|
||||||
`internal/api/router.go`'s `opMode` wrapper): the blocked set is exactly
|
|
||||||
team create/rename/delete, a team's OIDC-group binding, its escalation
|
|
||||||
policy, its dead man's switches, and its integrations — precisely the
|
|
||||||
resources `TerdutTeam`, `TerdutEscalationRule`, `TerdutDeadmanSwitch` and
|
|
||||||
`TerdutAlertSource` manage, and nothing more. **Deliberately not
|
|
||||||
blocked, confirmed against the same router**: team membership and
|
|
||||||
invites (the router's own comment: "membership is deliberately never
|
|
||||||
gitops-managed"), and the on-call schedule/rota
|
|
||||||
(`/api/teams/{teamID}/schedule`, `/api/schedule/current`) — neither
|
|
||||||
route carries the `opMode` wrapper at all. A human can still add or
|
|
||||||
remove a team member, or assign who's on call, on a server running in
|
|
||||||
operator mode; only the CRD-shaped resources above are locked to
|
|
||||||
GitOps. This isn't a gap to close — it's the same boundary §4.2 already
|
|
||||||
draws for membership, confirmed to hold on the server side too, not
|
|
||||||
just stated as an intent here.
|
|
||||||
|
|
||||||
## 7. Ownership, status, garbage collection
|
## 7. Ownership, status, garbage collection
|
||||||
|
|
||||||
- Every generated object that lives in the *same* namespace as the CR that
|
- Everything the controllers generate in a CR's namespace carries an `OwnerReference` (Deployment,
|
||||||
caused it (Deployment, Service, webhook Secret, and `TerdutServer`'s own
|
Service, PodDisruptionBudget, the operator key Secret, the webhook Secret), so GC cleans up
|
||||||
PodDisruptionBudget) carries a standard `metav1.OwnerReference` — GC
|
and no finalizer is needed. The PDB exists only while `spec.pod.disruptionBudget` is set; the
|
||||||
handles these, no finalizer needed. PodDisruptionBudget is the one
|
controller deletes it itself when the field is cleared.
|
||||||
member of that list that's conditionally created/deleted rather than
|
- Every kind has a `Ready` condition and `status.observedGeneration`.
|
||||||
always present: it exists only while `spec.pod.disruptionBudget` is set,
|
- No cluster-scoped aggregate object: `kubectl get terdutservers -A` is the overview.
|
||||||
and the controller deletes it itself the moment that field is cleared
|
|
||||||
(it doesn't wait on GC for that case, only for the `TerdutServer` being
|
|
||||||
deleted outright). The two credential Secrets from §6 are the one
|
|
||||||
exception to OwnerReference-based cleanup generally: they live in the
|
|
||||||
operator's own namespace regardless of where their owning CR lives, so
|
|
||||||
`OwnerReference` doesn't apply (cross-namespace) and cleanup instead runs
|
|
||||||
through that CR's finalizer directly, alongside the server-side DELETE
|
|
||||||
it already has to issue (§5).
|
|
||||||
- Status conditions follow the standard `metav1.Condition` shape with at
|
|
||||||
least `Ready` on every kind, plus kind-specific ones (`TerdutServer`:
|
|
||||||
`DatabaseReady`, `Bootstrapped`; children: `Synced`).
|
|
||||||
- `status.observedGeneration` on every kind, bumped only after a successful
|
|
||||||
reconcile against that generation's spec — the standard way a client
|
|
||||||
(or `kubectl wait`) tells "applied" from "seen".
|
|
||||||
- No cluster-scoped aggregation object (e.g. no cluster-wide "all servers"
|
|
||||||
status) in v1 — `kubectl get terdutservers -A` is the aggregate view.
|
|
||||||
|
|
||||||
## 8. Postgres integration
|
## 8. Postgres integration
|
||||||
|
|
||||||
@@ -672,85 +360,43 @@ documented and tested operationally:
|
|||||||
(`<user>.<cluster>.credentials.postgresql.acid.zalan.do`) the same way
|
(`<user>.<cluster>.credentials.postgresql.acid.zalan.do`) the same way
|
||||||
the chart's comment already documents, and wires it in as
|
the chart's comment already documents, and wires it in as
|
||||||
`PGPASSWORD` the same way.
|
`PGPASSWORD` the same way.
|
||||||
- Watches that Secret (not just the `postgresql` CR) so a credential
|
- Does not watch that Secret: a rotated credential is noticed at the next
|
||||||
rotation triggers a requeue — the chart today requires a manual pod
|
5-minute resync, and restarting pods on rotation is a §13 follow-up.
|
||||||
restart for this; the operator can at least detect and report it via a
|
|
||||||
condition even if restarting on rotation is left as a §13 follow-up
|
|
||||||
rather than done automatically (a rolling restart on credential change
|
|
||||||
is a behavior change worth its own design pass, not folded in here).
|
|
||||||
- Requires read RBAC on `postgresql.acid.zalan.do` (optional CRD — the
|
- Requires read RBAC on `postgresql.acid.zalan.do` (optional CRD — the
|
||||||
operator's ClusterRole/Role should not hard-fail if the CRD isn't
|
operator's ClusterRole/Role should not hard-fail if the CRD isn't
|
||||||
installed and a given `TerdutServer` uses BYO DSN instead).
|
installed and a given `TerdutServer` uses BYO DSN instead).
|
||||||
|
|
||||||
## 9. RBAC
|
## 9. RBAC
|
||||||
|
|
||||||
- The operator's own ServiceAccount needs, per namespace it's granted:
|
- The operator needs `get/list/watch/create/update/patch/delete` on `Deployments`, `Services`,
|
||||||
`get/list/watch/create/update/patch/delete` on `Deployments`, `Services`
|
`PodDisruptionBudgets` and `Secrets` it owns, `get/list/watch` on `postgresql.acid.zalan.do`
|
||||||
and `PodDisruptionBudgets` it owns, and `get/list/watch` on
|
(optional; degraded gracefully when the CRD is absent), and `get/list/watch` on `Namespaces`
|
||||||
`postgresql.acid.zalan.do` (optional, degrade gracefully if absent per
|
(labels only, for `allowedTeams: {from: Selector}`).
|
||||||
§8), plus cluster-wide `get/list` on `Namespace` (labels only, for
|
- Secrets live in the `TerdutServer`'s namespace (the operator key) and in each
|
||||||
`allowedTeams: {from: Selector}` evaluation — §4.1, §4.6).
|
`TerdutAlertSource`'s namespace (the webhook Secret), so the operator needs Secret access in
|
||||||
- **Two different `Secret` scopes, not one — corrected from an earlier draft
|
every tenant namespace. Kubernetes RBAC has no owner-scoped grant finer than the namespace.
|
||||||
of this section.** That earlier draft said `Secret` access was "scoped to
|
- **As built (2026-10): broader than the above.** The shipped default is a
|
||||||
the operator's own namespace only... nowhere else," reasoning that with
|
`ClusterRole` with full verbs on `Secrets` in every namespace (the chart's
|
||||||
every credential held privately in the operator's own namespace (§6)
|
`rbac.namespaced: true` gives a `Role` in the release namespace only, which
|
||||||
there was no legitimate reason to touch a `Secret` anywhere else. That was
|
cannot serve tenant namespaces), and the manager's cache is not restricted
|
||||||
wrong once §4.5 existed:
|
to watched namespaces. A per-namespace `Role` split (a Role and RoleBinding per watched namespace, with the cache
|
||||||
- The §6 credential Secrets (one instance-scoped key per `TerdutServer`,
|
restricted to them) is the intended end state, not implemented, and the decision (2026-10) is to stay
|
||||||
one team-scoped key per `TerdutTeam`) do live in, and are only ever
|
cluster-wide for now: treat this operator as able to read every Secret in the cluster.
|
||||||
touched from, the operator's own namespace —
|
- terdut-server's own RBAC is unaffected: the operator uses only its HTTP API (§6), never the
|
||||||
`get/list/watch/create/update/patch/delete` there, nowhere else. The
|
Kubernetes API for app-level state.
|
||||||
rest of the original reasoning stands for *these* Secrets specifically:
|
|
||||||
no human or team's own RBAC is ever granted access to a terdut-server
|
|
||||||
credential by this design, and the operator itself never needs
|
|
||||||
cross-namespace access to reach them.
|
|
||||||
- The §4.5 webhook Secret is different: it's owned by and lives beside
|
|
||||||
its `TerdutAlertSource`, in that CR's own tenant namespace, not the
|
|
||||||
operator's. The per-namespace `Role` already granted for
|
|
||||||
`Deployments`/`Services` in each watched namespace (below) must carry
|
|
||||||
the same `Secret` verbs there too, or the controller cannot create,
|
|
||||||
watch, or even detect the loss of (§5) that Secret at all.
|
|
||||||
- This necessarily widens the operator's footprint in each watched
|
|
||||||
tenant namespace to "any `Secret` in that namespace," not just the ones
|
|
||||||
it created — Kubernetes RBAC has no owner-scoped grant finer than the
|
|
||||||
namespace itself, and the design already accepts this same granularity
|
|
||||||
for Deployments/Services there. Flagged as an accepted trade-off, not a
|
|
||||||
silent gap (§13).
|
|
||||||
- No cluster-scoped resources are created by this operator (namespaced CRDs
|
|
||||||
only, per §1) — a `Role` + `RoleBinding` per watched (tenant) namespace is
|
|
||||||
sufficient for Deployments/Services/the webhook `Secret`/the optional
|
|
||||||
Zalando CRD, plus a separate `Role` + `RoleBinding` in the operator's own
|
|
||||||
namespace for the §6 credential Secrets; a `ClusterRole` is only needed
|
|
||||||
for watching CRDs across all namespaces (the normal Kubebuilder
|
|
||||||
multi-tenant-operator default) — none of the `Secret` access above needs
|
|
||||||
to be cluster-scoped.
|
|
||||||
- terdut-server's own RBAC is unaffected — the operator talks to it purely
|
|
||||||
over HTTP with service-account API keys (§6), never via the Kubernetes
|
|
||||||
API for app-level state.
|
|
||||||
|
|
||||||
## 10. Relationship to `charts/terdut-server`
|
## 10. Relationship to `charts/terdut-server`
|
||||||
|
|
||||||
The chart's Deployment/Service/bootstrap-job templates are redundant once
|
The server chart's Deployment, Service and bootstrap Job are redundant once `TerdutServer`
|
||||||
`TerdutServer` exists — running both would mean two controllers (Helm and
|
exists: running both would have two controllers reconciling the same Deployment. The operator's
|
||||||
this operator) reconciling the same Deployment, which is exactly the
|
chart (`charts/terdut-operator`) installs the operator, CRDs and RBAC, and optionally one
|
||||||
conflict Kubernetes operators exist to avoid. The chart is repurposed into
|
`TerdutServer` from `values.yaml` (`terdutServer.enabled`). There is no migration from a
|
||||||
an **installer chart**: it installs the operator + CRDs (and optionally one
|
chart-based install and none is planned (§1); whatever the server chart deployed stays a separate
|
||||||
`TerdutServer` CR from `values.yaml`, for users who want "helm install and
|
install until someone deletes it.
|
||||||
get a server" without hand-writing a CR) rather than templating the
|
|
||||||
Deployment directly.
|
|
||||||
|
|
||||||
**No migration path from an existing chart-based install, and none is
|
The operator's Deployment builder mirrors the chart's env block for the knobs both expose (the
|
||||||
planned (§1).** An earlier draft of this section spent most of its length on
|
chart's `deployment.yaml` and `buildEnv` in `internal/controller/terdutserver_deployment.go`);
|
||||||
one — `helm template` the current release's `values.yaml` into an equivalent
|
a new server setting is added in `config.go`, the chart, and the operator, in that order.
|
||||||
`TerdutServer` CR, uninstall or shrink the old release, and a whole
|
|
||||||
sub-question about who gets to call `/api/bootstrap` first, the chart's Job
|
|
||||||
or the operator — all of which presupposed the operator might end up
|
|
||||||
managing a server the chart had already deployed and bootstrapped. §1 rules
|
|
||||||
that out: the operator only ever manages servers it created itself, so
|
|
||||||
there's nothing to migrate and no bootstrap race to settle (§6 covers why
|
|
||||||
that race doesn't exist either). Adopting the operator means applying a
|
|
||||||
fresh `TerdutServer` CR; whatever the chart deployed before stays exactly
|
|
||||||
what it was, a separate install, until someone deletes it.
|
|
||||||
|
|
||||||
## 11. Testing strategy
|
## 11. Testing strategy
|
||||||
|
|
||||||
@@ -761,84 +407,27 @@ what it was, a separate install, until someone deletes it.
|
|||||||
request/response shapes (already well-documented in
|
request/response shapes (already well-documented in
|
||||||
`internal/api/*_test.go` on the server side) — no real Postgres or real
|
`internal/api/*_test.go` on the server side) — no real Postgres or real
|
||||||
terdut-server binary needed for controller unit tests.
|
terdut-server binary needed for controller unit tests.
|
||||||
- A smaller number of true end-to-end tests (`kind` cluster + real
|
- The golden path (`kind` cluster + real terdut-server image + real Postgres:
|
||||||
terdut-server image + real Postgres) covering the golden path per CRD:
|
create `TerdutServer` → `TerdutTeam` → `TerdutAlertSource`, verify through
|
||||||
create `TerdutServer` → `TerdutTeam` → one of each child kind → verify via
|
terdut-server's own API, delete, verify it is gone) is a manual pass via
|
||||||
terdut-server's own API that the objects exist with the right shape →
|
`examples/demo/run-demo.sh`, not a CI job.
|
||||||
delete the CR → verify the server-side object is gone.
|
|
||||||
|
|
||||||
## 12. Observability
|
## 12. Observability
|
||||||
|
|
||||||
- Standard controller-runtime metrics (reconcile duration/error counts) are
|
- Standard controller-runtime metrics (reconcile duration/error counts) are
|
||||||
enough for v1 — no custom metrics.
|
enough for v1 — no custom metrics.
|
||||||
- Every externally-visible action (bootstrap, key rotation-needed, delete-
|
- Every externally-visible action (a team created or renamed, an integration recreated, a delete
|
||||||
and-recreate on the no-PUT resources, adopt-on-conflict) emits a
|
that failed) emits a
|
||||||
Kubernetes `Event` on the CR, since that's what shows up in `kubectl
|
Kubernetes `Event` on the CR, since that's what shows up in `kubectl
|
||||||
describe` and gitops tooling (Argo CD/Flux) surfaces without extra wiring.
|
describe` and gitops tooling (Argo CD/Flux) surfaces without extra wiring.
|
||||||
|
|
||||||
## 13. Deferred / explicitly out of scope for this design
|
## 13. Deferred / out of scope
|
||||||
|
|
||||||
- **A real scoped service-account/token type in terdut-server — not merely
|
- CloudNativePG support, alongside the Zalando `postgresClusterRef` (same `spec.database` shape).
|
||||||
deferred, this is v1-blocking for §6 as written** (verified: without it,
|
- Gitops-managed team membership (§4.2).
|
||||||
§6's bootstrap flow has no working credential-rotation path and no clean
|
- Per-namespace RBAC with a restricted cache (§9).
|
||||||
answer to the chart-vs-operator bootstrap race; see §6 points 1, 5, 6 and
|
|
||||||
§10). Sequence this server-side change *before* implementing the
|
|
||||||
`TerdutServer` controller's bootstrap logic, not after.
|
|
||||||
- **A version-discovery endpoint on terdut-server** (e.g. `GET /api/version`).
|
|
||||||
Neither this operator nor terdut-tui has one today — both independently
|
|
||||||
detect capability by probing specific routes (terdut-tui via `GET
|
|
||||||
/api/teams` 404-checking; this operator would otherwise need to invent
|
|
||||||
its own equivalent probe). An unattended reconciler is more exposed to a
|
|
||||||
silent breaking API change than an interactive TUI a human is watching;
|
|
||||||
raising this alongside the service-account request rather than inventing
|
|
||||||
another route-probe here.
|
|
||||||
- CloudNativePG support — same `spec.database` shape as Zalando should
|
|
||||||
extend to it, but the concrete field/Secret-naming conventions need their
|
|
||||||
own look.
|
|
||||||
- Cross-namespace `teamRef` on the child CRDs (`TerdutEscalationRule`,
|
|
||||||
`TerdutDeadmanSwitch`, `TerdutAlertSource`) — only `TerdutTeam.serverRef`
|
|
||||||
crosses namespaces in v1 (§2, §4.2, §4.6); these stay same-namespace as
|
|
||||||
their `TerdutTeam` until a real need for splitting them out shows up.
|
|
||||||
- Narrower-than-namespace RBAC for the §4.5 webhook Secret (Kubernetes RBAC
|
|
||||||
has no owner-scoped grant below the namespace itself, per §9) — revisit
|
|
||||||
if the widened per-tenant-namespace `Secret` access proves too broad in
|
|
||||||
practice.
|
|
||||||
- A mid-life `spec.teamRef` change on a child CRD (`TerdutEscalationRule`,
|
|
||||||
`TerdutDeadmanSwitch`, `TerdutAlertSource`) isn't specially detected —
|
|
||||||
noticed while grounding Stage 4 against source, not newly introduced by
|
|
||||||
it: all three controllers always resolve `spec.teamRef` fresh every
|
|
||||||
reconcile and act against whatever `TerdutTeam` that currently names,
|
|
||||||
trusting the server-side id already stored in `status` remains valid
|
|
||||||
there. There's no server-side verb that could move an existing
|
|
||||||
integration/policy/switch to a different team in place regardless, so
|
|
||||||
retargeting one onto a live child isn't a supported operation in v1 —
|
|
||||||
delete and recreate the CR instead.
|
|
||||||
- `TerdutServerSpec.OIDC` has no `trustEmail` field (nor `usernameClaim`,
|
|
||||||
`emailClaim`, `groupsClaim` — the "rather than being added here
|
|
||||||
speculatively" fields its own doc comment already names), unlike
|
|
||||||
`charts/terdut-server`'s own chart, which sets `oidc.trustEmail: true`
|
|
||||||
for the production install specifically because Authentik reports
|
|
||||||
`email_verified: false` and without it a user's first SSO sign-in
|
|
||||||
creates a second, empty account instead of linking to their existing
|
|
||||||
one (`terdut-server/README.md`'s own account of this). Found by
|
|
||||||
actually trying to stand up a second real `TerdutServer` against the
|
|
||||||
same Authentik provider (`terdut-demo`, `Ryuvia/charts#275`), not by
|
|
||||||
inspection: that install's OIDC config is otherwise a straight copy of
|
|
||||||
production's and runs with terdut-server's own default (`trustEmail:
|
|
||||||
false`) regardless, since the CRD has nowhere to put the override.
|
|
||||||
Worth closing if a second real OIDC install becomes routine rather than
|
|
||||||
a one-off exercise.
|
|
||||||
- Gitops-managed team *membership* (see §4.2).
|
|
||||||
- Automatic Deployment restart on upstream Postgres credential rotation.
|
- Automatic Deployment restart on upstream Postgres credential rotation.
|
||||||
- `spec.pod.priorityClassName`, pod-label passthrough beyond
|
- `spec.pod.priorityClassName`, pod labels beyond annotations, and an HPA for `TerdutServer`.
|
||||||
`spec.pod.annotations`, and a HorizontalPodAutoscaler for `TerdutServer`
|
- Admission webhooks beyond CEL (for example, checking a `teamRef` exists at admission time).
|
||||||
— all considered alongside §4.1's `spec.pod` and left out of that round.
|
- A shared API types module or generated client for terdut-server, terdut-operator and terdut-tui.
|
||||||
An HPA no longer contradicts anything now that `spec.replicas` defaults
|
|
||||||
to 2 (terdut-server v0.36.0's advisory locks), but it is still a
|
|
||||||
separate, not-yet-made decision: a fixed replica count has no scaling
|
|
||||||
metric, min/max bounds, or cooldown behaviour to get right, and nobody
|
|
||||||
has asked for it yet.
|
|
||||||
- Admission webhooks / CEL-only validation limits (e.g. verifying a
|
|
||||||
`teamRef` exists at admission time rather than surfacing it as a status
|
|
||||||
condition after the fact).
|
|
||||||
- OLM packaging.
|
- OLM packaging.
|
||||||
|
|||||||
@@ -61,38 +61,7 @@ vet: ## Run go vet against code.
|
|||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: manifests generate fmt vet setup-envtest ## Run tests.
|
test: manifests generate fmt vet setup-envtest ## Run tests.
|
||||||
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out
|
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./...) -coverprofile cover.out
|
||||||
|
|
||||||
# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.
|
|
||||||
# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
|
|
||||||
# kubectl kuberc is disabled by default for test isolation; enable with:
|
|
||||||
# - KUBECTL_KUBERC=true
|
|
||||||
# CertManager is installed by default; skip with:
|
|
||||||
# - CERT_MANAGER_INSTALL_SKIP=true
|
|
||||||
KIND_CLUSTER ?= terdut-operator-test-e2e
|
|
||||||
|
|
||||||
.PHONY: setup-test-e2e
|
|
||||||
setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist
|
|
||||||
@command -v $(KIND) >/dev/null 2>&1 || { \
|
|
||||||
echo "Kind is not installed. Please install Kind manually."; \
|
|
||||||
exit 1; \
|
|
||||||
}
|
|
||||||
@case "$$($(KIND) get clusters)" in \
|
|
||||||
*"$(KIND_CLUSTER)"*) \
|
|
||||||
echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \
|
|
||||||
*) \
|
|
||||||
echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \
|
|
||||||
$(KIND) create cluster --name $(KIND_CLUSTER) ;; \
|
|
||||||
esac
|
|
||||||
|
|
||||||
.PHONY: test-e2e
|
|
||||||
test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
|
|
||||||
KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v
|
|
||||||
$(MAKE) cleanup-test-e2e
|
|
||||||
|
|
||||||
.PHONY: cleanup-test-e2e
|
|
||||||
cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests
|
|
||||||
@$(KIND) delete cluster --name $(KIND_CLUSTER)
|
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: golangci-lint ## Run golangci-lint linter
|
lint: golangci-lint ## Run golangci-lint linter
|
||||||
@@ -186,7 +155,6 @@ $(LOCALBIN):
|
|||||||
|
|
||||||
## Tool Binaries
|
## Tool Binaries
|
||||||
KUBECTL ?= kubectl
|
KUBECTL ?= kubectl
|
||||||
KIND ?= kind
|
|
||||||
KUSTOMIZE ?= $(LOCALBIN)/kustomize
|
KUSTOMIZE ?= $(LOCALBIN)/kustomize
|
||||||
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
|
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
|
||||||
ENVTEST ?= $(LOCALBIN)/setup-envtest
|
ENVTEST ?= $(LOCALBIN)/setup-envtest
|
||||||
|
|||||||
@@ -31,24 +31,6 @@ resources:
|
|||||||
kind: TerdutTeam
|
kind: TerdutTeam
|
||||||
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
||||||
version: v1alpha1
|
version: v1alpha1
|
||||||
- api:
|
|
||||||
crdVersion: v1
|
|
||||||
namespaced: true
|
|
||||||
controller: true
|
|
||||||
domain: ryuvia.com
|
|
||||||
group: terdut
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
|
||||||
version: v1alpha1
|
|
||||||
- api:
|
|
||||||
crdVersion: v1
|
|
||||||
namespaced: true
|
|
||||||
controller: true
|
|
||||||
domain: ryuvia.com
|
|
||||||
group: terdut
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
|
||||||
version: v1alpha1
|
|
||||||
- api:
|
- api:
|
||||||
crdVersion: v1
|
crdVersion: v1
|
||||||
namespaced: true
|
namespaced: true
|
||||||
|
|||||||
@@ -1,44 +1,31 @@
|
|||||||
# Terdut operator
|
# Terdut operator
|
||||||
|
|
||||||
Aims to expose most config as CRD's, so end users can self-service over gitops.
|
Exposes terdut-server's configuration as Kubernetes objects, so teams can self-service it over
|
||||||
|
gitops. See [DESIGN.md](./DESIGN.md) for the design (read its revision section first: it is the
|
||||||
|
current shape of credentials and the CRD catalog), and [examples/demo](./examples/demo) for a
|
||||||
|
working install.
|
||||||
|
|
||||||
See [DESIGN.md](./DESIGN.md) for the full design: CRD catalog and specs,
|
## CRDs
|
||||||
reconciliation semantics, bootstrap/auth, Postgres integration, RBAC, and the
|
|
||||||
relationship to `charts/terdut-server`. This README stays a short pitch; the
|
|
||||||
open questions it used to carry are now resolved decisions there (§2).
|
|
||||||
|
|
||||||
## CRD's
|
### TerdutServer
|
||||||
|
A terdut-server install: Deployment, Service, database wiring (a DSN, or a Zalando
|
||||||
|
`postgresClusterRef`), and an operator key Secret it hands to the server so the operator can
|
||||||
|
authenticate. `allowedTeams` consents to `TerdutTeam`s in other namespaces.
|
||||||
|
|
||||||
### terdutServers
|
### TerdutTeam
|
||||||
Creates a server — Deployment, Service, database wiring, bootstrap, operator
|
One team on a server, possibly in another namespace (`serverRef`, gated by that server's
|
||||||
credentials, and `allowedTeams` consent for cross-namespace teams. See
|
`allowedTeams`). It carries the team's whole configuration:
|
||||||
DESIGN.md §4.1, §4.6.
|
- `displayName` and OIDC group bindings
|
||||||
|
- `escalation` — the escalation ladder
|
||||||
|
- `deadmanSwitches` — dead man's switches, by name (switches not listed are removed)
|
||||||
|
|
||||||
### terdutTeams
|
### TerdutAlertSource
|
||||||
- team name
|
An alert-ingest integration on a team (`teamRef`). The server shows the webhook key once; it is
|
||||||
- oidc groups
|
surfaced only through a generated Secret next to the object, never set explicitly.
|
||||||
- `serverRef` — explicit reference to its `TerdutServer`, may be in a
|
|
||||||
different namespace (one team owns the server, others self-service a
|
|
||||||
team against it), gated by that `TerdutServer`'s own `allowedTeams`
|
|
||||||
field (DESIGN.md §2, §4.1, §4.2, §4.6)
|
|
||||||
|
|
||||||
### terdutEscalationrules
|
|
||||||
- rule
|
|
||||||
- `teamRef` — explicit reference to its `TerdutTeam` (DESIGN.md §2, §4.3)
|
|
||||||
|
|
||||||
### terdutDeadmansswitches
|
|
||||||
- rule
|
|
||||||
- `teamRef` (DESIGN.md §4.4)
|
|
||||||
|
|
||||||
### terdutAlertSources
|
|
||||||
- `teamRef` (DESIGN.md §4.5)
|
|
||||||
- URL/key are generated by the server at creation and surfaced only via a
|
|
||||||
generated Secret, never set explicitly
|
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
[`examples/demo`](./examples/demo) wires one of every CRD above together
|
[`examples/demo`](./examples/demo) wires one of each together — two teams, each with an
|
||||||
— two teams, each with an escalation rule, a dead man's switch and an
|
escalation ladder, a dead man's switch and an alert source — plus a script that fires synthetic
|
||||||
alert source — plus a script that fires synthetic Alertmanager webhooks
|
Alertmanager webhooks at it, so you can watch incidents open, escalate and resolve without a real
|
||||||
at it, so you can watch real incidents open, escalate and resolve without
|
Alertmanager.
|
||||||
a real Alertmanager anywhere in the picture.
|
|
||||||
|
|||||||
+20
-235
@@ -1,240 +1,25 @@
|
|||||||
# terdut-operator build roadmap
|
# terdut-operator status and deferred work
|
||||||
|
|
||||||
This is the staging plan for implementing the operator against `DESIGN.md`'s
|
The staged build plan this file used to hold (scaffolding, TerdutServer, TerdutTeam, the child
|
||||||
settled decisions. It exists for the same reason `DESIGN.md` and
|
kinds, the installer chart and first release) is done and shipped; the history is in git. The
|
||||||
`SERVICE-ACCOUNTS.md` do: so each stage starts from an agreed sequencing
|
design it implemented is in `DESIGN.md`, and its revision section at the top is the current
|
||||||
instead of re-litigating "what do we build first" mid-PR.
|
shape of credentials and the CRD catalog.
|
||||||
|
|
||||||
## Sequencing call this roadmap makes
|
## Validation
|
||||||
|
|
||||||
**The operator creates and owns every `TerdutServer` it manages — it never
|
There is no CI end-to-end job. The golden path (create every kind against a real terdut-server on
|
||||||
adopts one deployed independently, by hand or by `charts/terdut-server`**
|
`kind`, check the server's own API, delete, check it is gone) is a manual pass, using
|
||||||
(`DESIGN.md` §1). An earlier version of this roadmap staged `TerdutServer`'s
|
`examples/demo/run-demo.sh`. It has not been re-run since the credential and CRD redesign
|
||||||
Deployment/Service/bootstrap takeover separately (old Stage 5), behind a
|
(2026-10): do that before the next release.
|
||||||
hand-deployed server the simpler CRDs could be proven against first.
|
|
||||||
That staging existed only because a credential-less operator couldn't
|
|
||||||
`/api/bootstrap` its way into a server something else had already
|
|
||||||
bootstrapped (`DESIGN.md` §6's original gap). With no server to adopt at
|
|
||||||
all, that split has nothing left to justify it: `TerdutServer` now builds
|
|
||||||
its full lifecycle — Deployment, Service, database wiring, bootstrap,
|
|
||||||
credentials — in one stage, Stage 1, since bootstrap only has something to
|
|
||||||
bootstrap once the Deployment exists.
|
|
||||||
|
|
||||||
## Stage 0 — Scaffolding & CI
|
## Deferred
|
||||||
|
|
||||||
- `go.mod` (`git.ryuvia.com/niklas/terdut-operator`) + Kubebuilder v4
|
- Narrower Secret RBAC: per-namespace Roles and a restricted cache (today a ClusterRole with
|
||||||
scaffold (`cmd/main.go`, `config/`, `Makefile`, `PROJECT`), matching
|
Secret access cluster-wide, DESIGN.md §9).
|
||||||
terdut-server's Go toolchain and house style (§3). Kubebuilder's own
|
- CloudNativePG support alongside the Zalando `postgresClusterRef`.
|
||||||
scaffolded `Makefile` already wires `manifests`/`generate`
|
- Gitops-managed team membership.
|
||||||
(`controller-gen`) and `setup-envtest` into `test`, and `golangci-lint`
|
- Automatic Deployment restart on upstream Postgres credential rotation.
|
||||||
into `lint`, all fetched on demand into `bin/` — no separate install
|
- Admission webhooks beyond CEL validation.
|
||||||
step needed beyond what `make test`/`make lint` already do.
|
- OLM packaging.
|
||||||
- `.release.conf` deliberately **not** added yet: it names a `HELM_CHART`
|
- A shared API types module (or generated client) between terdut-server, terdut-operator and
|
||||||
this repo doesn't have until Stage 5. Adding it now would either be a
|
terdut-tui, so contract drift is a compile error and not a manual mirror.
|
||||||
stub that lies about what's releasable or dead config nobody can run —
|
|
||||||
it lands in Stage 5, alongside the chart it describes.
|
|
||||||
- Gitea Actions CI calling `fmt lint test`, mirroring terdut-server's
|
|
||||||
`ci.yaml` convention (its `CLAUDE.md`: "a green gate here and a green
|
|
||||||
pipeline are the same code, not two descriptions of it") minus the
|
|
||||||
`chart`/`security` jobs, which need a chart (Stage 5) and real controller
|
|
||||||
code (Stage 1+) respectively to have anything to check.
|
|
||||||
- Drop kubebuilder's default `.github/workflows/*` scaffold — this org
|
|
||||||
runs on Gitea, not GitHub; `.gitea/workflows/ci.yaml` is the only CI this
|
|
||||||
repo has.
|
|
||||||
- Housekeeping: drop the stray `.DESIGN.md.swp` (leftover vim swapfile,
|
|
||||||
shouldn't be committed); correct `DESIGN.md` §6/§13's "v1-blocking, not
|
|
||||||
v1-shippable" language — the service-account feature it was blocking on
|
|
||||||
has since shipped in terdut-server.
|
|
||||||
|
|
||||||
**Done when:** CI is green on an otherwise-empty scaffold.
|
|
||||||
|
|
||||||
## Stage 1 — `TerdutServer`, full lifecycle
|
|
||||||
|
|
||||||
Supersedes the Stage 1 shipped before this redesign (commit `1be7cf2`)
|
|
||||||
outright — that `TerdutServerSpec`/`Status`/controller/tests implemented the
|
|
||||||
now-removed bring-your-own path and get replaced wholesale, not extended.
|
|
||||||
New commits build forward over the old ones; no git history rewrite.
|
|
||||||
|
|
||||||
- Full §4.1 spec: `image`, `replicas`, `networking`, `database`, `sweeper`,
|
|
||||||
`deadman`, `notify`, `oidc`, `passwordLogin`, `allowedTeams`, all together
|
|
||||||
— no narrowing, since bootstrap needs the Deployment it's narrowed away
|
|
||||||
from in the version this replaces.
|
|
||||||
- Controller manages the Deployment + Service, both Postgres paths from §8
|
|
||||||
at once (bring-your-own DSN *and* the Zalando `postgres-operator`
|
|
||||||
`postgresClusterRef` integration — not sequenced, per the user's call),
|
|
||||||
and bootstrap/credentials per §6's self-registration flow: `/api/bootstrap`
|
|
||||||
once the Deployment has a ready replica, checkpoint the admin key, mint
|
|
||||||
the instance-scoped service account, generated credentials Secret in the
|
|
||||||
operator's own namespace, `status.credentialsSecretRef`. Finalizer cleans
|
|
||||||
up that Secret (and the checkpoint, if one's still there) on delete —
|
|
||||||
there's no server-side row to clean up alongside it: terdut-server's API
|
|
||||||
has no way to delete a user or a service account, only to revoke
|
|
||||||
individual keys, so there's nothing to undo there regardless.
|
|
||||||
- RBAC: read-only watch on `postgresql.acid.zalan.do`, degrading gracefully
|
|
||||||
if that CRD isn't installed (§8, §9).
|
|
||||||
- Shipped, scoped down from §8's full ambition in one way, called out in
|
|
||||||
code rather than silently dropped: no live watch on the Zalando-
|
|
||||||
generated credentials Secret for rotation (relies on the periodic resync
|
|
||||||
to notice eventually, higher latency than a watch). A near-term
|
|
||||||
follow-up, not deferred to a later stage.
|
|
||||||
- External exposure (a Gateway API `HTTPRoute` from
|
|
||||||
`spec.networking.hostname`) was originally sketched here too, as a
|
|
||||||
second near-term follow-up alongside the one above. It's since become an
|
|
||||||
explicit, permanent non-goal instead (DESIGN.md §1): the operator will
|
|
||||||
never manage ingress/exposure for `TerdutServer` in any form. See
|
|
||||||
`examples/networking` for how to do that yourself.
|
|
||||||
- `envtest` covering Deployment/Service reconciliation and both database
|
|
||||||
paths — the Zalando path needs that CRD's schema vendored into the test
|
|
||||||
environment (there's no real `postgres-operator` controller in `envtest`,
|
|
||||||
only the CRD shape to create fixture objects against) — plus the
|
|
||||||
self-registration flow against an `httptest.Server` fake of
|
|
||||||
`/api/bootstrap` and `/api/service-accounts` (§11), including the
|
|
||||||
adopt-on-409 recovery path and the one fail-closed case
|
|
||||||
(`BootstrapStateLost`), not just the happy path.
|
|
||||||
- **Done, 2026-10-01**: a real `kind` end-to-end pass (bring-your-own DSN,
|
|
||||||
real terdut-server `v0.33.0` image, operator built into a real image and
|
|
||||||
deployed as a real Pod, not `go run` against the cluster). `TerdutServer`
|
|
||||||
went `Ready`; the generated credential authenticated and exercised its
|
|
||||||
real capability against the actual server
|
|
||||||
(`GET`/`POST /api/teams` → `200`/`201`, confirmed from terdut-server's own
|
|
||||||
access log). Caught two real bugs no `envtest` suite could have (its
|
|
||||||
client bypasses RBAC): `.dockerignore`'s `!**/*.go` not working under
|
|
||||||
podman, and missing RBAC for `events.k8s.io` (the new events API
|
|
||||||
`GetEventRecorder` uses) — both fixed. The Zalando path can additionally
|
|
||||||
be validated for real against the org's own cluster later, where
|
|
||||||
`postgres-operator` already runs, rather than only in a disposable `kind`
|
|
||||||
stand-in — not done in this pass.
|
|
||||||
|
|
||||||
## Stage 2 — `TerdutTeam`
|
|
||||||
|
|
||||||
- §4.2: `serverRef` resolution, real update-in-place (POST create / PUT
|
|
||||||
rename / PUT oidc-groups), team-scoped service-account minting once
|
|
||||||
`Ready` (§6 point 3), finalizer that DELETEs the team server-side and its
|
|
||||||
credential Secret.
|
|
||||||
- First place the "every child resolves its own `teamRef` →
|
|
||||||
`TerdutTeam.status`, never chains up to `TerdutServer`" pattern (§5) gets
|
|
||||||
proven end to end.
|
|
||||||
|
|
||||||
## Stage 3 — `TerdutEscalationRule` + `TerdutDeadmanSwitch`
|
|
||||||
|
|
||||||
- Built together: both stay same-namespace-as-their-`TerdutTeam` (§1), so
|
|
||||||
neither exercises cross-namespace complexity, but together they cover the
|
|
||||||
two different reconciliation shapes §5's table calls out — whole-policy
|
|
||||||
PUT-upsert for the escalation policy (no separate create step at all),
|
|
||||||
real create/update-in-place/delete for the dead man's switch (PUT added
|
|
||||||
in terdut-server `v0.33.0` specifically for this operator) — against the
|
|
||||||
same shared create/finalizer/resync scaffolding Stage 2 already built.
|
|
||||||
- New shared `resolveTeamAndClient` helper (`childref.go`) implements §5's
|
|
||||||
"every child resolves its own `teamRef` → `TerdutTeam.status`, never
|
|
||||||
chains up to `TerdutServer`" rule once, for both controllers —
|
|
||||||
`TerdutTeam.status.serverEndpoint`, added in this stage, is what makes
|
|
||||||
that literally true rather than just a stated intent.
|
|
||||||
- `TerdutEscalationRule` resolves each "user" target's username to a
|
|
||||||
user_id via `GET /api/users` (confirmed open to any authenticated
|
|
||||||
caller) and reports `Ready: False, reason: UnknownUser` if it doesn't
|
|
||||||
resolve. No `DELETE` exists for this resource, so its delete path `PUT`s
|
|
||||||
an empty policy as the closest available undo.
|
|
||||||
- `TerdutDeadmanSwitch` has no unique-name constraint server-side, so its
|
|
||||||
idempotent-create is `GET`-list-and-match-by-name rather than
|
|
||||||
adopt-on-409 (unlike every other resource in this operator).
|
|
||||||
- **Done, 2026-10-01**: `envtest` coverage for both controllers' happy
|
|
||||||
path, `TeamRefNotFound`/`WaitingForTeam`, `UnknownUser`, list-and-match
|
|
||||||
adoption, update-in-place on spec drift, and deletion. `make fmt lint
|
|
||||||
test build` all clean; `internal/controller` envtest coverage
|
|
||||||
50.5% → 71.7%. No `kind` e2e pass for this stage — Stage 1's already
|
|
||||||
proved the real-cluster mechanics (RBAC, image, bootstrap) these two
|
|
||||||
controllers reuse unchanged, and neither introduces a new mechanism that
|
|
||||||
pass would exercise differently (same reasoning Stage 2 used to skip
|
|
||||||
one).
|
|
||||||
|
|
||||||
## Stage 4 — `TerdutAlertSource`
|
|
||||||
|
|
||||||
- Last of the children on purpose: it has the subtlest failure mode of the
|
|
||||||
four. Covers webhook Secret generation/ownership (§4.5, §7), the
|
|
||||||
`WebhookSecretLost` fail-closed condition + `Warning` event (§5, added
|
|
||||||
2026-09-30), and the kind-change delete-and-recreate rotation path — all
|
|
||||||
easier to get right with the other three controllers' patterns already
|
|
||||||
in place to build on.
|
|
||||||
- Idempotent-create here is neither adopt-on-409 (Team/service-account) nor
|
|
||||||
list-and-match-by-name (`TerdutDeadmanSwitch`): terdut-server shows the
|
|
||||||
webhook key exactly once, at creation, and never again, so no server-side
|
|
||||||
lookup could ever recover it after a crash. The generated webhook Secret
|
|
||||||
itself — written immediately after the POST, before `status` is ever
|
|
||||||
touched — is this CR's only durable record that a create already
|
|
||||||
succeeded; found on a later reconcile with `status.integrationID` still
|
|
||||||
unset, it's read back directly rather than POSTing again. Found missing
|
|
||||||
with `status.integrationID` *set*, that's the already-designed
|
|
||||||
`WebhookSecretLost` fail-closed case instead.
|
|
||||||
- **Done, 2026-10-01**: `envtest` coverage for the happy path, rename
|
|
||||||
(PATCH, no key rotation), a kind change (delete-and-recreate, new id and
|
|
||||||
key), crash recovery between POST and the Secret write, `WebhookSecretLost`,
|
|
||||||
`TeamRefNotFound`/`WaitingForTeam`, and deletion. `make fmt lint test
|
|
||||||
build` all clean; `internal/controller` envtest coverage holds at 71.6%.
|
|
||||||
No `kind` e2e pass for this stage, same reasoning as Stage 3 (reuses
|
|
||||||
Stage 1's already-proven real-cluster mechanics unchanged).
|
|
||||||
|
|
||||||
## Stage 5 — Installer chart + real release
|
|
||||||
|
|
||||||
- Package CRDs + the operator's own Deployment/RBAC into the installer
|
|
||||||
chart §10 describes; wire `.release.conf`/release-vars the same way
|
|
||||||
terdut-server does; run it through the `release` skill for a real first
|
|
||||||
cut.
|
|
||||||
- Full `kind` end-to-end test per §11: create `TerdutServer` → `TerdutTeam`
|
|
||||||
→ one of each child kind → verify via terdut-server's own API that each
|
|
||||||
object exists with the right shape → delete the CR → verify the
|
|
||||||
server-side object is gone.
|
|
||||||
- Chart built via kubebuilder's own `helm/v2-alpha` plugin from `config/`'s
|
|
||||||
kustomize output (`charts/terdut-operator`), not hand-rolled -- CRDs +
|
|
||||||
manager Deployment/RBAC come from the same markers/manifests every other
|
|
||||||
stage already generates, so there's exactly one source of truth for
|
|
||||||
them. Hand-added on top: the optional `terdutServer` values block (§10's
|
|
||||||
"helm install and get a server" path), `.release.conf`, and the
|
|
||||||
`release-vars`/`helm-lint`/`push`/`helm-package`/`helm-push`/`release`
|
|
||||||
Makefile targets `.gitea/workflows/release.yaml` calls, mirroring
|
|
||||||
terdut-server's own shape end to end (same registry/namespace
|
|
||||||
convention, same multi-arch buildx push, same trivy/govulncheck/gitleaks
|
|
||||||
scans). Also fixed while wiring this: the Dockerfile's builder stage
|
|
||||||
didn't pin `--platform=$BUILDPLATFORM`, which would have made a
|
|
||||||
multi-arch release build fail outright on this org's runners (no binfmt
|
|
||||||
registration) -- caught before it ever shipped, not discovered mid-release;
|
|
||||||
and govulncheck surfaced one real, reachable finding (`google.golang.org/grpc`
|
|
||||||
v1.82.1, transitive via controller-runtime's otel exporter), fixed by
|
|
||||||
bumping to v1.83.1.
|
|
||||||
- **Done, 2026-10-01**: the full golden-path pass above, run for real
|
|
||||||
against a `kind` cluster, installed via `helm install` (not raw
|
|
||||||
kustomize/kubectl apply -- the first time the chart itself, not just
|
|
||||||
`config/`, was exercised): `TerdutServer` (real terdut-server `v0.33.0`
|
|
||||||
image, bring-your-own DSN against a throwaway in-cluster Postgres) →
|
|
||||||
`TerdutTeam` → one `TerdutEscalationRule` + `TerdutDeadmanSwitch` +
|
|
||||||
`TerdutAlertSource`, each confirmed `Ready` and then confirmed a second
|
|
||||||
way, independent of the operator's own status: a `curl` pod inside the
|
|
||||||
cluster, authenticated with the generated team credential, hit
|
|
||||||
terdut-server's real API directly (`GET /api/teams/{id}/escalation`,
|
|
||||||
`.../deadman/switches`, `.../integrations`) and got back exactly the
|
|
||||||
policy/switch/integration each spec declared. Deleting every CR in
|
|
||||||
reverse order was verified the same way: the escalation policy came back
|
|
||||||
empty (its only available "undo"), the switch and the integration were
|
|
||||||
both gone from their list endpoints, the team no longer resolved by
|
|
||||||
name, and the Deployment/Service/every generated Secret were gone from
|
|
||||||
the cluster. No new bugs found this pass -- Stage 1's own kind e2e pass
|
|
||||||
already caught the two issues (`events.k8s.io` RBAC, the podman
|
|
||||||
`.dockerignore` fix) a real cluster catches and `envtest` can't, and
|
|
||||||
nothing since has touched that surface.
|
|
||||||
- Not done in this pass, deliberately: an actual tagged release. `make
|
|
||||||
release-vars`/`helm-lint`/`push`/`helm-package`/`helm-push` all work
|
|
||||||
locally and `.gitea/workflows/release.yaml` is wired, but
|
|
||||||
`release-preflight` found there is no `terdut-operator/` entry under
|
|
||||||
`Ryuvia/charts` yet to bump -- every other onboarded repo had that
|
|
||||||
one-time wrapper-chart bootstrap done for it before its own first
|
|
||||||
release, and this one doesn't, since deploying this operator for real is
|
|
||||||
a decision for whoever runs the cluster, not a side effect of finishing
|
|
||||||
this stage. Cutting the first real release (and creating that wrapper
|
|
||||||
entry) is therefore the next action, not yet taken.
|
|
||||||
|
|
||||||
## Deferred (§13, unchanged by this roadmap)
|
|
||||||
|
|
||||||
Cross-namespace `allowedTeams` exercised against a real second namespace,
|
|
||||||
CloudNativePG support, narrower-than-namespace Secret RBAC for the webhook
|
|
||||||
Secret, gitops-managed team membership, automatic Deployment restart on
|
|
||||||
upstream Postgres credential rotation, admission webhooks/CEL-only
|
|
||||||
validation limits, OLM packaging.
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package v1alpha1
|
|
||||||
|
|
||||||
import (
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TerdutDeadmanSwitchSpec defines the desired state of TerdutDeadmanSwitch.
|
|
||||||
//
|
|
||||||
// One per switch (DESIGN.md §4.4). Reconciled with real update-in-place
|
|
||||||
// (terdut-server v0.33.0 added PUT specifically for this, §5) -- but with no
|
|
||||||
// unique-name constraint server-side, idempotent-create here means
|
|
||||||
// GET-list-and-match-by-name, not adopt-on-409.
|
|
||||||
type TerdutDeadmanSwitchSpec struct {
|
|
||||||
// +required
|
|
||||||
TeamRef TerdutTeamRef `json:"teamRef"`
|
|
||||||
|
|
||||||
// name is optional, same as the API: left empty, terdut-server derives
|
|
||||||
// it from matcher's own canonical form, and that's what the
|
|
||||||
// idempotent-create lookup matches against too.
|
|
||||||
// +optional
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
|
|
||||||
// matcher names the alerts this switch watches, e.g.
|
|
||||||
// "alertname=Watchdog,cluster=prod". One matcher per switch -- add
|
|
||||||
// another TerdutDeadmanSwitch instead of separating with ";"
|
|
||||||
// (terdut-server's own restriction, mirrored here so a bad spec is
|
|
||||||
// rejected at apply time).
|
|
||||||
// +required
|
|
||||||
// +kubebuilder:validation:MinLength=1
|
|
||||||
// +kubebuilder:validation:XValidation:rule="!self.contains(';')",message="one matcher per switch: add another TerdutDeadmanSwitch instead of separating with ;"
|
|
||||||
Matcher string `json:"matcher"`
|
|
||||||
|
|
||||||
// timeout is a Go duration string, e.g. "15m".
|
|
||||||
// +required
|
|
||||||
// +kubebuilder:validation:MinLength=1
|
|
||||||
Timeout string `json:"timeout"`
|
|
||||||
|
|
||||||
// +kubebuilder:validation:Enum=critical;error;warning;info
|
|
||||||
// +kubebuilder:default=critical
|
|
||||||
// +optional
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerdutDeadmanSwitchStatus defines the observed state of TerdutDeadmanSwitch.
|
|
||||||
type TerdutDeadmanSwitchStatus struct {
|
|
||||||
// +listType=map
|
|
||||||
// +listMapKey=type
|
|
||||||
// +optional
|
|
||||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
|
||||||
|
|
||||||
// switchID is the server-side id.
|
|
||||||
// +optional
|
|
||||||
SwitchID int64 `json:"switchID,omitempty"`
|
|
||||||
|
|
||||||
// +optional
|
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:object:root=true
|
|
||||||
// +kubebuilder:subresource:status
|
|
||||||
// +kubebuilder:printcolumn:name="Team",type=string,JSONPath=`.spec.teamRef.name`
|
|
||||||
// +kubebuilder:printcolumn:name="SwitchID",type=integer,JSONPath=`.status.switchID`
|
|
||||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
|
|
||||||
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
|
|
||||||
|
|
||||||
// TerdutDeadmanSwitch is the Schema for the terdutdeadmanswitches API
|
|
||||||
type TerdutDeadmanSwitch struct {
|
|
||||||
metav1.TypeMeta `json:",inline"`
|
|
||||||
|
|
||||||
// metadata is a standard object metadata
|
|
||||||
// +optional
|
|
||||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
|
||||||
|
|
||||||
// spec defines the desired state of TerdutDeadmanSwitch
|
|
||||||
// +required
|
|
||||||
Spec TerdutDeadmanSwitchSpec `json:"spec"`
|
|
||||||
|
|
||||||
// status defines the observed state of TerdutDeadmanSwitch
|
|
||||||
// +optional
|
|
||||||
Status TerdutDeadmanSwitchStatus `json:"status,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:object:root=true
|
|
||||||
|
|
||||||
// TerdutDeadmanSwitchList contains a list of TerdutDeadmanSwitch
|
|
||||||
type TerdutDeadmanSwitchList struct {
|
|
||||||
metav1.TypeMeta `json:",inline"`
|
|
||||||
metav1.ListMeta `json:"metadata,omitzero"`
|
|
||||||
Items []TerdutDeadmanSwitch `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
|
||||||
s.AddKnownTypes(SchemeGroupVersion, &TerdutDeadmanSwitch{}, &TerdutDeadmanSwitchList{})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package v1alpha1
|
|
||||||
|
|
||||||
import (
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
|
||||||
// same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
|
||||||
// crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
type TerdutTeamRef struct {
|
|
||||||
// +kubebuilder:validation:MinLength=1
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscalationTargetKind is who one rung of the ladder pages.
|
|
||||||
// +kubebuilder:validation:Enum=oncall;user
|
|
||||||
type EscalationTargetKind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EscalationTargetOncall EscalationTargetKind = "oncall"
|
|
||||||
EscalationTargetUser EscalationTargetKind = "user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EscalationTarget is one page within a level. username is required iff
|
|
||||||
// kind is "user" (terdut-server's own validation, internal/api/escalation.go's
|
|
||||||
// handleSetEscalation -- mirrored here as a CEL rule so a bad spec is
|
|
||||||
// rejected at apply time, not discovered on the next failed PUT).
|
|
||||||
// +kubebuilder:validation:XValidation:rule="self.kind != 'user' || has(self.username)",message="username is required when kind is user"
|
|
||||||
// +kubebuilder:validation:XValidation:rule="self.kind != 'oncall' || !has(self.username)",message="username must not be set when kind is oncall"
|
|
||||||
type EscalationTarget struct {
|
|
||||||
// +required
|
|
||||||
Kind EscalationTargetKind `json:"kind"`
|
|
||||||
// +optional
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscalationLevel is one rung of the ladder: how long to wait, and who to
|
|
||||||
// page if nobody's acknowledged by then.
|
|
||||||
type EscalationLevel struct {
|
|
||||||
// timeout is a Go duration string, e.g. "5m".
|
|
||||||
// +required
|
|
||||||
// +kubebuilder:validation:MinLength=1
|
|
||||||
Timeout string `json:"timeout"`
|
|
||||||
|
|
||||||
// +required
|
|
||||||
// +kubebuilder:validation:MinItems=1
|
|
||||||
Targets []EscalationTarget `json:"targets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerdutEscalationRuleSpec defines the desired state of TerdutEscalationRule.
|
|
||||||
//
|
|
||||||
// One per team (DESIGN.md §4.3) -- terdut-server models a policy as one row
|
|
||||||
// with an owned list of levels, reconciled with a single whole-policy PUT.
|
|
||||||
// Not enforced at admission if two CRs name the same team (no webhooks in
|
|
||||||
// v1, §1); they would simply clobber each other every reconcile.
|
|
||||||
type TerdutEscalationRuleSpec struct {
|
|
||||||
// +required
|
|
||||||
TeamRef TerdutTeamRef `json:"teamRef"`
|
|
||||||
|
|
||||||
// +kubebuilder:validation:Minimum=0
|
|
||||||
// +kubebuilder:validation:Maximum=10
|
|
||||||
// +optional
|
|
||||||
RepeatCount int64 `json:"repeatCount,omitempty"`
|
|
||||||
|
|
||||||
// +optional
|
|
||||||
FallbackTopic string `json:"fallbackTopic,omitempty"`
|
|
||||||
|
|
||||||
// +required
|
|
||||||
// +kubebuilder:validation:MinItems=1
|
|
||||||
Levels []EscalationLevel `json:"levels"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition reasons shared by TerdutEscalationRule and TerdutDeadmanSwitch
|
|
||||||
// (both resolve a teamRef the same way, DESIGN.md §5).
|
|
||||||
const (
|
|
||||||
// ReasonTeamRefNotFound: spec.teamRef names no TerdutTeam (yet).
|
|
||||||
ReasonTeamRefNotFound = "TeamRefNotFound"
|
|
||||||
// ReasonWaitingForTeam: the referenced TerdutTeam exists but isn't
|
|
||||||
// Ready yet (no status.credentialsSecretRef to read).
|
|
||||||
ReasonWaitingForTeam = "WaitingForTeam"
|
|
||||||
// ReasonUnknownUser: an escalation target's username doesn't resolve to
|
|
||||||
// any user server-side (TerdutEscalationRule only).
|
|
||||||
ReasonUnknownUser = "UnknownUser"
|
|
||||||
// ReasonChildAdopted: the happy path, shared by both child kinds.
|
|
||||||
ReasonChildAdopted = "Adopted"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TerdutEscalationRuleStatus defines the observed state of TerdutEscalationRule.
|
|
||||||
type TerdutEscalationRuleStatus struct {
|
|
||||||
// +listType=map
|
|
||||||
// +listMapKey=type
|
|
||||||
// +optional
|
|
||||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
|
||||||
|
|
||||||
// +optional
|
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:object:root=true
|
|
||||||
// +kubebuilder:subresource:status
|
|
||||||
// +kubebuilder:printcolumn:name="Team",type=string,JSONPath=`.spec.teamRef.name`
|
|
||||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
|
|
||||||
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
|
|
||||||
|
|
||||||
// TerdutEscalationRule is the Schema for the terdutescalationrules API
|
|
||||||
type TerdutEscalationRule struct {
|
|
||||||
metav1.TypeMeta `json:",inline"`
|
|
||||||
|
|
||||||
// metadata is a standard object metadata
|
|
||||||
// +optional
|
|
||||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
|
||||||
|
|
||||||
// spec defines the desired state of TerdutEscalationRule
|
|
||||||
// +required
|
|
||||||
Spec TerdutEscalationRuleSpec `json:"spec"`
|
|
||||||
|
|
||||||
// status defines the observed state of TerdutEscalationRule
|
|
||||||
// +optional
|
|
||||||
Status TerdutEscalationRuleStatus `json:"status,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:object:root=true
|
|
||||||
|
|
||||||
// TerdutEscalationRuleList contains a list of TerdutEscalationRule
|
|
||||||
type TerdutEscalationRuleList struct {
|
|
||||||
metav1.TypeMeta `json:",inline"`
|
|
||||||
metav1.ListMeta `json:"metadata,omitzero"`
|
|
||||||
Items []TerdutEscalationRule `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
|
||||||
s.AddKnownTypes(SchemeGroupVersion, &TerdutEscalationRule{}, &TerdutEscalationRuleList{})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,11 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/util/intstr"
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecretKeyRef names one data key inside a Secret. Every use of this type in
|
// SecretKeyRef names one data key inside a Secret in the TerdutServer's own
|
||||||
// TerdutServerSpec resolves in the TerdutServer's own namespace (it's wired
|
// namespace. Every use of this type is wired into the Deployment's pod spec as
|
||||||
// straight into the Deployment's pod spec as a secretKeyRef env source,
|
// a secretKeyRef env source, which Kubernetes only allows same-namespace --
|
||||||
// which Kubernetes itself only allows same-namespace) -- unlike the
|
// including status.credentialsSecretRef, the operator key the controller
|
||||||
// generated credentials Secret (DESIGN.md §6), which always lives in the
|
// generates there.
|
||||||
// operator's own namespace and is never referenced through this type.
|
|
||||||
type SecretKeyRef struct {
|
type SecretKeyRef struct {
|
||||||
// name is the Secret's name.
|
// name is the Secret's name.
|
||||||
// +kubebuilder:validation:MinLength=1
|
// +kubebuilder:validation:MinLength=1
|
||||||
@@ -104,18 +103,6 @@ type SweeperSpec struct {
|
|||||||
ArchiveAfter string `json:"archiveAfter,omitempty"`
|
ArchiveAfter string `json:"archiveAfter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeadmanSpec controls dead man's switch alerts. Matchers/Timeout/Severity
|
|
||||||
// map straight to TERDUT_DEADMAN_MATCHERS/TERDUT_DEADMAN_TIMEOUT/
|
|
||||||
// TERDUT_DEADMAN_SEVERITY.
|
|
||||||
type DeadmanSpec struct {
|
|
||||||
// +optional
|
|
||||||
Matchers string `json:"matchers,omitempty"`
|
|
||||||
// +optional
|
|
||||||
Timeout string `json:"timeout,omitempty"`
|
|
||||||
// +optional
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifySpec controls push notifications via ntfy. Empty ntfyURL disables
|
// NotifySpec controls push notifications via ntfy. Empty ntfyURL disables
|
||||||
// notifications entirely (matches the chart's own default).
|
// notifications entirely (matches the chart's own default).
|
||||||
type NotifySpec struct {
|
type NotifySpec struct {
|
||||||
@@ -131,11 +118,7 @@ type NotifySpec struct {
|
|||||||
TokenSecretRef *SecretKeyRef `json:"tokenSecretRef,omitempty"`
|
TokenSecretRef *SecretKeyRef `json:"tokenSecretRef,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OIDCSpec controls single sign-on. Fields the chart also exposes but
|
// OIDCSpec controls single sign-on.
|
||||||
// DESIGN.md's spec doesn't (usernameClaim, emailClaim, groupsClaim,
|
|
||||||
// trustEmail) use terdut-server's own defaults
|
|
||||||
// (preferred_username/email/groups/false) rather than being added here
|
|
||||||
// speculatively.
|
|
||||||
type OIDCSpec struct {
|
type OIDCSpec struct {
|
||||||
// +optional
|
// +optional
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
@@ -158,6 +141,21 @@ type OIDCSpec struct {
|
|||||||
// +kubebuilder:default="12h"
|
// +kubebuilder:default="12h"
|
||||||
// +optional
|
// +optional
|
||||||
SessionMaxAge string `json:"sessionMaxAge,omitempty"`
|
SessionMaxAge string `json:"sessionMaxAge,omitempty"`
|
||||||
|
// usernameClaim, emailClaim and groupsClaim name the ID token claims read.
|
||||||
|
// +kubebuilder:default="preferred_username"
|
||||||
|
// +optional
|
||||||
|
UsernameClaim string `json:"usernameClaim,omitempty"`
|
||||||
|
// +kubebuilder:default="email"
|
||||||
|
// +optional
|
||||||
|
EmailClaim string `json:"emailClaim,omitempty"`
|
||||||
|
// +kubebuilder:default="groups"
|
||||||
|
// +optional
|
||||||
|
GroupsClaim string `json:"groupsClaim,omitempty"`
|
||||||
|
// trustEmail links a sign-in to an existing local user by email even when
|
||||||
|
// the provider does not vouch the address is verified (Authentik reports
|
||||||
|
// email_verified false unless told otherwise).
|
||||||
|
// +optional
|
||||||
|
TrustEmail bool `json:"trustEmail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedTeamsNamespaces gates which namespaces a TerdutTeam may resolve a
|
// AllowedTeamsNamespaces gates which namespaces a TerdutTeam may resolve a
|
||||||
@@ -321,9 +319,6 @@ type TerdutServerSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
Sweeper SweeperSpec `json:"sweeper,omitempty"`
|
Sweeper SweeperSpec `json:"sweeper,omitempty"`
|
||||||
|
|
||||||
// +optional
|
|
||||||
Deadman DeadmanSpec `json:"deadman,omitempty"`
|
|
||||||
|
|
||||||
// +optional
|
// +optional
|
||||||
Notify NotifySpec `json:"notify,omitempty"`
|
Notify NotifySpec `json:"notify,omitempty"`
|
||||||
|
|
||||||
@@ -353,15 +348,6 @@ const (
|
|||||||
// ConditionReady is the standard top-level condition every CRD carries
|
// ConditionReady is the standard top-level condition every CRD carries
|
||||||
// (DESIGN.md §7).
|
// (DESIGN.md §7).
|
||||||
ConditionReady = "Ready"
|
ConditionReady = "Ready"
|
||||||
// ConditionDatabaseReady reflects whether the configured database is
|
|
||||||
// usable -- for postgresClusterRef, whether the Zalando CR and its
|
|
||||||
// generated credentials Secret both resolved; for a plain dsn, always
|
|
||||||
// true once set (DESIGN.md §8: "no connectivity check beyond what the
|
|
||||||
// Deployment's own readiness probe already gives").
|
|
||||||
ConditionDatabaseReady = "DatabaseReady"
|
|
||||||
// ConditionBootstrapped reflects whether a working credential has been
|
|
||||||
// acquired via self-registration (DESIGN.md §6).
|
|
||||||
ConditionBootstrapped = "Bootstrapped"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Condition reasons this controller sets.
|
// Condition reasons this controller sets.
|
||||||
@@ -379,13 +365,6 @@ const (
|
|||||||
// instead -- but a TerdutServer that explicitly asks for it still needs
|
// instead -- but a TerdutServer that explicitly asks for it still needs
|
||||||
// to say clearly that it can't be satisfied).
|
// to say clearly that it can't be satisfied).
|
||||||
ReasonPostgresOperatorCRDNotInstalled = "PostgresOperatorCRDNotInstalled"
|
ReasonPostgresOperatorCRDNotInstalled = "PostgresOperatorCRDNotInstalled"
|
||||||
// ReasonBootstrapStateLost: a checkpointed admin credential
|
|
||||||
// (DESIGN.md §6) was lost after being used but before the lasting
|
|
||||||
// credential it was for could be persisted -- the one genuinely
|
|
||||||
// pathological case in the self-registration flow. Fail-closed, same
|
|
||||||
// recovery as DESIGN.md §5's webhook-Secret-loss rule: delete and
|
|
||||||
// recreate this TerdutServer.
|
|
||||||
ReasonBootstrapStateLost = "BootstrapStateLost"
|
|
||||||
// ReasonAdopted: the happy path. A working credential is in hand, the
|
// ReasonAdopted: the happy path. A working credential is in hand, the
|
||||||
// Deployment has a ready replica, and the database (if postgresClusterRef)
|
// Deployment has a ready replica, and the database (if postgresClusterRef)
|
||||||
// resolved.
|
// resolved.
|
||||||
@@ -406,16 +385,9 @@ type TerdutServerStatus struct {
|
|||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
|
|
||||||
// serviceName is the Service this controller created for the
|
// credentialsSecretRef is the operator key this controller generated for
|
||||||
// Deployment, so other objects can reference it without recomputing the
|
// the server (TERDUT_OPERATOR_KEY): pure output, in the TerdutServer's own
|
||||||
// naming convention.
|
// namespace and owned by it, under the data key "token".
|
||||||
// +optional
|
|
||||||
ServiceName string `json:"serviceName,omitempty"`
|
|
||||||
|
|
||||||
// credentialsSecretRef is the generated instance-scoped credential
|
|
||||||
// (DESIGN.md §6) -- pure output, always in the operator's own
|
|
||||||
// namespace, under a fixed data key ("token"). Set only once
|
|
||||||
// Bootstrapped is True.
|
|
||||||
// +optional
|
// +optional
|
||||||
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
|
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,52 +27,15 @@ type TerdutTeamOIDC struct {
|
|||||||
OwnerGroup string `json:"ownerGroup,omitempty"`
|
OwnerGroup string `json:"ownerGroup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerdutTeamInvite requests a standing invite link into this team, minted
|
|
||||||
// with the team's own team-scoped credential — requireTeamOwner already
|
|
||||||
// treats that credential as owner-equivalent for every /invites route
|
|
||||||
// (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is
|
|
||||||
// the real answer to "how does a human ever get a first login on a
|
|
||||||
// password-only, operator-managed install" (terdut-server#23): no signup_mode
|
|
||||||
// flip, no admin token, just a link redeemed the same way anyone else's
|
|
||||||
// invite would be.
|
|
||||||
type TerdutTeamInvite struct {
|
|
||||||
// enabled mints (and keeps refreshed ahead of terdut-server's own fixed
|
|
||||||
// 7-day TTL) an invite link while true. Flipping it back to false
|
|
||||||
// revokes the current one server-side rather than leaving it to expire
|
|
||||||
// on its own.
|
|
||||||
// +optional
|
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
|
||||||
|
|
||||||
// role is what the invite grants: member or owner. Defaults to member —
|
|
||||||
// owner by default would make every invite link a standing
|
|
||||||
// administrative credential for the team, a much bigger blast radius
|
|
||||||
// than "let a human see the queue".
|
|
||||||
// +optional
|
|
||||||
// +kubebuilder:validation:Enum=member;owner
|
|
||||||
// +kubebuilder:default=member
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
|
|
||||||
// maxUses bounds how many times this link may be redeemed before it
|
|
||||||
// stops working, mirroring terdut-server's own 1-100 range
|
|
||||||
// (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for
|
|
||||||
// one specific person, not a standing door.
|
|
||||||
// +optional
|
|
||||||
// +kubebuilder:validation:Minimum=1
|
|
||||||
// +kubebuilder:validation:Maximum=100
|
|
||||||
// +kubebuilder:default=1
|
|
||||||
MaxUses int64 `json:"maxUses,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerdutTeamSpec defines the desired state of TerdutTeam.
|
// TerdutTeamSpec defines the desired state of TerdutTeam.
|
||||||
type TerdutTeamSpec struct {
|
type TerdutTeamSpec struct {
|
||||||
// serverRef names the TerdutServer this team belongs to.
|
// serverRef names the TerdutServer this team belongs to.
|
||||||
// +required
|
// +required
|
||||||
ServerRef TerdutServerRef `json:"serverRef"`
|
ServerRef TerdutServerRef `json:"serverRef"`
|
||||||
|
|
||||||
// displayName is this team's name, both in terdut-server's own data
|
// displayName is this team's name on the server. It can be changed freely:
|
||||||
// (POST /api/teams {"name": ...}) and as the identity POST /api/teams
|
// the team is found by the CR's own identity (<namespace>/<name>, sent as
|
||||||
// and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
|
// external_id), not by this name.
|
||||||
// create rule, via TEAM-LOOKUP.md).
|
|
||||||
// +required
|
// +required
|
||||||
// +kubebuilder:validation:MinLength=1
|
// +kubebuilder:validation:MinLength=1
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
@@ -80,8 +43,107 @@ type TerdutTeamSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
||||||
|
|
||||||
|
// escalation is this team's escalation ladder. Omitted, the team has none
|
||||||
|
// (the server's plain reminder behaviour applies).
|
||||||
// +optional
|
// +optional
|
||||||
Invite TerdutTeamInvite `json:"invite,omitempty"`
|
Escalation *EscalationSpec `json:"escalation,omitempty"`
|
||||||
|
|
||||||
|
// deadmanSwitches are this team's dead man's switches, by name. Switches on
|
||||||
|
// the server that are not listed here are removed: in operator mode this
|
||||||
|
// list is the whole truth.
|
||||||
|
// +listType=map
|
||||||
|
// +listMapKey=name
|
||||||
|
// +kubebuilder:validation:MaxItems=50
|
||||||
|
// +optional
|
||||||
|
DeadmanSwitches []DeadmanSwitchSpec `json:"deadmanSwitches,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationTargetKind is who one rung of the ladder pages.
|
||||||
|
// +kubebuilder:validation:Enum=oncall;user
|
||||||
|
type EscalationTargetKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EscalationTargetOncall EscalationTargetKind = "oncall"
|
||||||
|
EscalationTargetUser EscalationTargetKind = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EscalationTarget is one page within a level. username is required iff kind is
|
||||||
|
// "user".
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self.kind != 'user' || has(self.username)",message="username is required when kind is user"
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self.kind != 'oncall' || !has(self.username)",message="username must not be set when kind is oncall"
|
||||||
|
type EscalationTarget struct {
|
||||||
|
// +required
|
||||||
|
Kind EscalationTargetKind `json:"kind"`
|
||||||
|
// +kubebuilder:validation:MaxLength=255
|
||||||
|
// +optional
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationLevel is one rung of the ladder: how long to wait, and who to page
|
||||||
|
// if nobody has acknowledged by then.
|
||||||
|
type EscalationLevel struct {
|
||||||
|
// timeout is a Go duration string, e.g. "5m".
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MaxLength=32
|
||||||
|
// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$`
|
||||||
|
Timeout string `json:"timeout"`
|
||||||
|
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MinItems=1
|
||||||
|
// +kubebuilder:validation:MaxItems=20
|
||||||
|
Targets []EscalationTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationSpec is a team's escalation ladder.
|
||||||
|
type EscalationSpec struct {
|
||||||
|
// +kubebuilder:validation:Minimum=0
|
||||||
|
// +kubebuilder:validation:Maximum=10
|
||||||
|
// +optional
|
||||||
|
RepeatCount int64 `json:"repeatCount,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
FallbackTopic string `json:"fallbackTopic,omitempty"`
|
||||||
|
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MinItems=1
|
||||||
|
// +kubebuilder:validation:MaxItems=10
|
||||||
|
Levels []EscalationLevel `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadmanSwitchSpec is one dead man's switch: the absence of an alert matching
|
||||||
|
// matcher for longer than timeout opens an incident.
|
||||||
|
type DeadmanSwitchSpec struct {
|
||||||
|
// name identifies the switch within the team.
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MinLength=1
|
||||||
|
// +kubebuilder:validation:MaxLength=100
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// matcher names the alerts this switch watches, e.g.
|
||||||
|
// "alertname=Watchdog,cluster=prod". One matcher per switch.
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MinLength=1
|
||||||
|
// +kubebuilder:validation:MaxLength=512
|
||||||
|
// +kubebuilder:validation:XValidation:rule="!self.contains(';')",message="one matcher per switch: add another entry instead of separating with ;"
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
|
||||||
|
// timeout is a Go duration string, e.g. "15m".
|
||||||
|
// +required
|
||||||
|
// +kubebuilder:validation:MaxLength=32
|
||||||
|
// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$`
|
||||||
|
Timeout string `json:"timeout"`
|
||||||
|
|
||||||
|
// +kubebuilder:validation:Enum=critical;error;warning;info
|
||||||
|
// +kubebuilder:default=critical
|
||||||
|
// +optional
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerdutTeamRef names the TerdutTeam a TerdutAlertSource belongs to. Always
|
||||||
|
// same-namespace as the CR itself.
|
||||||
|
type TerdutTeamRef struct {
|
||||||
|
// +kubebuilder:validation:MinLength=1
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition reasons this controller sets.
|
// Condition reasons this controller sets.
|
||||||
@@ -97,21 +159,28 @@ const (
|
|||||||
// DESIGN.md §5's "every child requeues with backoff, no cross-
|
// DESIGN.md §5's "every child requeues with backoff, no cross-
|
||||||
// controller RPC" rule.
|
// controller RPC" rule.
|
||||||
ReasonWaitingForServer = "WaitingForServer"
|
ReasonWaitingForServer = "WaitingForServer"
|
||||||
|
// ReasonTeamNameTaken: the server already has a team with spec.displayName
|
||||||
|
// that belongs to a different TerdutTeam (or to a person). The name is global
|
||||||
|
// to the server, so the operator waits for one of them to change.
|
||||||
|
ReasonTeamNameTaken = "TeamNameTaken"
|
||||||
|
// ReasonUnknownUser: an escalation target's username matches no user on the
|
||||||
|
// server (yet).
|
||||||
|
ReasonUnknownUser = "UnknownUser"
|
||||||
|
// ReasonInvalidSpec: a duration in the spec does not parse.
|
||||||
|
ReasonInvalidSpec = "InvalidSpec"
|
||||||
// ReasonTeamAdopted: the happy path.
|
// ReasonTeamAdopted: the happy path.
|
||||||
ReasonTeamAdopted = "Adopted"
|
ReasonTeamAdopted = "Adopted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Condition reasons for spec.invite reconciliation (TerdutTeamInvite). Not
|
// Condition reasons shared by the resources that hang off a TerdutTeam
|
||||||
// surfaced on the Ready condition itself — an invite is a convenience, not
|
// (currently TerdutAlertSource).
|
||||||
// a dependency anything else in this team's own readiness waits on — but
|
|
||||||
// recorded as Events and readable via `kubectl describe`.
|
|
||||||
const (
|
const (
|
||||||
// ReasonInviteMinted: spec.invite.enabled is true and status.inviteSecretRef
|
// ReasonTeamRefNotFound: spec.teamRef names no TerdutTeam (yet).
|
||||||
// is populated and live.
|
ReasonTeamRefNotFound = "TeamRefNotFound"
|
||||||
ReasonInviteMinted = "InviteMinted"
|
// ReasonWaitingForTeam: the referenced TerdutTeam exists but is not Ready.
|
||||||
// ReasonInviteRevoked: spec.invite.enabled flipped back to false and the
|
ReasonWaitingForTeam = "WaitingForTeam"
|
||||||
// server-side invite was revoked (or there was nothing to revoke).
|
// ReasonChildAdopted: the happy path.
|
||||||
ReasonInviteRevoked = "InviteRevoked"
|
ReasonChildAdopted = "Adopted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TerdutTeamStatus defines the observed state of TerdutTeam.
|
// TerdutTeamStatus defines the observed state of TerdutTeam.
|
||||||
@@ -126,30 +195,6 @@ type TerdutTeamStatus struct {
|
|||||||
// +optional
|
// +optional
|
||||||
TeamID int64 `json:"teamID,omitempty"`
|
TeamID int64 `json:"teamID,omitempty"`
|
||||||
|
|
||||||
// credentialsSecretRef is this team's own scoped credential
|
|
||||||
// (DESIGN.md §6 point 3) -- pure output, always in the operator's own
|
|
||||||
// namespace, under a fixed data key ("token").
|
|
||||||
// +optional
|
|
||||||
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
|
|
||||||
|
|
||||||
// serverEndpoint is the resolved TerdutServer's base URL, resolved once
|
|
||||||
// here so no child controller (TerdutEscalationRule, TerdutDeadmanSwitch,
|
|
||||||
// TerdutAlertSource) ever needs its own RBAC on terdutservers just to
|
|
||||||
// find out where to send a request (DESIGN.md §5).
|
|
||||||
// +optional
|
|
||||||
ServerEndpoint string `json:"serverEndpoint,omitempty"`
|
|
||||||
|
|
||||||
// inviteSecretRef is this team's current invite link, if spec.invite.enabled.
|
|
||||||
// Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN
|
|
||||||
// namespace, not the operator's: an invite is bounded, limited-use, and
|
|
||||||
// meant for this namespace's own human operators to read and hand out,
|
|
||||||
// not a durable high-privilege credential — same shape as
|
|
||||||
// TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's
|
|
||||||
// cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled
|
|
||||||
// is false or unset.
|
|
||||||
// +optional
|
|
||||||
InviteSecretRef *LocalSecretRef `json:"inviteSecretRef,omitempty"`
|
|
||||||
|
|
||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,16 +73,16 @@ func (in *DatabaseSpec) DeepCopy() *DatabaseSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *DeadmanSpec) DeepCopyInto(out *DeadmanSpec) {
|
func (in *DeadmanSwitchSpec) DeepCopyInto(out *DeadmanSwitchSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeadmanSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeadmanSwitchSpec.
|
||||||
func (in *DeadmanSpec) DeepCopy() *DeadmanSpec {
|
func (in *DeadmanSwitchSpec) DeepCopy() *DeadmanSwitchSpec {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := new(DeadmanSpec)
|
out := new(DeadmanSwitchSpec)
|
||||||
in.DeepCopyInto(out)
|
in.DeepCopyInto(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -107,6 +107,28 @@ func (in *EscalationLevel) DeepCopy() *EscalationLevel {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *EscalationSpec) DeepCopyInto(out *EscalationSpec) {
|
||||||
|
*out = *in
|
||||||
|
if in.Levels != nil {
|
||||||
|
in, out := &in.Levels, &out.Levels
|
||||||
|
*out = make([]EscalationLevel, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EscalationSpec.
|
||||||
|
func (in *EscalationSpec) DeepCopy() *EscalationSpec {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(EscalationSpec)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *EscalationTarget) DeepCopyInto(out *EscalationTarget) {
|
func (in *EscalationTarget) DeepCopyInto(out *EscalationTarget) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -481,207 +503,6 @@ func (in *TerdutAlertSourceStatus) DeepCopy() *TerdutAlertSourceStatus {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutDeadmanSwitch) DeepCopyInto(out *TerdutDeadmanSwitch) {
|
|
||||||
*out = *in
|
|
||||||
out.TypeMeta = in.TypeMeta
|
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
|
||||||
out.Spec = in.Spec
|
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitch.
|
|
||||||
func (in *TerdutDeadmanSwitch) DeepCopy() *TerdutDeadmanSwitch {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutDeadmanSwitch)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
|
||||||
func (in *TerdutDeadmanSwitch) DeepCopyObject() runtime.Object {
|
|
||||||
if c := in.DeepCopy(); c != nil {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutDeadmanSwitchList) DeepCopyInto(out *TerdutDeadmanSwitchList) {
|
|
||||||
*out = *in
|
|
||||||
out.TypeMeta = in.TypeMeta
|
|
||||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
|
||||||
if in.Items != nil {
|
|
||||||
in, out := &in.Items, &out.Items
|
|
||||||
*out = make([]TerdutDeadmanSwitch, len(*in))
|
|
||||||
for i := range *in {
|
|
||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchList.
|
|
||||||
func (in *TerdutDeadmanSwitchList) DeepCopy() *TerdutDeadmanSwitchList {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutDeadmanSwitchList)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
|
||||||
func (in *TerdutDeadmanSwitchList) DeepCopyObject() runtime.Object {
|
|
||||||
if c := in.DeepCopy(); c != nil {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutDeadmanSwitchSpec) DeepCopyInto(out *TerdutDeadmanSwitchSpec) {
|
|
||||||
*out = *in
|
|
||||||
out.TeamRef = in.TeamRef
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchSpec.
|
|
||||||
func (in *TerdutDeadmanSwitchSpec) DeepCopy() *TerdutDeadmanSwitchSpec {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutDeadmanSwitchSpec)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutDeadmanSwitchStatus) DeepCopyInto(out *TerdutDeadmanSwitchStatus) {
|
|
||||||
*out = *in
|
|
||||||
if in.Conditions != nil {
|
|
||||||
in, out := &in.Conditions, &out.Conditions
|
|
||||||
*out = make([]v1.Condition, len(*in))
|
|
||||||
for i := range *in {
|
|
||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchStatus.
|
|
||||||
func (in *TerdutDeadmanSwitchStatus) DeepCopy() *TerdutDeadmanSwitchStatus {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutDeadmanSwitchStatus)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutEscalationRule) DeepCopyInto(out *TerdutEscalationRule) {
|
|
||||||
*out = *in
|
|
||||||
out.TypeMeta = in.TypeMeta
|
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
|
||||||
in.Spec.DeepCopyInto(&out.Spec)
|
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRule.
|
|
||||||
func (in *TerdutEscalationRule) DeepCopy() *TerdutEscalationRule {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutEscalationRule)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
|
||||||
func (in *TerdutEscalationRule) DeepCopyObject() runtime.Object {
|
|
||||||
if c := in.DeepCopy(); c != nil {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutEscalationRuleList) DeepCopyInto(out *TerdutEscalationRuleList) {
|
|
||||||
*out = *in
|
|
||||||
out.TypeMeta = in.TypeMeta
|
|
||||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
|
||||||
if in.Items != nil {
|
|
||||||
in, out := &in.Items, &out.Items
|
|
||||||
*out = make([]TerdutEscalationRule, len(*in))
|
|
||||||
for i := range *in {
|
|
||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleList.
|
|
||||||
func (in *TerdutEscalationRuleList) DeepCopy() *TerdutEscalationRuleList {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutEscalationRuleList)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
|
||||||
func (in *TerdutEscalationRuleList) DeepCopyObject() runtime.Object {
|
|
||||||
if c := in.DeepCopy(); c != nil {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutEscalationRuleSpec) DeepCopyInto(out *TerdutEscalationRuleSpec) {
|
|
||||||
*out = *in
|
|
||||||
out.TeamRef = in.TeamRef
|
|
||||||
if in.Levels != nil {
|
|
||||||
in, out := &in.Levels, &out.Levels
|
|
||||||
*out = make([]EscalationLevel, len(*in))
|
|
||||||
for i := range *in {
|
|
||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleSpec.
|
|
||||||
func (in *TerdutEscalationRuleSpec) DeepCopy() *TerdutEscalationRuleSpec {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutEscalationRuleSpec)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutEscalationRuleStatus) DeepCopyInto(out *TerdutEscalationRuleStatus) {
|
|
||||||
*out = *in
|
|
||||||
if in.Conditions != nil {
|
|
||||||
in, out := &in.Conditions, &out.Conditions
|
|
||||||
*out = make([]v1.Condition, len(*in))
|
|
||||||
for i := range *in {
|
|
||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleStatus.
|
|
||||||
func (in *TerdutEscalationRuleStatus) DeepCopy() *TerdutEscalationRuleStatus {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutEscalationRuleStatus)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
|
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -763,7 +584,6 @@ func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
|
|||||||
out.Networking = in.Networking
|
out.Networking = in.Networking
|
||||||
in.Database.DeepCopyInto(&out.Database)
|
in.Database.DeepCopyInto(&out.Database)
|
||||||
out.Sweeper = in.Sweeper
|
out.Sweeper = in.Sweeper
|
||||||
out.Deadman = in.Deadman
|
|
||||||
in.Notify.DeepCopyInto(&out.Notify)
|
in.Notify.DeepCopyInto(&out.Notify)
|
||||||
in.OIDC.DeepCopyInto(&out.OIDC)
|
in.OIDC.DeepCopyInto(&out.OIDC)
|
||||||
in.AllowedTeams.DeepCopyInto(&out.AllowedTeams)
|
in.AllowedTeams.DeepCopyInto(&out.AllowedTeams)
|
||||||
@@ -812,7 +632,7 @@ func (in *TerdutTeam) DeepCopyInto(out *TerdutTeam) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,21 +654,6 @@ func (in *TerdutTeam) DeepCopyObject() runtime.Object {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
|
||||||
func (in *TerdutTeamInvite) DeepCopyInto(out *TerdutTeamInvite) {
|
|
||||||
*out = *in
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamInvite.
|
|
||||||
func (in *TerdutTeamInvite) DeepCopy() *TerdutTeamInvite {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := new(TerdutTeamInvite)
|
|
||||||
in.DeepCopyInto(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -916,7 +721,16 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.ServerRef = in.ServerRef
|
out.ServerRef = in.ServerRef
|
||||||
out.OIDC = in.OIDC
|
out.OIDC = in.OIDC
|
||||||
out.Invite = in.Invite
|
if in.Escalation != nil {
|
||||||
|
in, out := &in.Escalation, &out.Escalation
|
||||||
|
*out = new(EscalationSpec)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.DeadmanSwitches != nil {
|
||||||
|
in, out := &in.DeadmanSwitches, &out.DeadmanSwitches
|
||||||
|
*out = make([]DeadmanSwitchSpec, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
|
||||||
@@ -939,16 +753,6 @@ func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
|
|||||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in.CredentialsSecretRef != nil {
|
|
||||||
in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef
|
|
||||||
*out = new(SecretKeyRef)
|
|
||||||
**out = **in
|
|
||||||
}
|
|
||||||
if in.InviteSecretRef != nil {
|
|
||||||
in, out := &in.InviteSecretRef, &out.InviteSecretRef
|
|
||||||
*out = new(LocalSecretRef)
|
|
||||||
**out = **in
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ type: application
|
|||||||
# These fields decide nothing: `make helm-package` passes --version and
|
# These fields decide nothing: `make helm-package` passes --version and
|
||||||
# --app-version from the release tag (same reasoning as terdut-server's own
|
# --app-version from the release tag (same reasoning as terdut-server's own
|
||||||
# chart). They're for whoever reads the tree before a tag exists.
|
# chart). They're for whoever reads the tree before a tag exists.
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
appVersion: "v0.4.0"
|
appVersion: "v0.5.0"
|
||||||
|
|
||||||
keywords:
|
keywords:
|
||||||
- kubernetes
|
- kubernetes
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
{{- if .Values.crd.enabled }}
|
|
||||||
apiVersion: apiextensions.k8s.io/v1
|
|
||||||
kind: CustomResourceDefinition
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
{{- if .Values.crd.keep }}
|
|
||||||
"helm.sh/resource-policy": keep
|
|
||||||
{{- end }}
|
|
||||||
controller-gen.kubebuilder.io/version: v0.22.0
|
|
||||||
name: terdutdeadmanswitches.terdut.ryuvia.com
|
|
||||||
spec:
|
|
||||||
group: terdut.ryuvia.com
|
|
||||||
names:
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
listKind: TerdutDeadmanSwitchList
|
|
||||||
plural: terdutdeadmanswitches
|
|
||||||
singular: terdutdeadmanswitch
|
|
||||||
scope: Namespaced
|
|
||||||
versions:
|
|
||||||
- additionalPrinterColumns:
|
|
||||||
- jsonPath: .spec.teamRef.name
|
|
||||||
name: Team
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.switchID
|
|
||||||
name: SwitchID
|
|
||||||
type: integer
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
|
||||||
name: Ready
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
|
||||||
name: Reason
|
|
||||||
type: string
|
|
||||||
name: v1alpha1
|
|
||||||
schema:
|
|
||||||
openAPIV3Schema:
|
|
||||||
description: TerdutDeadmanSwitch is the Schema for the terdutdeadmanswitches
|
|
||||||
API
|
|
||||||
properties:
|
|
||||||
apiVersion:
|
|
||||||
description: |-
|
|
||||||
APIVersion defines the versioned schema of this representation of an object.
|
|
||||||
Servers should convert recognized schemas to the latest internal value, and
|
|
||||||
may reject unrecognized values.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
description: |-
|
|
||||||
Kind is a string value representing the REST resource this object represents.
|
|
||||||
Servers may infer this from the endpoint the client submits requests to.
|
|
||||||
Cannot be updated.
|
|
||||||
In CamelCase.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
|
||||||
type: string
|
|
||||||
metadata:
|
|
||||||
type: object
|
|
||||||
spec:
|
|
||||||
description: spec defines the desired state of TerdutDeadmanSwitch
|
|
||||||
properties:
|
|
||||||
matcher:
|
|
||||||
description: |-
|
|
||||||
matcher names the alerts this switch watches, e.g.
|
|
||||||
"alertname=Watchdog,cluster=prod". One matcher per switch -- add
|
|
||||||
another TerdutDeadmanSwitch instead of separating with ";"
|
|
||||||
(terdut-server's own restriction, mirrored here so a bad spec is
|
|
||||||
rejected at apply time).
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
x-kubernetes-validations:
|
|
||||||
- message: 'one matcher per switch: add another TerdutDeadmanSwitch
|
|
||||||
instead of separating with ;'
|
|
||||||
rule: '!self.contains('';'')'
|
|
||||||
name:
|
|
||||||
description: |-
|
|
||||||
name is optional, same as the API: left empty, terdut-server derives
|
|
||||||
it from matcher's own canonical form, and that's what the
|
|
||||||
idempotent-create lookup matches against too.
|
|
||||||
type: string
|
|
||||||
severity:
|
|
||||||
default: critical
|
|
||||||
enum:
|
|
||||||
- critical
|
|
||||||
- error
|
|
||||||
- warning
|
|
||||||
- info
|
|
||||||
type: string
|
|
||||||
teamRef:
|
|
||||||
description: |-
|
|
||||||
TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
|
||||||
same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
|
||||||
crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
timeout:
|
|
||||||
description: timeout is a Go duration string, e.g. "15m".
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- matcher
|
|
||||||
- teamRef
|
|
||||||
- timeout
|
|
||||||
type: object
|
|
||||||
status:
|
|
||||||
description: status defines the observed state of TerdutDeadmanSwitch
|
|
||||||
properties:
|
|
||||||
conditions:
|
|
||||||
items:
|
|
||||||
description: Condition contains details for one aspect of the current
|
|
||||||
state of this API Resource.
|
|
||||||
properties:
|
|
||||||
lastTransitionTime:
|
|
||||||
description: |-
|
|
||||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
||||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
description: |-
|
|
||||||
message is a human readable message indicating details about the transition.
|
|
||||||
This may be an empty string.
|
|
||||||
maxLength: 32768
|
|
||||||
type: string
|
|
||||||
observedGeneration:
|
|
||||||
description: |-
|
|
||||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
||||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
|
||||||
with respect to the current state of the instance.
|
|
||||||
format: int64
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
reason:
|
|
||||||
description: |-
|
|
||||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
||||||
Producers of specific condition types may define expected values and meanings for this field,
|
|
||||||
and whether the values are considered a guaranteed API.
|
|
||||||
The value should be a CamelCase string.
|
|
||||||
This field may not be empty.
|
|
||||||
maxLength: 1024
|
|
||||||
minLength: 1
|
|
||||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
description: status of the condition, one of True, False, Unknown.
|
|
||||||
enum:
|
|
||||||
- "True"
|
|
||||||
- "False"
|
|
||||||
- Unknown
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
||||||
maxLength: 316
|
|
||||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- lastTransitionTime
|
|
||||||
- message
|
|
||||||
- reason
|
|
||||||
- status
|
|
||||||
- type
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
x-kubernetes-list-map-keys:
|
|
||||||
- type
|
|
||||||
x-kubernetes-list-type: map
|
|
||||||
observedGeneration:
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
switchID:
|
|
||||||
description: switchID is the server-side id.
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- spec
|
|
||||||
type: object
|
|
||||||
served: true
|
|
||||||
storage: true
|
|
||||||
subresources:
|
|
||||||
status: {}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
{{- if .Values.crd.enabled }}
|
|
||||||
apiVersion: apiextensions.k8s.io/v1
|
|
||||||
kind: CustomResourceDefinition
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
{{- if .Values.crd.keep }}
|
|
||||||
"helm.sh/resource-policy": keep
|
|
||||||
{{- end }}
|
|
||||||
controller-gen.kubebuilder.io/version: v0.22.0
|
|
||||||
name: terdutescalationrules.terdut.ryuvia.com
|
|
||||||
spec:
|
|
||||||
group: terdut.ryuvia.com
|
|
||||||
names:
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
listKind: TerdutEscalationRuleList
|
|
||||||
plural: terdutescalationrules
|
|
||||||
singular: terdutescalationrule
|
|
||||||
scope: Namespaced
|
|
||||||
versions:
|
|
||||||
- additionalPrinterColumns:
|
|
||||||
- jsonPath: .spec.teamRef.name
|
|
||||||
name: Team
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
|
||||||
name: Ready
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
|
||||||
name: Reason
|
|
||||||
type: string
|
|
||||||
name: v1alpha1
|
|
||||||
schema:
|
|
||||||
openAPIV3Schema:
|
|
||||||
description: TerdutEscalationRule is the Schema for the terdutescalationrules
|
|
||||||
API
|
|
||||||
properties:
|
|
||||||
apiVersion:
|
|
||||||
description: |-
|
|
||||||
APIVersion defines the versioned schema of this representation of an object.
|
|
||||||
Servers should convert recognized schemas to the latest internal value, and
|
|
||||||
may reject unrecognized values.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
description: |-
|
|
||||||
Kind is a string value representing the REST resource this object represents.
|
|
||||||
Servers may infer this from the endpoint the client submits requests to.
|
|
||||||
Cannot be updated.
|
|
||||||
In CamelCase.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
|
||||||
type: string
|
|
||||||
metadata:
|
|
||||||
type: object
|
|
||||||
spec:
|
|
||||||
description: spec defines the desired state of TerdutEscalationRule
|
|
||||||
properties:
|
|
||||||
fallbackTopic:
|
|
||||||
type: string
|
|
||||||
levels:
|
|
||||||
items:
|
|
||||||
description: |-
|
|
||||||
EscalationLevel is one rung of the ladder: how long to wait, and who to
|
|
||||||
page if nobody's acknowledged by then.
|
|
||||||
properties:
|
|
||||||
targets:
|
|
||||||
items:
|
|
||||||
description: |-
|
|
||||||
EscalationTarget is one page within a level. username is required iff
|
|
||||||
kind is "user" (terdut-server's own validation, internal/api/escalation.go's
|
|
||||||
handleSetEscalation -- mirrored here as a CEL rule so a bad spec is
|
|
||||||
rejected at apply time, not discovered on the next failed PUT).
|
|
||||||
properties:
|
|
||||||
kind:
|
|
||||||
description: EscalationTargetKind is who one rung of the
|
|
||||||
ladder pages.
|
|
||||||
enum:
|
|
||||||
- oncall
|
|
||||||
- user
|
|
||||||
type: string
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- kind
|
|
||||||
type: object
|
|
||||||
x-kubernetes-validations:
|
|
||||||
- message: username is required when kind is user
|
|
||||||
rule: self.kind != 'user' || has(self.username)
|
|
||||||
- message: username must not be set when kind is oncall
|
|
||||||
rule: self.kind != 'oncall' || !has(self.username)
|
|
||||||
minItems: 1
|
|
||||||
type: array
|
|
||||||
timeout:
|
|
||||||
description: timeout is a Go duration string, e.g. "5m".
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- targets
|
|
||||||
- timeout
|
|
||||||
type: object
|
|
||||||
minItems: 1
|
|
||||||
type: array
|
|
||||||
repeatCount:
|
|
||||||
format: int64
|
|
||||||
maximum: 10
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
teamRef:
|
|
||||||
description: |-
|
|
||||||
TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
|
||||||
same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
|
||||||
crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- levels
|
|
||||||
- teamRef
|
|
||||||
type: object
|
|
||||||
status:
|
|
||||||
description: status defines the observed state of TerdutEscalationRule
|
|
||||||
properties:
|
|
||||||
conditions:
|
|
||||||
items:
|
|
||||||
description: Condition contains details for one aspect of the current
|
|
||||||
state of this API Resource.
|
|
||||||
properties:
|
|
||||||
lastTransitionTime:
|
|
||||||
description: |-
|
|
||||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
||||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
description: |-
|
|
||||||
message is a human readable message indicating details about the transition.
|
|
||||||
This may be an empty string.
|
|
||||||
maxLength: 32768
|
|
||||||
type: string
|
|
||||||
observedGeneration:
|
|
||||||
description: |-
|
|
||||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
||||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
|
||||||
with respect to the current state of the instance.
|
|
||||||
format: int64
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
reason:
|
|
||||||
description: |-
|
|
||||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
||||||
Producers of specific condition types may define expected values and meanings for this field,
|
|
||||||
and whether the values are considered a guaranteed API.
|
|
||||||
The value should be a CamelCase string.
|
|
||||||
This field may not be empty.
|
|
||||||
maxLength: 1024
|
|
||||||
minLength: 1
|
|
||||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
description: status of the condition, one of True, False, Unknown.
|
|
||||||
enum:
|
|
||||||
- "True"
|
|
||||||
- "False"
|
|
||||||
- Unknown
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
||||||
maxLength: 316
|
|
||||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- lastTransitionTime
|
|
||||||
- message
|
|
||||||
- reason
|
|
||||||
- status
|
|
||||||
- type
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
x-kubernetes-list-map-keys:
|
|
||||||
- type
|
|
||||||
x-kubernetes-list-type: map
|
|
||||||
observedGeneration:
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- spec
|
|
||||||
type: object
|
|
||||||
served: true
|
|
||||||
storage: true
|
|
||||||
subresources:
|
|
||||||
status: {}
|
|
||||||
{{- end }}
|
|
||||||
@@ -178,19 +178,6 @@ spec:
|
|||||||
- message: exactly one of dsn or postgresClusterRef must be set
|
- message: exactly one of dsn or postgresClusterRef must be set
|
||||||
rule: '(has(self.dsn) ? 1 : 0) + (has(self.postgresClusterRef) ?
|
rule: '(has(self.dsn) ? 1 : 0) + (has(self.postgresClusterRef) ?
|
||||||
1 : 0) == 1'
|
1 : 0) == 1'
|
||||||
deadman:
|
|
||||||
description: |-
|
|
||||||
DeadmanSpec controls dead man's switch alerts. Matchers/Timeout/Severity
|
|
||||||
map straight to TERDUT_DEADMAN_MATCHERS/TERDUT_DEADMAN_TIMEOUT/
|
|
||||||
TERDUT_DEADMAN_SEVERITY.
|
|
||||||
properties:
|
|
||||||
matchers:
|
|
||||||
type: string
|
|
||||||
severity:
|
|
||||||
type: string
|
|
||||||
timeout:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
image:
|
image:
|
||||||
description: ImageSpec is the terdut-server image to run.
|
description: ImageSpec is the terdut-server image to run.
|
||||||
properties:
|
properties:
|
||||||
@@ -262,12 +249,7 @@ spec:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
oidc:
|
oidc:
|
||||||
description: |-
|
description: OIDCSpec controls single sign-on.
|
||||||
OIDCSpec controls single sign-on. Fields the chart also exposes but
|
|
||||||
DESIGN.md's spec doesn't (usernameClaim, emailClaim, groupsClaim,
|
|
||||||
trustEmail) use terdut-server's own defaults
|
|
||||||
(preferred_username/email/groups/false) rather than being added here
|
|
||||||
speculatively.
|
|
||||||
properties:
|
properties:
|
||||||
adminGroup:
|
adminGroup:
|
||||||
type: string
|
type: string
|
||||||
@@ -279,12 +261,11 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
clientSecretRef:
|
clientSecretRef:
|
||||||
description: |-
|
description: |-
|
||||||
SecretKeyRef names one data key inside a Secret. Every use of this type in
|
SecretKeyRef names one data key inside a Secret in the TerdutServer's own
|
||||||
TerdutServerSpec resolves in the TerdutServer's own namespace (it's wired
|
namespace. Every use of this type is wired into the Deployment's pod spec as
|
||||||
straight into the Deployment's pod spec as a secretKeyRef env source,
|
a secretKeyRef env source, which Kubernetes only allows same-namespace --
|
||||||
which Kubernetes itself only allows same-namespace) -- unlike the
|
including status.credentialsSecretRef, the operator key the controller
|
||||||
generated credentials Secret (DESIGN.md §6), which always lives in the
|
generates there.
|
||||||
operator's own namespace and is never referenced through this type.
|
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
description: key is the data key inside the Secret holding
|
description: key is the data key inside the Secret holding
|
||||||
@@ -299,8 +280,14 @@ spec:
|
|||||||
- key
|
- key
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
|
emailClaim:
|
||||||
|
default: email
|
||||||
|
type: string
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
groupsClaim:
|
||||||
|
default: groups
|
||||||
|
type: string
|
||||||
issuer:
|
issuer:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
@@ -312,6 +299,17 @@ spec:
|
|||||||
sessionMaxAge:
|
sessionMaxAge:
|
||||||
default: 12h
|
default: 12h
|
||||||
type: string
|
type: string
|
||||||
|
trustEmail:
|
||||||
|
description: |-
|
||||||
|
trustEmail links a sign-in to an existing local user by email even when
|
||||||
|
the provider does not vouch the address is verified (Authentik reports
|
||||||
|
email_verified false unless told otherwise).
|
||||||
|
type: boolean
|
||||||
|
usernameClaim:
|
||||||
|
default: preferred_username
|
||||||
|
description: usernameClaim, emailClaim and groupsClaim name the
|
||||||
|
ID token claims read.
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
passwordLogin:
|
passwordLogin:
|
||||||
default: true
|
default: true
|
||||||
@@ -4399,10 +4397,9 @@ spec:
|
|||||||
x-kubernetes-list-type: map
|
x-kubernetes-list-type: map
|
||||||
credentialsSecretRef:
|
credentialsSecretRef:
|
||||||
description: |-
|
description: |-
|
||||||
credentialsSecretRef is the generated instance-scoped credential
|
credentialsSecretRef is the operator key this controller generated for
|
||||||
(DESIGN.md §6) -- pure output, always in the operator's own
|
the server (TERDUT_OPERATOR_KEY): pure output, in the TerdutServer's own
|
||||||
namespace, under a fixed data key ("token"). Set only once
|
namespace and owned by it, under the data key "token".
|
||||||
Bootstrapped is True.
|
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
description: key is the data key inside the Secret holding the
|
description: key is the data key inside the Secret holding the
|
||||||
@@ -4424,12 +4421,6 @@ spec:
|
|||||||
tells "applied" from "seen" (DESIGN.md §7).
|
tells "applied" from "seen" (DESIGN.md §7).
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
serviceName:
|
|
||||||
description: |-
|
|
||||||
serviceName is the Service this controller created for the
|
|
||||||
Deployment, so other objects can reference it without recomputing the
|
|
||||||
naming convention.
|
|
||||||
type: string
|
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- spec
|
- spec
|
||||||
|
|||||||
@@ -55,54 +55,121 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
description: spec defines the desired state of TerdutTeam
|
description: spec defines the desired state of TerdutTeam
|
||||||
properties:
|
properties:
|
||||||
|
deadmanSwitches:
|
||||||
|
description: |-
|
||||||
|
deadmanSwitches are this team's dead man's switches, by name. Switches on
|
||||||
|
the server that are not listed here are removed: in operator mode this
|
||||||
|
list is the whole truth.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
DeadmanSwitchSpec is one dead man's switch: the absence of an alert matching
|
||||||
|
matcher for longer than timeout opens an incident.
|
||||||
|
properties:
|
||||||
|
matcher:
|
||||||
|
description: |-
|
||||||
|
matcher names the alerts this switch watches, e.g.
|
||||||
|
"alertname=Watchdog,cluster=prod". One matcher per switch.
|
||||||
|
maxLength: 512
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: 'one matcher per switch: add another entry instead
|
||||||
|
of separating with ;'
|
||||||
|
rule: '!self.contains('';'')'
|
||||||
|
name:
|
||||||
|
description: name identifies the switch within the team.
|
||||||
|
maxLength: 100
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
default: critical
|
||||||
|
enum:
|
||||||
|
- critical
|
||||||
|
- error
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
type: string
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "15m".
|
||||||
|
maxLength: 32
|
||||||
|
pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- matcher
|
||||||
|
- name
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
maxItems: 50
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- name
|
||||||
|
x-kubernetes-list-type: map
|
||||||
displayName:
|
displayName:
|
||||||
description: |-
|
description: |-
|
||||||
displayName is this team's name, both in terdut-server's own data
|
displayName is this team's name on the server. It can be changed freely:
|
||||||
(POST /api/teams {"name": ...}) and as the identity POST /api/teams
|
the team is found by the CR's own identity (<namespace>/<name>, sent as
|
||||||
and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
|
external_id), not by this name.
|
||||||
create rule, via TEAM-LOOKUP.md).
|
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
type: string
|
||||||
invite:
|
escalation:
|
||||||
description: |-
|
description: |-
|
||||||
TerdutTeamInvite requests a standing invite link into this team, minted
|
escalation is this team's escalation ladder. Omitted, the team has none
|
||||||
with the team's own team-scoped credential — requireTeamOwner already
|
(the server's plain reminder behaviour applies).
|
||||||
treats that credential as owner-equivalent for every /invites route
|
|
||||||
(ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is
|
|
||||||
the real answer to "how does a human ever get a first login on a
|
|
||||||
password-only, operator-managed install" (terdut-server#23): no signup_mode
|
|
||||||
flip, no admin token, just a link redeemed the same way anyone else's
|
|
||||||
invite would be.
|
|
||||||
properties:
|
properties:
|
||||||
enabled:
|
fallbackTopic:
|
||||||
description: |-
|
|
||||||
enabled mints (and keeps refreshed ahead of terdut-server's own fixed
|
|
||||||
7-day TTL) an invite link while true. Flipping it back to false
|
|
||||||
revokes the current one server-side rather than leaving it to expire
|
|
||||||
on its own.
|
|
||||||
type: boolean
|
|
||||||
maxUses:
|
|
||||||
default: 1
|
|
||||||
description: |-
|
|
||||||
maxUses bounds how many times this link may be redeemed before it
|
|
||||||
stops working, mirroring terdut-server's own 1-100 range
|
|
||||||
(POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for
|
|
||||||
one specific person, not a standing door.
|
|
||||||
format: int64
|
|
||||||
maximum: 100
|
|
||||||
minimum: 1
|
|
||||||
type: integer
|
|
||||||
role:
|
|
||||||
default: member
|
|
||||||
description: |-
|
|
||||||
role is what the invite grants: member or owner. Defaults to member —
|
|
||||||
owner by default would make every invite link a standing
|
|
||||||
administrative credential for the team, a much bigger blast radius
|
|
||||||
than "let a human see the queue".
|
|
||||||
enum:
|
|
||||||
- member
|
|
||||||
- owner
|
|
||||||
type: string
|
type: string
|
||||||
|
levels:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EscalationLevel is one rung of the ladder: how long to wait, and who to page
|
||||||
|
if nobody has acknowledged by then.
|
||||||
|
properties:
|
||||||
|
targets:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EscalationTarget is one page within a level. username is required iff kind is
|
||||||
|
"user".
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
description: EscalationTargetKind is who one rung
|
||||||
|
of the ladder pages.
|
||||||
|
enum:
|
||||||
|
- oncall
|
||||||
|
- user
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
maxLength: 255
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
type: object
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: username is required when kind is user
|
||||||
|
rule: self.kind != 'user' || has(self.username)
|
||||||
|
- message: username must not be set when kind is oncall
|
||||||
|
rule: self.kind != 'oncall' || !has(self.username)
|
||||||
|
maxItems: 20
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "5m".
|
||||||
|
maxLength: 32
|
||||||
|
pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- targets
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
maxItems: 10
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
repeatCount:
|
||||||
|
format: int64
|
||||||
|
maximum: 10
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- levels
|
||||||
type: object
|
type: object
|
||||||
oidc:
|
oidc:
|
||||||
description: |-
|
description: |-
|
||||||
@@ -193,53 +260,9 @@ spec:
|
|||||||
x-kubernetes-list-map-keys:
|
x-kubernetes-list-map-keys:
|
||||||
- type
|
- type
|
||||||
x-kubernetes-list-type: map
|
x-kubernetes-list-type: map
|
||||||
credentialsSecretRef:
|
|
||||||
description: |-
|
|
||||||
credentialsSecretRef is this team's own scoped credential
|
|
||||||
(DESIGN.md §6 point 3) -- pure output, always in the operator's own
|
|
||||||
namespace, under a fixed data key ("token").
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
description: key is the data key inside the Secret holding the
|
|
||||||
raw value.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
description: name is the Secret's name.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- key
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
inviteSecretRef:
|
|
||||||
description: |-
|
|
||||||
inviteSecretRef is this team's current invite link, if spec.invite.enabled.
|
|
||||||
Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN
|
|
||||||
namespace, not the operator's: an invite is bounded, limited-use, and
|
|
||||||
meant for this namespace's own human operators to read and hand out,
|
|
||||||
not a durable high-privilege credential — same shape as
|
|
||||||
TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's
|
|
||||||
cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled
|
|
||||||
is false or unset.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
description: name is the Secret's name.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
observedGeneration:
|
observedGeneration:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
serverEndpoint:
|
|
||||||
description: |-
|
|
||||||
serverEndpoint is the resolved TerdutServer's base URL, resolved once
|
|
||||||
here so no child controller (TerdutEscalationRule, TerdutDeadmanSwitch,
|
|
||||||
TerdutAlertSource) ever needs its own RBAC on terdutservers just to
|
|
||||||
find out where to send a request (DESIGN.md §5).
|
|
||||||
type: string
|
|
||||||
teamID:
|
teamID:
|
||||||
description: |-
|
description: |-
|
||||||
teamID is the server-side id -- needed by every child object's
|
teamID is the server-side id -- needed by every child object's
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources
|
- terdutalertsources
|
||||||
- terdutdeadmanswitches
|
|
||||||
- terdutescalationrules
|
|
||||||
- terdutservers
|
- terdutservers
|
||||||
- terdutteams
|
- terdutteams
|
||||||
verbs:
|
verbs:
|
||||||
@@ -90,8 +88,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources/finalizers
|
- terdutalertsources/finalizers
|
||||||
- terdutdeadmanswitches/finalizers
|
|
||||||
- terdutescalationrules/finalizers
|
|
||||||
- terdutservers/finalizers
|
- terdutservers/finalizers
|
||||||
- terdutteams/finalizers
|
- terdutteams/finalizers
|
||||||
verbs:
|
verbs:
|
||||||
@@ -100,8 +96,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources/status
|
- terdutalertsources/status
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
- terdutescalationrules/status
|
|
||||||
- terdutservers/status
|
- terdutservers/status
|
||||||
- terdutteams/status
|
- terdutteams/status
|
||||||
verbs:
|
verbs:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutdeadmanswitch-admin-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- '*'
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutdeadmanswitch-editor-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- delete
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- patch
|
|
||||||
- update
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutdeadmanswitch-viewer-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutescalationrule-admin-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- '*'
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutescalationrule-editor-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- delete
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- patch
|
|
||||||
- update
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{- if .Values.rbac.helpers.enabled }}
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
kind: Role
|
|
||||||
{{- else }}
|
|
||||||
kind: ClusterRole
|
|
||||||
{{- end }}
|
|
||||||
metadata:
|
|
||||||
{{- if .Values.rbac.namespaced }}
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
app.kubernetes.io/name: {{ include "terdut-operator.name" . }}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
name: {{ include "terdut-operator.resourceName" (dict "suffix" "terdutescalationrule-viewer-role" "context" $) }}
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
{{- end }}
|
|
||||||
@@ -34,10 +34,6 @@ spec:
|
|||||||
sweeper:
|
sweeper:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .Values.terdutServer.deadman }}
|
|
||||||
deadman:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.terdutServer.notify }}
|
{{- with .Values.terdutServer.notify }}
|
||||||
notify:
|
notify:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
|
|||||||
@@ -266,10 +266,6 @@ terdutServer:
|
|||||||
# sweeper:
|
# sweeper:
|
||||||
# staleAfter: 6h
|
# staleAfter: 6h
|
||||||
# archiveAfter: 168h
|
# archiveAfter: 168h
|
||||||
# deadman:
|
|
||||||
# matchers: "alertname=Watchdog"
|
|
||||||
# timeout: 15m
|
|
||||||
# severity: critical
|
|
||||||
# notify: {}
|
# notify: {}
|
||||||
# oidc: {}
|
# oidc: {}
|
||||||
|
|
||||||
|
|||||||
+6
-36
@@ -166,53 +166,23 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POD_NAMESPACE is the operator's own namespace, via the Deployment's
|
|
||||||
// downward API (config/manager/manager.yaml) — every credentials Secret
|
|
||||||
// TerdutServerReconciler reads or writes lives here, never in a
|
|
||||||
// TerdutServer's own namespace (DESIGN.md §6). Falling back to "default"
|
|
||||||
// keeps `go run` usable for local development against a real cluster;
|
|
||||||
// production always sets it.
|
|
||||||
operatorNamespace := os.Getenv("POD_NAMESPACE")
|
|
||||||
if operatorNamespace == "" {
|
|
||||||
operatorNamespace = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := (&controller.TerdutServerReconciler{
|
if err := (&controller.TerdutServerReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutserver")
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutserver")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err := (&controller.TerdutTeamReconciler{
|
if err := (&controller.TerdutTeamReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutteam")
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutteam")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err := (&controller.TerdutEscalationRuleReconciler{
|
|
||||||
Client: mgr.GetClient(),
|
|
||||||
Scheme: mgr.GetScheme(),
|
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
|
||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutescalationrule")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := (&controller.TerdutDeadmanSwitchReconciler{
|
|
||||||
Client: mgr.GetClient(),
|
|
||||||
Scheme: mgr.GetScheme(),
|
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
|
||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutdeadmanswitch")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := (&controller.TerdutAlertSourceReconciler{
|
if err := (&controller.TerdutAlertSourceReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutalertsource")
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutalertsource")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -76,9 +76,8 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
teamRef:
|
teamRef:
|
||||||
description: |-
|
description: |-
|
||||||
TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
TerdutTeamRef names the TerdutTeam a TerdutAlertSource belongs to. Always
|
||||||
same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
same-namespace as the CR itself.
|
||||||
crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: apiextensions.k8s.io/v1
|
|
||||||
kind: CustomResourceDefinition
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
controller-gen.kubebuilder.io/version: v0.22.0
|
|
||||||
name: terdutdeadmanswitches.terdut.ryuvia.com
|
|
||||||
spec:
|
|
||||||
group: terdut.ryuvia.com
|
|
||||||
names:
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
listKind: TerdutDeadmanSwitchList
|
|
||||||
plural: terdutdeadmanswitches
|
|
||||||
singular: terdutdeadmanswitch
|
|
||||||
scope: Namespaced
|
|
||||||
versions:
|
|
||||||
- additionalPrinterColumns:
|
|
||||||
- jsonPath: .spec.teamRef.name
|
|
||||||
name: Team
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.switchID
|
|
||||||
name: SwitchID
|
|
||||||
type: integer
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
|
||||||
name: Ready
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
|
||||||
name: Reason
|
|
||||||
type: string
|
|
||||||
name: v1alpha1
|
|
||||||
schema:
|
|
||||||
openAPIV3Schema:
|
|
||||||
description: TerdutDeadmanSwitch is the Schema for the terdutdeadmanswitches
|
|
||||||
API
|
|
||||||
properties:
|
|
||||||
apiVersion:
|
|
||||||
description: |-
|
|
||||||
APIVersion defines the versioned schema of this representation of an object.
|
|
||||||
Servers should convert recognized schemas to the latest internal value, and
|
|
||||||
may reject unrecognized values.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
description: |-
|
|
||||||
Kind is a string value representing the REST resource this object represents.
|
|
||||||
Servers may infer this from the endpoint the client submits requests to.
|
|
||||||
Cannot be updated.
|
|
||||||
In CamelCase.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
|
||||||
type: string
|
|
||||||
metadata:
|
|
||||||
type: object
|
|
||||||
spec:
|
|
||||||
description: spec defines the desired state of TerdutDeadmanSwitch
|
|
||||||
properties:
|
|
||||||
matcher:
|
|
||||||
description: |-
|
|
||||||
matcher names the alerts this switch watches, e.g.
|
|
||||||
"alertname=Watchdog,cluster=prod". One matcher per switch -- add
|
|
||||||
another TerdutDeadmanSwitch instead of separating with ";"
|
|
||||||
(terdut-server's own restriction, mirrored here so a bad spec is
|
|
||||||
rejected at apply time).
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
x-kubernetes-validations:
|
|
||||||
- message: 'one matcher per switch: add another TerdutDeadmanSwitch
|
|
||||||
instead of separating with ;'
|
|
||||||
rule: '!self.contains('';'')'
|
|
||||||
name:
|
|
||||||
description: |-
|
|
||||||
name is optional, same as the API: left empty, terdut-server derives
|
|
||||||
it from matcher's own canonical form, and that's what the
|
|
||||||
idempotent-create lookup matches against too.
|
|
||||||
type: string
|
|
||||||
severity:
|
|
||||||
default: critical
|
|
||||||
enum:
|
|
||||||
- critical
|
|
||||||
- error
|
|
||||||
- warning
|
|
||||||
- info
|
|
||||||
type: string
|
|
||||||
teamRef:
|
|
||||||
description: |-
|
|
||||||
TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
|
||||||
same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
|
||||||
crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
timeout:
|
|
||||||
description: timeout is a Go duration string, e.g. "15m".
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- matcher
|
|
||||||
- teamRef
|
|
||||||
- timeout
|
|
||||||
type: object
|
|
||||||
status:
|
|
||||||
description: status defines the observed state of TerdutDeadmanSwitch
|
|
||||||
properties:
|
|
||||||
conditions:
|
|
||||||
items:
|
|
||||||
description: Condition contains details for one aspect of the current
|
|
||||||
state of this API Resource.
|
|
||||||
properties:
|
|
||||||
lastTransitionTime:
|
|
||||||
description: |-
|
|
||||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
||||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
description: |-
|
|
||||||
message is a human readable message indicating details about the transition.
|
|
||||||
This may be an empty string.
|
|
||||||
maxLength: 32768
|
|
||||||
type: string
|
|
||||||
observedGeneration:
|
|
||||||
description: |-
|
|
||||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
||||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
|
||||||
with respect to the current state of the instance.
|
|
||||||
format: int64
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
reason:
|
|
||||||
description: |-
|
|
||||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
||||||
Producers of specific condition types may define expected values and meanings for this field,
|
|
||||||
and whether the values are considered a guaranteed API.
|
|
||||||
The value should be a CamelCase string.
|
|
||||||
This field may not be empty.
|
|
||||||
maxLength: 1024
|
|
||||||
minLength: 1
|
|
||||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
description: status of the condition, one of True, False, Unknown.
|
|
||||||
enum:
|
|
||||||
- "True"
|
|
||||||
- "False"
|
|
||||||
- Unknown
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
||||||
maxLength: 316
|
|
||||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- lastTransitionTime
|
|
||||||
- message
|
|
||||||
- reason
|
|
||||||
- status
|
|
||||||
- type
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
x-kubernetes-list-map-keys:
|
|
||||||
- type
|
|
||||||
x-kubernetes-list-type: map
|
|
||||||
observedGeneration:
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
switchID:
|
|
||||||
description: switchID is the server-side id.
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- spec
|
|
||||||
type: object
|
|
||||||
served: true
|
|
||||||
storage: true
|
|
||||||
subresources:
|
|
||||||
status: {}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: apiextensions.k8s.io/v1
|
|
||||||
kind: CustomResourceDefinition
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
controller-gen.kubebuilder.io/version: v0.22.0
|
|
||||||
name: terdutescalationrules.terdut.ryuvia.com
|
|
||||||
spec:
|
|
||||||
group: terdut.ryuvia.com
|
|
||||||
names:
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
listKind: TerdutEscalationRuleList
|
|
||||||
plural: terdutescalationrules
|
|
||||||
singular: terdutescalationrule
|
|
||||||
scope: Namespaced
|
|
||||||
versions:
|
|
||||||
- additionalPrinterColumns:
|
|
||||||
- jsonPath: .spec.teamRef.name
|
|
||||||
name: Team
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
|
||||||
name: Ready
|
|
||||||
type: string
|
|
||||||
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
|
||||||
name: Reason
|
|
||||||
type: string
|
|
||||||
name: v1alpha1
|
|
||||||
schema:
|
|
||||||
openAPIV3Schema:
|
|
||||||
description: TerdutEscalationRule is the Schema for the terdutescalationrules
|
|
||||||
API
|
|
||||||
properties:
|
|
||||||
apiVersion:
|
|
||||||
description: |-
|
|
||||||
APIVersion defines the versioned schema of this representation of an object.
|
|
||||||
Servers should convert recognized schemas to the latest internal value, and
|
|
||||||
may reject unrecognized values.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
description: |-
|
|
||||||
Kind is a string value representing the REST resource this object represents.
|
|
||||||
Servers may infer this from the endpoint the client submits requests to.
|
|
||||||
Cannot be updated.
|
|
||||||
In CamelCase.
|
|
||||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
|
||||||
type: string
|
|
||||||
metadata:
|
|
||||||
type: object
|
|
||||||
spec:
|
|
||||||
description: spec defines the desired state of TerdutEscalationRule
|
|
||||||
properties:
|
|
||||||
fallbackTopic:
|
|
||||||
type: string
|
|
||||||
levels:
|
|
||||||
items:
|
|
||||||
description: |-
|
|
||||||
EscalationLevel is one rung of the ladder: how long to wait, and who to
|
|
||||||
page if nobody's acknowledged by then.
|
|
||||||
properties:
|
|
||||||
targets:
|
|
||||||
items:
|
|
||||||
description: |-
|
|
||||||
EscalationTarget is one page within a level. username is required iff
|
|
||||||
kind is "user" (terdut-server's own validation, internal/api/escalation.go's
|
|
||||||
handleSetEscalation -- mirrored here as a CEL rule so a bad spec is
|
|
||||||
rejected at apply time, not discovered on the next failed PUT).
|
|
||||||
properties:
|
|
||||||
kind:
|
|
||||||
description: EscalationTargetKind is who one rung of the
|
|
||||||
ladder pages.
|
|
||||||
enum:
|
|
||||||
- oncall
|
|
||||||
- user
|
|
||||||
type: string
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- kind
|
|
||||||
type: object
|
|
||||||
x-kubernetes-validations:
|
|
||||||
- message: username is required when kind is user
|
|
||||||
rule: self.kind != 'user' || has(self.username)
|
|
||||||
- message: username must not be set when kind is oncall
|
|
||||||
rule: self.kind != 'oncall' || !has(self.username)
|
|
||||||
minItems: 1
|
|
||||||
type: array
|
|
||||||
timeout:
|
|
||||||
description: timeout is a Go duration string, e.g. "5m".
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- targets
|
|
||||||
- timeout
|
|
||||||
type: object
|
|
||||||
minItems: 1
|
|
||||||
type: array
|
|
||||||
repeatCount:
|
|
||||||
format: int64
|
|
||||||
maximum: 10
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
teamRef:
|
|
||||||
description: |-
|
|
||||||
TerdutTeamRef names the TerdutTeam this resource belongs to. Always
|
|
||||||
same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
|
|
||||||
crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- levels
|
|
||||||
- teamRef
|
|
||||||
type: object
|
|
||||||
status:
|
|
||||||
description: status defines the observed state of TerdutEscalationRule
|
|
||||||
properties:
|
|
||||||
conditions:
|
|
||||||
items:
|
|
||||||
description: Condition contains details for one aspect of the current
|
|
||||||
state of this API Resource.
|
|
||||||
properties:
|
|
||||||
lastTransitionTime:
|
|
||||||
description: |-
|
|
||||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
||||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
description: |-
|
|
||||||
message is a human readable message indicating details about the transition.
|
|
||||||
This may be an empty string.
|
|
||||||
maxLength: 32768
|
|
||||||
type: string
|
|
||||||
observedGeneration:
|
|
||||||
description: |-
|
|
||||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
||||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
|
||||||
with respect to the current state of the instance.
|
|
||||||
format: int64
|
|
||||||
minimum: 0
|
|
||||||
type: integer
|
|
||||||
reason:
|
|
||||||
description: |-
|
|
||||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
||||||
Producers of specific condition types may define expected values and meanings for this field,
|
|
||||||
and whether the values are considered a guaranteed API.
|
|
||||||
The value should be a CamelCase string.
|
|
||||||
This field may not be empty.
|
|
||||||
maxLength: 1024
|
|
||||||
minLength: 1
|
|
||||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
description: status of the condition, one of True, False, Unknown.
|
|
||||||
enum:
|
|
||||||
- "True"
|
|
||||||
- "False"
|
|
||||||
- Unknown
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
||||||
maxLength: 316
|
|
||||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- lastTransitionTime
|
|
||||||
- message
|
|
||||||
- reason
|
|
||||||
- status
|
|
||||||
- type
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
x-kubernetes-list-map-keys:
|
|
||||||
- type
|
|
||||||
x-kubernetes-list-type: map
|
|
||||||
observedGeneration:
|
|
||||||
format: int64
|
|
||||||
type: integer
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- spec
|
|
||||||
type: object
|
|
||||||
served: true
|
|
||||||
storage: true
|
|
||||||
subresources:
|
|
||||||
status: {}
|
|
||||||
@@ -175,19 +175,6 @@ spec:
|
|||||||
- message: exactly one of dsn or postgresClusterRef must be set
|
- message: exactly one of dsn or postgresClusterRef must be set
|
||||||
rule: '(has(self.dsn) ? 1 : 0) + (has(self.postgresClusterRef) ?
|
rule: '(has(self.dsn) ? 1 : 0) + (has(self.postgresClusterRef) ?
|
||||||
1 : 0) == 1'
|
1 : 0) == 1'
|
||||||
deadman:
|
|
||||||
description: |-
|
|
||||||
DeadmanSpec controls dead man's switch alerts. Matchers/Timeout/Severity
|
|
||||||
map straight to TERDUT_DEADMAN_MATCHERS/TERDUT_DEADMAN_TIMEOUT/
|
|
||||||
TERDUT_DEADMAN_SEVERITY.
|
|
||||||
properties:
|
|
||||||
matchers:
|
|
||||||
type: string
|
|
||||||
severity:
|
|
||||||
type: string
|
|
||||||
timeout:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
image:
|
image:
|
||||||
description: ImageSpec is the terdut-server image to run.
|
description: ImageSpec is the terdut-server image to run.
|
||||||
properties:
|
properties:
|
||||||
@@ -259,12 +246,7 @@ spec:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
oidc:
|
oidc:
|
||||||
description: |-
|
description: OIDCSpec controls single sign-on.
|
||||||
OIDCSpec controls single sign-on. Fields the chart also exposes but
|
|
||||||
DESIGN.md's spec doesn't (usernameClaim, emailClaim, groupsClaim,
|
|
||||||
trustEmail) use terdut-server's own defaults
|
|
||||||
(preferred_username/email/groups/false) rather than being added here
|
|
||||||
speculatively.
|
|
||||||
properties:
|
properties:
|
||||||
adminGroup:
|
adminGroup:
|
||||||
type: string
|
type: string
|
||||||
@@ -276,12 +258,11 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
clientSecretRef:
|
clientSecretRef:
|
||||||
description: |-
|
description: |-
|
||||||
SecretKeyRef names one data key inside a Secret. Every use of this type in
|
SecretKeyRef names one data key inside a Secret in the TerdutServer's own
|
||||||
TerdutServerSpec resolves in the TerdutServer's own namespace (it's wired
|
namespace. Every use of this type is wired into the Deployment's pod spec as
|
||||||
straight into the Deployment's pod spec as a secretKeyRef env source,
|
a secretKeyRef env source, which Kubernetes only allows same-namespace --
|
||||||
which Kubernetes itself only allows same-namespace) -- unlike the
|
including status.credentialsSecretRef, the operator key the controller
|
||||||
generated credentials Secret (DESIGN.md §6), which always lives in the
|
generates there.
|
||||||
operator's own namespace and is never referenced through this type.
|
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
description: key is the data key inside the Secret holding
|
description: key is the data key inside the Secret holding
|
||||||
@@ -296,8 +277,14 @@ spec:
|
|||||||
- key
|
- key
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
|
emailClaim:
|
||||||
|
default: email
|
||||||
|
type: string
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
groupsClaim:
|
||||||
|
default: groups
|
||||||
|
type: string
|
||||||
issuer:
|
issuer:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
@@ -309,6 +296,17 @@ spec:
|
|||||||
sessionMaxAge:
|
sessionMaxAge:
|
||||||
default: 12h
|
default: 12h
|
||||||
type: string
|
type: string
|
||||||
|
trustEmail:
|
||||||
|
description: |-
|
||||||
|
trustEmail links a sign-in to an existing local user by email even when
|
||||||
|
the provider does not vouch the address is verified (Authentik reports
|
||||||
|
email_verified false unless told otherwise).
|
||||||
|
type: boolean
|
||||||
|
usernameClaim:
|
||||||
|
default: preferred_username
|
||||||
|
description: usernameClaim, emailClaim and groupsClaim name the
|
||||||
|
ID token claims read.
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
passwordLogin:
|
passwordLogin:
|
||||||
default: true
|
default: true
|
||||||
@@ -4396,10 +4394,9 @@ spec:
|
|||||||
x-kubernetes-list-type: map
|
x-kubernetes-list-type: map
|
||||||
credentialsSecretRef:
|
credentialsSecretRef:
|
||||||
description: |-
|
description: |-
|
||||||
credentialsSecretRef is the generated instance-scoped credential
|
credentialsSecretRef is the operator key this controller generated for
|
||||||
(DESIGN.md §6) -- pure output, always in the operator's own
|
the server (TERDUT_OPERATOR_KEY): pure output, in the TerdutServer's own
|
||||||
namespace, under a fixed data key ("token"). Set only once
|
namespace and owned by it, under the data key "token".
|
||||||
Bootstrapped is True.
|
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
description: key is the data key inside the Secret holding the
|
description: key is the data key inside the Secret holding the
|
||||||
@@ -4421,12 +4418,6 @@ spec:
|
|||||||
tells "applied" from "seen" (DESIGN.md §7).
|
tells "applied" from "seen" (DESIGN.md §7).
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
serviceName:
|
|
||||||
description: |-
|
|
||||||
serviceName is the Service this controller created for the
|
|
||||||
Deployment, so other objects can reference it without recomputing the
|
|
||||||
naming convention.
|
|
||||||
type: string
|
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- spec
|
- spec
|
||||||
|
|||||||
@@ -52,54 +52,121 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
description: spec defines the desired state of TerdutTeam
|
description: spec defines the desired state of TerdutTeam
|
||||||
properties:
|
properties:
|
||||||
|
deadmanSwitches:
|
||||||
|
description: |-
|
||||||
|
deadmanSwitches are this team's dead man's switches, by name. Switches on
|
||||||
|
the server that are not listed here are removed: in operator mode this
|
||||||
|
list is the whole truth.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
DeadmanSwitchSpec is one dead man's switch: the absence of an alert matching
|
||||||
|
matcher for longer than timeout opens an incident.
|
||||||
|
properties:
|
||||||
|
matcher:
|
||||||
|
description: |-
|
||||||
|
matcher names the alerts this switch watches, e.g.
|
||||||
|
"alertname=Watchdog,cluster=prod". One matcher per switch.
|
||||||
|
maxLength: 512
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: 'one matcher per switch: add another entry instead
|
||||||
|
of separating with ;'
|
||||||
|
rule: '!self.contains('';'')'
|
||||||
|
name:
|
||||||
|
description: name identifies the switch within the team.
|
||||||
|
maxLength: 100
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
default: critical
|
||||||
|
enum:
|
||||||
|
- critical
|
||||||
|
- error
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
type: string
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "15m".
|
||||||
|
maxLength: 32
|
||||||
|
pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- matcher
|
||||||
|
- name
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
maxItems: 50
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- name
|
||||||
|
x-kubernetes-list-type: map
|
||||||
displayName:
|
displayName:
|
||||||
description: |-
|
description: |-
|
||||||
displayName is this team's name, both in terdut-server's own data
|
displayName is this team's name on the server. It can be changed freely:
|
||||||
(POST /api/teams {"name": ...}) and as the identity POST /api/teams
|
the team is found by the CR's own identity (<namespace>/<name>, sent as
|
||||||
and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
|
external_id), not by this name.
|
||||||
create rule, via TEAM-LOOKUP.md).
|
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
type: string
|
||||||
invite:
|
escalation:
|
||||||
description: |-
|
description: |-
|
||||||
TerdutTeamInvite requests a standing invite link into this team, minted
|
escalation is this team's escalation ladder. Omitted, the team has none
|
||||||
with the team's own team-scoped credential — requireTeamOwner already
|
(the server's plain reminder behaviour applies).
|
||||||
treats that credential as owner-equivalent for every /invites route
|
|
||||||
(ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is
|
|
||||||
the real answer to "how does a human ever get a first login on a
|
|
||||||
password-only, operator-managed install" (terdut-server#23): no signup_mode
|
|
||||||
flip, no admin token, just a link redeemed the same way anyone else's
|
|
||||||
invite would be.
|
|
||||||
properties:
|
properties:
|
||||||
enabled:
|
fallbackTopic:
|
||||||
description: |-
|
|
||||||
enabled mints (and keeps refreshed ahead of terdut-server's own fixed
|
|
||||||
7-day TTL) an invite link while true. Flipping it back to false
|
|
||||||
revokes the current one server-side rather than leaving it to expire
|
|
||||||
on its own.
|
|
||||||
type: boolean
|
|
||||||
maxUses:
|
|
||||||
default: 1
|
|
||||||
description: |-
|
|
||||||
maxUses bounds how many times this link may be redeemed before it
|
|
||||||
stops working, mirroring terdut-server's own 1-100 range
|
|
||||||
(POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for
|
|
||||||
one specific person, not a standing door.
|
|
||||||
format: int64
|
|
||||||
maximum: 100
|
|
||||||
minimum: 1
|
|
||||||
type: integer
|
|
||||||
role:
|
|
||||||
default: member
|
|
||||||
description: |-
|
|
||||||
role is what the invite grants: member or owner. Defaults to member —
|
|
||||||
owner by default would make every invite link a standing
|
|
||||||
administrative credential for the team, a much bigger blast radius
|
|
||||||
than "let a human see the queue".
|
|
||||||
enum:
|
|
||||||
- member
|
|
||||||
- owner
|
|
||||||
type: string
|
type: string
|
||||||
|
levels:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EscalationLevel is one rung of the ladder: how long to wait, and who to page
|
||||||
|
if nobody has acknowledged by then.
|
||||||
|
properties:
|
||||||
|
targets:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EscalationTarget is one page within a level. username is required iff kind is
|
||||||
|
"user".
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
description: EscalationTargetKind is who one rung
|
||||||
|
of the ladder pages.
|
||||||
|
enum:
|
||||||
|
- oncall
|
||||||
|
- user
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
maxLength: 255
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
type: object
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: username is required when kind is user
|
||||||
|
rule: self.kind != 'user' || has(self.username)
|
||||||
|
- message: username must not be set when kind is oncall
|
||||||
|
rule: self.kind != 'oncall' || !has(self.username)
|
||||||
|
maxItems: 20
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "5m".
|
||||||
|
maxLength: 32
|
||||||
|
pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- targets
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
maxItems: 10
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
repeatCount:
|
||||||
|
format: int64
|
||||||
|
maximum: 10
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- levels
|
||||||
type: object
|
type: object
|
||||||
oidc:
|
oidc:
|
||||||
description: |-
|
description: |-
|
||||||
@@ -190,53 +257,9 @@ spec:
|
|||||||
x-kubernetes-list-map-keys:
|
x-kubernetes-list-map-keys:
|
||||||
- type
|
- type
|
||||||
x-kubernetes-list-type: map
|
x-kubernetes-list-type: map
|
||||||
credentialsSecretRef:
|
|
||||||
description: |-
|
|
||||||
credentialsSecretRef is this team's own scoped credential
|
|
||||||
(DESIGN.md §6 point 3) -- pure output, always in the operator's own
|
|
||||||
namespace, under a fixed data key ("token").
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
description: key is the data key inside the Secret holding the
|
|
||||||
raw value.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
description: name is the Secret's name.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- key
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
inviteSecretRef:
|
|
||||||
description: |-
|
|
||||||
inviteSecretRef is this team's current invite link, if spec.invite.enabled.
|
|
||||||
Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN
|
|
||||||
namespace, not the operator's: an invite is bounded, limited-use, and
|
|
||||||
meant for this namespace's own human operators to read and hand out,
|
|
||||||
not a durable high-privilege credential — same shape as
|
|
||||||
TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's
|
|
||||||
cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled
|
|
||||||
is false or unset.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
description: name is the Secret's name.
|
|
||||||
minLength: 1
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
type: object
|
|
||||||
observedGeneration:
|
observedGeneration:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
serverEndpoint:
|
|
||||||
description: |-
|
|
||||||
serverEndpoint is the resolved TerdutServer's base URL, resolved once
|
|
||||||
here so no child controller (TerdutEscalationRule, TerdutDeadmanSwitch,
|
|
||||||
TerdutAlertSource) ever needs its own RBAC on terdutservers just to
|
|
||||||
find out where to send a request (DESIGN.md §5).
|
|
||||||
type: string
|
|
||||||
teamID:
|
teamID:
|
||||||
description: |-
|
description: |-
|
||||||
teamID is the server-side id -- needed by every child object's
|
teamID is the server-side id -- needed by every child object's
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
resources:
|
resources:
|
||||||
- bases/terdut.ryuvia.com_terdutservers.yaml
|
- bases/terdut.ryuvia.com_terdutservers.yaml
|
||||||
- bases/terdut.ryuvia.com_terdutteams.yaml
|
- bases/terdut.ryuvia.com_terdutteams.yaml
|
||||||
- bases/terdut.ryuvia.com_terdutescalationrules.yaml
|
|
||||||
- bases/terdut.ryuvia.com_terdutdeadmanswitches.yaml
|
|
||||||
- bases/terdut.ryuvia.com_terdutalertsources.yaml
|
- bases/terdut.ryuvia.com_terdutalertsources.yaml
|
||||||
# +kubebuilder:scaffold:crdkustomizeresource
|
# +kubebuilder:scaffold:crdkustomizeresource
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,8 @@ resources:
|
|||||||
#- ../webhook
|
#- ../webhook
|
||||||
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
|
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
|
||||||
#- ../certmanager
|
#- ../certmanager
|
||||||
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
|
|
||||||
#- ../prometheus
|
|
||||||
# [METRICS] Expose the controller manager metrics service.
|
# [METRICS] Expose the controller manager metrics service.
|
||||||
- metrics_service.yaml
|
- metrics_service.yaml
|
||||||
# [NETWORK POLICY] Control ingress to metrics and webhook ports.
|
|
||||||
# Allow metrics traffic from pods in namespaces labeled 'metrics: enabled'.
|
|
||||||
# Allow webhook traffic from all sources.
|
|
||||||
#- ../network-policy
|
|
||||||
|
|
||||||
# Uncomment the patches line if you enable Metrics
|
# Uncomment the patches line if you enable Metrics
|
||||||
patches:
|
patches:
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Allow metrics traffic from pods in namespaces labeled 'metrics: enabled'.
|
|
||||||
# Add this label to namespaces whose pods should scrape metrics.
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: NetworkPolicy
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: allow-metrics-traffic
|
|
||||||
namespace: system
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
control-plane: controller-manager
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
policyTypes:
|
|
||||||
- Ingress
|
|
||||||
ingress:
|
|
||||||
# Allow pods in namespaces labeled 'metrics: enabled' to scrape metrics.
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
metrics: enabled # Only from namespaces with this label
|
|
||||||
ports:
|
|
||||||
- port: 8443
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
resources:
|
|
||||||
- allow-metrics-traffic.yaml
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
resources:
|
|
||||||
- monitor.yaml
|
|
||||||
|
|
||||||
# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus
|
|
||||||
# to securely reference certificates created and managed by cert-manager.
|
|
||||||
# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml
|
|
||||||
# to mount the "metrics-server-cert" secret in the Manager Deployment.
|
|
||||||
#patches:
|
|
||||||
# - path: monitor_tls_patch.yaml
|
|
||||||
# target:
|
|
||||||
# kind: ServiceMonitor
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Prometheus Monitor Service (Metrics)
|
|
||||||
apiVersion: monitoring.coreos.com/v1
|
|
||||||
kind: ServiceMonitor
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
control-plane: controller-manager
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: controller-manager-metrics-monitor
|
|
||||||
namespace: system
|
|
||||||
spec:
|
|
||||||
endpoints:
|
|
||||||
- path: /metrics
|
|
||||||
port: https # Ensure this is the name of the port that exposes HTTPS metrics
|
|
||||||
scheme: https
|
|
||||||
bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
|
||||||
tlsConfig:
|
|
||||||
# TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables
|
|
||||||
# certificate verification, exposing the system to potential man-in-the-middle attacks.
|
|
||||||
# For production environments, it is recommended to use cert-manager for automatic TLS certificate management.
|
|
||||||
# To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml,
|
|
||||||
# which securely references the certificate from the 'metrics-server-cert' secret.
|
|
||||||
insecureSkipVerify: true
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
control-plane: controller-manager
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Patch for Prometheus ServiceMonitor to enable secure TLS configuration
|
|
||||||
# using certificates managed by cert-manager
|
|
||||||
- op: replace
|
|
||||||
path: /spec/endpoints/0/tlsConfig
|
|
||||||
value:
|
|
||||||
# SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
|
|
||||||
serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc
|
|
||||||
insecureSkipVerify: false
|
|
||||||
ca:
|
|
||||||
secret:
|
|
||||||
name: metrics-server-cert
|
|
||||||
key: ca.crt
|
|
||||||
cert:
|
|
||||||
secret:
|
|
||||||
name: metrics-server-cert
|
|
||||||
key: tls.crt
|
|
||||||
keySecret:
|
|
||||||
name: metrics-server-cert
|
|
||||||
key: tls.key
|
|
||||||
@@ -25,12 +25,6 @@ resources:
|
|||||||
- terdutalertsource_admin_role.yaml
|
- terdutalertsource_admin_role.yaml
|
||||||
- terdutalertsource_editor_role.yaml
|
- terdutalertsource_editor_role.yaml
|
||||||
- terdutalertsource_viewer_role.yaml
|
- terdutalertsource_viewer_role.yaml
|
||||||
- terdutdeadmanswitch_admin_role.yaml
|
|
||||||
- terdutdeadmanswitch_editor_role.yaml
|
|
||||||
- terdutdeadmanswitch_viewer_role.yaml
|
|
||||||
- terdutescalationrule_admin_role.yaml
|
|
||||||
- terdutescalationrule_editor_role.yaml
|
|
||||||
- terdutescalationrule_viewer_role.yaml
|
|
||||||
- terdutteam_admin_role.yaml
|
- terdutteam_admin_role.yaml
|
||||||
- terdutteam_editor_role.yaml
|
- terdutteam_editor_role.yaml
|
||||||
- terdutteam_viewer_role.yaml
|
- terdutteam_viewer_role.yaml
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources
|
- terdutalertsources
|
||||||
- terdutdeadmanswitches
|
|
||||||
- terdutescalationrules
|
|
||||||
- terdutservers
|
- terdutservers
|
||||||
- terdutteams
|
- terdutteams
|
||||||
verbs:
|
verbs:
|
||||||
@@ -84,8 +82,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources/finalizers
|
- terdutalertsources/finalizers
|
||||||
- terdutdeadmanswitches/finalizers
|
|
||||||
- terdutescalationrules/finalizers
|
|
||||||
- terdutservers/finalizers
|
- terdutservers/finalizers
|
||||||
- terdutteams/finalizers
|
- terdutteams/finalizers
|
||||||
verbs:
|
verbs:
|
||||||
@@ -94,8 +90,6 @@ rules:
|
|||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
- terdutalertsources/status
|
- terdutalertsources/status
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
- terdutescalationrules/status
|
|
||||||
- terdutservers/status
|
- terdutservers/status
|
||||||
- terdutteams/status
|
- terdutteams/status
|
||||||
verbs:
|
verbs:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants full permissions ('*') over terdut.ryuvia.com.
|
|
||||||
# This role is intended for users authorized to modify roles and bindings within the cluster,
|
|
||||||
# enabling them to delegate specific permissions to other users or groups as needed.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutdeadmanswitch-admin-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- '*'
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
|
|
||||||
# This role is intended for users who need to manage these resources
|
|
||||||
# but should not control RBAC or manage permissions for others.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutdeadmanswitch-editor-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- delete
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- patch
|
|
||||||
- update
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants read-only access to terdut.ryuvia.com resources.
|
|
||||||
# This role is intended for users who need visibility into these resources
|
|
||||||
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutdeadmanswitch-viewer-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutdeadmanswitches/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants full permissions ('*') over terdut.ryuvia.com.
|
|
||||||
# This role is intended for users authorized to modify roles and bindings within the cluster,
|
|
||||||
# enabling them to delegate specific permissions to other users or groups as needed.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutescalationrule-admin-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- '*'
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
|
|
||||||
# This role is intended for users who need to manage these resources
|
|
||||||
# but should not control RBAC or manage permissions for others.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutescalationrule-editor-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- delete
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- patch
|
|
||||||
- update
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# This rule is not used by the project terdut-operator itself.
|
|
||||||
# It is provided to allow the cluster admin to help manage permissions for users.
|
|
||||||
#
|
|
||||||
# Grants read-only access to terdut.ryuvia.com resources.
|
|
||||||
# This role is intended for users who need visibility into these resources
|
|
||||||
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
|
|
||||||
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutescalationrule-viewer-role
|
|
||||||
rules:
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
- list
|
|
||||||
- watch
|
|
||||||
- apiGroups:
|
|
||||||
- terdut.ryuvia.com
|
|
||||||
resources:
|
|
||||||
- terdutescalationrules/status
|
|
||||||
verbs:
|
|
||||||
- get
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
## Append samples of your project ##
|
|
||||||
resources:
|
|
||||||
- terdut_v1alpha1_terdutserver.yaml
|
|
||||||
- terdut_v1alpha1_terdutteam.yaml
|
|
||||||
- terdut_v1alpha1_terdutescalationrule.yaml
|
|
||||||
- terdut_v1alpha1_terdutdeadmanswitch.yaml
|
|
||||||
- terdut_v1alpha1_terdutalertsource.yaml
|
|
||||||
# +kubebuilder:scaffold:manifestskustomizesamples
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutAlertSource
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutalertsource-sample
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-sample
|
|
||||||
# kind defaults to "alertmanager" -- the only value terdut-server
|
|
||||||
# supports today. Changing it after this object exists rotates the
|
|
||||||
# webhook key (DESIGN.md §5): the old integration is deleted and a new
|
|
||||||
# one created, which breaks whatever still sends to the old URL.
|
|
||||||
kind: alertmanager
|
|
||||||
# name is this source's own display name server-side, distinct from this
|
|
||||||
# object's own metadata.name above -- renaming it is safe and never
|
|
||||||
# rotates the key.
|
|
||||||
name: prod-alertmanager
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutdeadmanswitch-sample
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-sample
|
|
||||||
# name is optional -- left empty, terdut-server derives it from matcher's
|
|
||||||
# own canonical form (DESIGN.md §4.4).
|
|
||||||
matcher: "alertname=Watchdog"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutescalationrule-sample
|
|
||||||
spec:
|
|
||||||
# One per team (DESIGN.md §4.3) -- a second TerdutEscalationRule naming
|
|
||||||
# the same teamRef would simply clobber this one every reconcile, since
|
|
||||||
# there's no admission-time check for it in v1.
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-sample
|
|
||||||
repeatCount: 2
|
|
||||||
fallbackTopic: platform-fallback
|
|
||||||
levels:
|
|
||||||
# username is required iff kind is "user", and rejected otherwise --
|
|
||||||
# enforced at apply time via CEL (api/v1alpha1/terdutescalationrule_types.go).
|
|
||||||
- timeout: 5m
|
|
||||||
targets:
|
|
||||||
- kind: user
|
|
||||||
username: alice
|
|
||||||
- timeout: 10m
|
|
||||||
targets:
|
|
||||||
- kind: oncall
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutServer
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutserver-sample
|
|
||||||
spec:
|
|
||||||
image:
|
|
||||||
repository: git.ryuvia.com/niklas/terdut-server
|
|
||||||
tag: v0.20.0
|
|
||||||
replicas: 1
|
|
||||||
networking:
|
|
||||||
hostname: terdut.example.com
|
|
||||||
servicePort: 8080
|
|
||||||
# Bring-your-own DSN (simplest path, no external CRD dependency). For the
|
|
||||||
# Zalando postgres-operator path instead, use:
|
|
||||||
# database:
|
|
||||||
# postgresClusterRef:
|
|
||||||
# name: terdut-postgres
|
|
||||||
database:
|
|
||||||
dsn: "postgres://terdut@terdut-postgres:5432/terdut?sslmode=require"
|
|
||||||
passwordSecretRef:
|
|
||||||
name: terdut-postgres-password
|
|
||||||
key: password
|
|
||||||
sweeper:
|
|
||||||
staleAfter: 6h
|
|
||||||
archiveAfter: 168h
|
|
||||||
deadman:
|
|
||||||
matchers: "alertname=Watchdog"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
passwordLogin: true
|
|
||||||
# Pod-level customization, all optional -- see PodSpec in
|
|
||||||
# api/v1alpha1/terdutserver_types.go for the full shape (tolerations,
|
|
||||||
# affinity, topologySpreadConstraints, securityContext,
|
|
||||||
# serviceAccountName, extraEnv/extraVolumes, imagePullSecrets,
|
|
||||||
# disruptionBudget, ...). Example:
|
|
||||||
# pod:
|
|
||||||
# resources:
|
|
||||||
# requests: {cpu: 100m, memory: 128Mi}
|
|
||||||
# limits: {memory: 256Mi}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutTeam
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: terdut-operator
|
|
||||||
app.kubernetes.io/managed-by: kustomize
|
|
||||||
name: terdutteam-sample
|
|
||||||
spec:
|
|
||||||
# serverRef.namespace is optional, defaulting to this TerdutTeam's own
|
|
||||||
# namespace (the common case). Set it only to reference a TerdutServer in
|
|
||||||
# a different namespace -- which needs that TerdutServer's own
|
|
||||||
# spec.allowedTeams to admit this namespace (DESIGN.md §4.6), otherwise
|
|
||||||
# this reports Ready: False, reason: RefNotPermitted.
|
|
||||||
serverRef:
|
|
||||||
name: terdutserver-sample
|
|
||||||
displayName: Platform
|
|
||||||
# oidc is optional -- omit entirely for a password-login-only install.
|
|
||||||
# oidc:
|
|
||||||
# memberGroup: terdut-platform-members
|
|
||||||
# ownerGroup: terdut-platform-owners
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# The one TerdutServer this whole demo runs against. Everything else in
|
# The one TerdutServer this whole demo runs against. Everything else in
|
||||||
# this directory (teams, escalation rules, dead man's switches, alert
|
# this directory (teams with their escalation and dead man's switches,
|
||||||
# sources) references it by name.
|
# alert sources) references it by name.
|
||||||
#
|
#
|
||||||
# The operator never creates any ingress/HTTPRoute for this TerdutServer --
|
# The operator never creates any ingress/HTTPRoute for this TerdutServer --
|
||||||
# that's a permanent non-goal (DESIGN.md §1, NetworkingSpec's own doc
|
# that's a permanent non-goal (DESIGN.md §1, NetworkingSpec's own doc
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Two teams (this one and 03-team-payments.yaml) so the demo shows
|
# Two teams (this one and 03-team-payments.yaml) so the demo shows
|
||||||
# per-team isolation -- separate incident lists, separate escalation
|
# per-team isolation -- separate incident lists, separate escalation
|
||||||
# ladders, separate alert sources -- rather than one team standing in for
|
# ladders, separate alert sources -- rather than one team standing in for
|
||||||
# everything.
|
# everything. A team carries its own escalation ladder and dead man's
|
||||||
|
# switches; alert sources are separate objects (04-alertsource-*.yaml)
|
||||||
|
# because each owns a webhook Secret.
|
||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
kind: TerdutTeam
|
kind: TerdutTeam
|
||||||
metadata:
|
metadata:
|
||||||
@@ -15,9 +17,29 @@ spec:
|
|||||||
name: terdut-operator-demo
|
name: terdut-operator-demo
|
||||||
displayName: Platform
|
displayName: Platform
|
||||||
# No oidc block: this demo is password-login only (01-server.yaml).
|
# No oidc block: this demo is password-login only (01-server.yaml).
|
||||||
# A real invite link, minted via this team's own credential, is how
|
|
||||||
# run-demo.sh's alice actually gets in -- no signup_mode flip, no admin
|
# The whole ladder, replaced as one unit. username is required iff kind is
|
||||||
# token (see niklas/terdut-server#23's resolution). Role/maxUses left at
|
# "user" (CEL validation at apply time). The server resolves it, so a
|
||||||
# their defaults (member, 1): one link for one person.
|
# username it does not know yet leaves this team at Reason: UnknownUser
|
||||||
invite:
|
# until that person exists -- run-demo.sh creates alice for exactly that.
|
||||||
enabled: true
|
escalation:
|
||||||
|
repeatCount: 2
|
||||||
|
fallbackTopic: platform-fallback
|
||||||
|
levels:
|
||||||
|
- timeout: 5m
|
||||||
|
targets:
|
||||||
|
- kind: user
|
||||||
|
username: alice
|
||||||
|
- timeout: 10m
|
||||||
|
targets:
|
||||||
|
- kind: oncall
|
||||||
|
|
||||||
|
# Dead man's switches, by name. fire-alerts.sh's "heartbeat" scenario sends
|
||||||
|
# a matching alert; stop sending it and terdut-server itself opens an
|
||||||
|
# incident once `timeout` passes with no heartbeat. A switch on the server
|
||||||
|
# that is not listed here is removed.
|
||||||
|
deadmanSwitches:
|
||||||
|
- name: platform-watchdog
|
||||||
|
matcher: "alertname=PlatformWatchdog"
|
||||||
|
timeout: 15m
|
||||||
|
severity: critical
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ spec:
|
|||||||
serverRef:
|
serverRef:
|
||||||
name: terdut-operator-demo
|
name: terdut-operator-demo
|
||||||
displayName: Payments
|
displayName: Payments
|
||||||
# No spec.invite here, deliberately: run-demo.sh joins alice to this team
|
escalation:
|
||||||
# through POST /api/teams/{teamID}/members instead (this team's own
|
repeatCount: 1
|
||||||
# credential, same owner-equivalent reach spec.invite relies on, plus her
|
fallbackTopic: payments-fallback
|
||||||
# user id resolved via GET /api/users), once she already has an account
|
levels:
|
||||||
# from Platform's invite -- showing both onboarding paths this feature
|
- timeout: 5m
|
||||||
# unlocks, not just the one.
|
targets:
|
||||||
|
- kind: oncall
|
||||||
|
deadmanSwitches:
|
||||||
|
- name: payments-watchdog
|
||||||
|
matcher: "alertname=PaymentsWatchdog"
|
||||||
|
timeout: 15m
|
||||||
|
severity: critical
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# One per team is the rule (DESIGN.md §4.3) -- a second TerdutEscalationRule
|
|
||||||
# naming the same teamRef would just clobber this one on the next reconcile.
|
|
||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
metadata:
|
|
||||||
name: terdutescalationrule-platform
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-platform
|
|
||||||
repeatCount: 2
|
|
||||||
fallbackTopic: platform-fallback
|
|
||||||
levels:
|
|
||||||
# username is required iff kind is "user", rejected otherwise -- CEL
|
|
||||||
# validation at apply time (api/v1alpha1/terdutescalationrule_types.go).
|
|
||||||
# alice won't exist on a fresh demo install -- see README.md for
|
|
||||||
# creating a real user if you want this level to mean something, or
|
|
||||||
# just watch it fall through to oncall after 5m.
|
|
||||||
- timeout: 5m
|
|
||||||
targets:
|
|
||||||
- kind: user
|
|
||||||
username: alice
|
|
||||||
- timeout: 10m
|
|
||||||
targets:
|
|
||||||
- kind: oncall
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutEscalationRule
|
|
||||||
metadata:
|
|
||||||
name: terdutescalationrule-payments
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-payments
|
|
||||||
repeatCount: 1
|
|
||||||
fallbackTopic: payments-fallback
|
|
||||||
levels:
|
|
||||||
- timeout: 5m
|
|
||||||
targets:
|
|
||||||
- kind: oncall
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# A per-team dead man's switch (DESIGN.md §4.4) -- a different thing from
|
|
||||||
# 01-server.yaml's spec.deadman, which this demo leaves unset so this CRD
|
|
||||||
# is what you're actually seeing reconcile. fire-alerts.sh's "heartbeat"
|
|
||||||
# scenario sends a matching alert; stop sending it and terdut-server
|
|
||||||
# itself opens an incident once `timeout` passes with no heartbeat.
|
|
||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
metadata:
|
|
||||||
name: terdutdeadmanswitch-platform
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-platform
|
|
||||||
# name omitted -- terdut-server derives one from the matcher's own
|
|
||||||
# canonical form (DESIGN.md §4.4).
|
|
||||||
matcher: "alertname=PlatformWatchdog"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
|
||||||
kind: TerdutDeadmanSwitch
|
|
||||||
metadata:
|
|
||||||
name: terdutdeadmanswitch-payments
|
|
||||||
spec:
|
|
||||||
teamRef:
|
|
||||||
name: terdutteam-payments
|
|
||||||
matcher: "alertname=PaymentsWatchdog"
|
|
||||||
timeout: 15m
|
|
||||||
severity: critical
|
|
||||||
+29
-43
@@ -1,9 +1,9 @@
|
|||||||
# Demo
|
# Demo
|
||||||
|
|
||||||
Every CRD this operator reconciles, wired into one working install: one
|
Every CRD this operator reconciles, wired into one working install: one
|
||||||
`TerdutServer`, two `TerdutTeam`s (Platform and Payments) each with their
|
`TerdutServer`, two `TerdutTeam`s (Platform and Payments), each carrying its
|
||||||
own `TerdutEscalationRule`, `TerdutDeadmanSwitch` and `TerdutAlertSource`,
|
own escalation ladder and dead man's switches, and a `TerdutAlertSource` per
|
||||||
plus a script that fires synthetic Alertmanager webhooks at it so you can
|
team, plus a script that fires synthetic Alertmanager webhooks at it so you can
|
||||||
watch real incidents appear, escalate and resolve.
|
watch real incidents appear, escalate and resolve.
|
||||||
|
|
||||||
This is a demo kit, not a reference deployment: `00-postgres.yaml` runs
|
This is a demo kit, not a reference deployment: `00-postgres.yaml` runs
|
||||||
@@ -13,7 +13,7 @@ directory. Throw the whole namespace away when you're done.
|
|||||||
**Want this fully automated instead of walking through it by hand?**
|
**Want this fully automated instead of walking through it by hand?**
|
||||||
`./run-demo.sh` does everything below itself, against a fresh (or
|
`./run-demo.sh` does everything below itself, against a fresh (or
|
||||||
already-set-up) `kind` cluster — creates the cluster, installs the
|
already-set-up) `kind` cluster — creates the cluster, installs the
|
||||||
operator, applies every CR here, signs `alice` in for real, and fires a
|
operator, applies every CR here, creates `alice` as the first user, and fires a
|
||||||
few alerts. `./run-demo.sh --help` for the knobs, `./run-demo.sh
|
few alerts. `./run-demo.sh --help` for the knobs, `./run-demo.sh
|
||||||
--teardown` to tear it back down. The rest of this file is the manual
|
--teardown` to tear it back down. The rest of this file is the manual
|
||||||
walkthrough it automates.
|
walkthrough it automates.
|
||||||
@@ -44,16 +44,17 @@ kubectl create namespace terdut-operator-demo
|
|||||||
kubectl apply -n terdut-operator-demo -k .
|
kubectl apply -n terdut-operator-demo -k .
|
||||||
```
|
```
|
||||||
|
|
||||||
Listed and applied in dependency order (server → team → everything that
|
Listed and applied in dependency order (server → team → alert source), but
|
||||||
`teamRef`s it), but you don't have to preserve that order yourself:
|
you don't have to preserve that order yourself: every controller here
|
||||||
every controller here re-queues and waits rather than failing when a ref
|
re-queues and waits rather than failing when a ref isn't resolvable yet
|
||||||
isn't resolvable yet (`kubectl describe` shows `Reason: TeamRefNotFound` /
|
(`kubectl describe` shows `Reason: WaitingForServer` / `WaitingForTeam` while
|
||||||
`WaitingForTeam` while that settles).
|
that settles). Platform stays at `Reason: UnknownUser` until `alice` exists:
|
||||||
|
its escalation ladder names her.
|
||||||
|
|
||||||
Watch it converge:
|
Watch it converge:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kubectl get terdutservers,terdutteams,terdutescalationrules,terdutdeadmanswitches,terdutalertsources \
|
kubectl get terdutservers,terdutteams,terdutalertsources \
|
||||||
-n terdut-operator-demo
|
-n terdut-operator-demo
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,36 +81,21 @@ exposing it for real (Gateway API, Istio, or a plain `Ingress`) instead.
|
|||||||
|
|
||||||
### First login
|
### First login
|
||||||
|
|
||||||
The operator's own bootstrap (DESIGN.md §6) creates the first user through
|
The operator authenticates with a key of its own (the `TerdutServer`'s
|
||||||
`/api/bootstrap` and immediately mints itself a service-account token from
|
`<name>-operator-key` Secret, handed to the server as `TERDUT_OPERATOR_KEY`) and
|
||||||
it, then discards the bootstrap user's own key — nobody ever signs in as
|
never creates a user. A person gets in the way anyone does on a fresh
|
||||||
that account, and `signup_mode` stays `invite_only` by default. **Don't try
|
terdut-server: `/api/bootstrap` creates the first user, an administrator,
|
||||||
to flip it via the operator's own token**: that token is a service account,
|
while no user exists yet:
|
||||||
and `/api/admin/settings` is deliberately human-only on terdut-server
|
|
||||||
(`niklas/terdut-server#23` has the full reasoning — widening that gate was
|
|
||||||
the wrong fix).
|
|
||||||
|
|
||||||
The real path in: `02-team-platform.yaml` turns on `spec.invite`, so
|
|
||||||
Platform's own `TerdutTeam` mints a real invite link with its own
|
|
||||||
already-working team-scoped credential (the same reach that lets it manage
|
|
||||||
its own escalation policy, dead man's switches and integrations — owner-
|
|
||||||
equivalent, confirmed in terdut-server's `SERVICE-ACCOUNTS.md`). Invite
|
|
||||||
redemption bypasses `signup_mode` entirely, so this needs no admin
|
|
||||||
credential at all:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
secretname=$(kubectl -n terdut-operator-demo get terdutteam terdutteam-platform \
|
curl -sS -X POST http://localhost:8080/api/bootstrap -H 'Content-Type: application/json' \
|
||||||
-o jsonpath='{.status.inviteSecretRef.name}')
|
-d '{"username":"alice","email":"alice@example.com","password":"a-long-demo-password"}'
|
||||||
url=$(kubectl -n terdut-operator-demo get secret "$secretname" -o jsonpath='{.data.url}' | base64 -d)
|
|
||||||
echo "$url" # open this, or POST /api/signup with {"invite": "<the token after invite=>", ...}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`04-escalation-platform.yaml` names a user `alice` at its first escalation
|
The response carries an API key, shown once. An administrator can manage any
|
||||||
level — sign up as `alice` if you want that level to mean something rather
|
team, so add `alice` to both teams with it (`POST /api/teams/{teamID}/members`;
|
||||||
than falling through to on-call after 5 minutes. `run-demo.sh` does exactly
|
`kubectl get terdutteam -o jsonpath='{.status.teamID}'` gives the ids), or just
|
||||||
this automatically (and also joins `alice` to Payments, which deliberately
|
use the UI's team pages. `run-demo.sh` does exactly this for you.
|
||||||
has no `spec.invite` of its own — see that file's comment for the second
|
|
||||||
onboarding path this demonstrates).
|
|
||||||
|
|
||||||
## Fire some alerts
|
## Fire some alerts
|
||||||
|
|
||||||
@@ -122,8 +108,8 @@ export NAMESPACE=terdut-operator-demo
|
|||||||
./fire-alerts.sh platform disk-full
|
./fire-alerts.sh platform disk-full
|
||||||
./fire-alerts.sh payments pod-crash
|
./fire-alerts.sh payments pod-crash
|
||||||
|
|
||||||
# Watch it open an incident, escalate per 04/05-escalation-*.yaml's
|
# Watch it open an incident, escalate per the escalation ladders in
|
||||||
# levels, and show up on the Platform/Payments team's own incident list.
|
# 02/03-team-*.yaml, and show up on the Platform/Payments team's own incident list.
|
||||||
|
|
||||||
./fire-alerts.sh platform high-cpu resolve
|
./fire-alerts.sh platform high-cpu resolve
|
||||||
```
|
```
|
||||||
@@ -149,7 +135,7 @@ fired with. `./run-demo.sh` fires its alerts across `prod-eu` and `prod-us`.
|
|||||||
|
|
||||||
### Dead man's switches
|
### Dead man's switches
|
||||||
|
|
||||||
`06-deadman-platform.yaml` / `07-deadman-payments.yaml` expect a heartbeat
|
The `deadmanSwitches` in `02-team-platform.yaml` / `03-team-payments.yaml` expect a heartbeat
|
||||||
alert on a 15-minute timeout:
|
alert on a 15-minute timeout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -167,7 +153,7 @@ the switch is watching for silence, not for a signal.
|
|||||||
kubectl delete namespace terdut-operator-demo
|
kubectl delete namespace terdut-operator-demo
|
||||||
```
|
```
|
||||||
|
|
||||||
The operator's own finalizers clean up everything cross-namespace
|
The operator's finalizers delete each team on the server (with its
|
||||||
(credentials Secrets in the operator's namespace, server-side team/rule/
|
escalation, switches and integrations) before this namespace's objects
|
||||||
integration rows) before this namespace's objects actually disappear —
|
actually disappear — give it a few seconds past the `kubectl delete`
|
||||||
give it a few seconds past the `kubectl delete` returning.
|
returning. A team with open incidents is not deleted until they are resolved.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ scenarios:
|
|||||||
disk-full critical -- disk usage above 95%
|
disk-full critical -- disk usage above 95%
|
||||||
pod-crash error -- a pod crash-looping
|
pod-crash error -- a pod crash-looping
|
||||||
heartbeat critical -- the team's dead man's switch heartbeat
|
heartbeat critical -- the team's dead man's switch heartbeat
|
||||||
(matches the matcher in 06/07-deadman-*.yaml -- send this
|
(matches the matcher in the deadmanSwitches in 02/03-team-*.yaml -- send this
|
||||||
repeatedly to keep the switch alive, or stop sending it and
|
repeatedly to keep the switch alive, or stop sending it and
|
||||||
watch terdut-server open an incident on its own once
|
watch terdut-server open an incident on its own once
|
||||||
`timeout` passes with no heartbeat. "resolve" is not a valid
|
`timeout` passes with no heartbeat. "resolve" is not a valid
|
||||||
@@ -74,7 +74,7 @@ case "$scenario" in
|
|||||||
disk-full) alertname=TerdutDemoDiskFull severity=critical summary="Disk usage above 95% on /data" ;;
|
disk-full) alertname=TerdutDemoDiskFull severity=critical summary="Disk usage above 95% on /data" ;;
|
||||||
pod-crash) alertname=TerdutDemoPodCrashLooping severity=error summary="Pod web-7f8b9 is crash-looping (5 restarts in 10m)" ;;
|
pod-crash) alertname=TerdutDemoPodCrashLooping severity=error summary="Pod web-7f8b9 is crash-looping (5 restarts in 10m)" ;;
|
||||||
heartbeat)
|
heartbeat)
|
||||||
# Must match 06-deadman-platform.yaml / 07-deadman-payments.yaml's own
|
# Must match 02-team-platform.yaml / 03-team-payments.yaml's own
|
||||||
# matcher exactly -- that's what makes this a heartbeat rather than a
|
# matcher exactly -- that's what makes this a heartbeat rather than a
|
||||||
# third ordinary alert.
|
# third ordinary alert.
|
||||||
case "$team" in
|
case "$team" in
|
||||||
@@ -95,7 +95,7 @@ esac
|
|||||||
|
|
||||||
secret_name="terdutalertsource-${team}-terdut-webhook"
|
secret_name="terdutalertsource-${team}-terdut-webhook"
|
||||||
key="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.key}' | base64 -d)"
|
key="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.key}' | base64 -d)"
|
||||||
[ -n "$key" ] || { echo "fire-alerts.sh: empty key read from Secret $secret_name -- has 08/09-alertsource-*.yaml reconciled yet?" >&2; exit 1; }
|
[ -n "$key" ] || { echo "fire-alerts.sh: empty key read from Secret $secret_name -- has 04/05-alertsource-*.yaml reconciled yet?" >&2; exit 1; }
|
||||||
|
|
||||||
# Stable per (team, scenario) so a resolve targets the same alert a fire
|
# Stable per (team, scenario) so a resolve targets the same alert a fire
|
||||||
# created: terdut-server correlates on (team_id, fingerprint), not on
|
# created: terdut-server correlates on (team_id, fingerprint), not on
|
||||||
|
|||||||
@@ -9,9 +9,5 @@ resources:
|
|||||||
- 01-server.yaml
|
- 01-server.yaml
|
||||||
- 02-team-platform.yaml
|
- 02-team-platform.yaml
|
||||||
- 03-team-payments.yaml
|
- 03-team-payments.yaml
|
||||||
- 04-escalation-platform.yaml
|
- 04-alertsource-platform.yaml
|
||||||
- 05-escalation-payments.yaml
|
- 05-alertsource-payments.yaml
|
||||||
- 06-deadman-platform.yaml
|
|
||||||
- 07-deadman-payments.yaml
|
|
||||||
- 08-alertsource-platform.yaml
|
|
||||||
- 09-alertsource-payments.yaml
|
|
||||||
|
|||||||
+47
-95
@@ -103,7 +103,7 @@ install_operator() {
|
|||||||
apply_demo() {
|
apply_demo() {
|
||||||
log "creating namespace $NAMESPACE"
|
log "creating namespace $NAMESPACE"
|
||||||
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
log "applying demo CRs (00-09) into $NAMESPACE"
|
log "applying demo CRs (00-05) into $NAMESPACE"
|
||||||
kubectl apply -n "$NAMESPACE" -k "$SCRIPT_DIR" >/dev/null
|
kubectl apply -n "$NAMESPACE" -k "$SCRIPT_DIR" >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,28 +116,18 @@ wait_for_objects() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Just the server and the two teams -- everything redeem_platform_invite and
|
# Only the server: the teams cannot reach Ready until alice exists (their
|
||||||
# join_payments_team need. Deliberately NOT the escalation rules here: this
|
# escalation ladders name her, and terdut-server resolves usernames when the
|
||||||
# demo kit's own terdutescalationrule-platform names alice as a level-1
|
# ladder is applied), and alice is created below, through the server itself.
|
||||||
# target, and that CR cannot reach Ready until alice actually exists
|
wait_for_server_ready() {
|
||||||
# (terdut-server resolves every named username at reconcile time, not just
|
wait_for_objects "terdutserver/terdut-operator-demo"
|
||||||
# at escalation time) -- a real dependency this script has to satisfy by
|
|
||||||
# creating her first, not something kubectl wait can be told to ignore.
|
|
||||||
wait_for_teams_ready() {
|
|
||||||
wait_for_objects \
|
|
||||||
"terdutserver/terdut-operator-demo" \
|
|
||||||
"terdutteam/terdutteam-platform" \
|
|
||||||
"terdutteam/terdutteam-payments"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Everything that was waiting on alice (or just on the teams above, now
|
# Everything that was waiting on alice to exist.
|
||||||
# already satisfied) to exist.
|
|
||||||
wait_for_remaining_ready() {
|
wait_for_remaining_ready() {
|
||||||
wait_for_objects \
|
wait_for_objects \
|
||||||
"terdutescalationrule/terdutescalationrule-platform" \
|
"terdutteam/terdutteam-platform" \
|
||||||
"terdutescalationrule/terdutescalationrule-payments" \
|
"terdutteam/terdutteam-payments" \
|
||||||
"terdutdeadmanswitch/terdutdeadmanswitch-platform" \
|
|
||||||
"terdutdeadmanswitch/terdutdeadmanswitch-payments" \
|
|
||||||
"terdutalertsource/terdutalertsource-platform" \
|
"terdutalertsource/terdutalertsource-platform" \
|
||||||
"terdutalertsource/terdutalertsource-payments"
|
"terdutalertsource/terdutalertsource-payments"
|
||||||
}
|
}
|
||||||
@@ -173,91 +163,54 @@ start_port_forward() {
|
|||||||
log "port-forward ready (pid $STARTED_PF_PID, log $PF_LOGFILE)"
|
log "port-forward ready (pid $STARTED_PF_PID, log $PF_LOGFILE)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Redeems Platform's own invite link -- minted by its TerdutTeam
|
# Creates alice as the install's first user and administrator through
|
||||||
# (02-team-platform.yaml's spec.invite.enabled, reconciled through that
|
# /api/bootstrap, which is open until a first user exists -- the operator
|
||||||
# team's own already-working team-scoped credential, which requireTeamOwner
|
# authenticates with its own seeded key and never uses it, so this is how a
|
||||||
# already treats as owner-equivalent for /invites -- ratified, not a
|
# person gets in. An administrator may manage any team, so alice's own key is
|
||||||
# workaround, in terdut-server's SERVICE-ACCOUNTS.md) and redeemed through
|
# enough to add her to both; the teams' own status.teamID is set as soon as
|
||||||
# the ordinary signup endpoint. Invite redemption bypasses signup_mode
|
# the operator has created each team, even while they still wait on her.
|
||||||
# entirely (terdut-server's internal/api/signup.go), so this needs no admin
|
create_alice_and_join_teams() {
|
||||||
# credential, no signup_mode flip, and no direct Postgres access at all --
|
# A re-run: she exists already (and was added to the teams the first time).
|
||||||
# unlike an earlier version of this script, before terdut-operator grew
|
|
||||||
# this feature (see niklas/terdut-server#23).
|
|
||||||
redeem_platform_invite() {
|
|
||||||
log "reading Platform's invite link"
|
|
||||||
local secret_name invite_url invite_token tries=0
|
|
||||||
until secret_name="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-platform \
|
|
||||||
-o jsonpath='{.status.inviteSecretRef.name}' 2>/dev/null)" && [ -n "$secret_name" ]; do
|
|
||||||
tries=$((tries + 1))
|
|
||||||
[ "$tries" -lt 30 ] || die "terdutteam-platform never reported status.inviteSecretRef -- check spec.invite.enabled and kubectl describe it"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
invite_url="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.url}' | base64 -d)"
|
|
||||||
invite_token="${invite_url##*invite=}"
|
|
||||||
[ -n "$invite_token" ] || die "could not parse an invite token out of $invite_url"
|
|
||||||
|
|
||||||
# A re-run: the invite was spent by the first run, and the server answers a
|
|
||||||
# spent invite with 403 before it ever looks at the username, so the 409
|
|
||||||
# handled below never arrives. If alice can already sign in, she exists.
|
|
||||||
local login_code
|
local login_code
|
||||||
login_code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
login_code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
-X POST "${BASE_URL}/api/login" -H 'Content-Type: application/json' \
|
-X POST "${BASE_URL}/api/login" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -n --arg u "$ALICE_USERNAME" --arg p "$DEMO_PASSWORD" '{username: $u, password: $p}')")"
|
-d "$(jq -n --arg u "$ALICE_USERNAME" --arg p "$DEMO_PASSWORD" '{username: $u, password: $p}')")"
|
||||||
if [ "$login_code" = "200" ]; then
|
if [ "$login_code" = "200" ]; then
|
||||||
log "account ${ALICE_USERNAME} already exists and can sign in, skipping signup (re-run detected)"
|
log "account ${ALICE_USERNAME} already exists and can sign in, skipping creation (re-run detected)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "signing up ${ALICE_USERNAME} via Platform's invite"
|
log "creating ${ALICE_USERNAME} as the first user (administrator)"
|
||||||
local body resp_file code
|
local resp_file code alice_key
|
||||||
body="$(jq -n \
|
|
||||||
--arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" \
|
|
||||||
--arg p "$DEMO_PASSWORD" --arg i "$invite_token" \
|
|
||||||
'{username: $u, email: $e, password: $p, invite: $i}')"
|
|
||||||
resp_file="$(mktemp)"
|
resp_file="$(mktemp)"
|
||||||
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
|
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
|
||||||
-X POST "${BASE_URL}/api/signup" -H 'Content-Type: application/json' -d "$body")"
|
-X POST "${BASE_URL}/api/bootstrap" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" --arg p "$DEMO_PASSWORD" \
|
||||||
case "$code" in
|
'{username: $u, email: $e, password: $p}')")"
|
||||||
201) log "created local account ${ALICE_USERNAME} (joined Platform)" ;;
|
[ "$code" = "201" ] || die "bootstrap failed (HTTP $code): $(cat "$resp_file")"
|
||||||
409) log "account ${ALICE_USERNAME} already exists, skipping (re-run detected)" ;;
|
alice_key="$(jq -r '.api_key.key' "$resp_file")"
|
||||||
*) die "signup failed (HTTP $code): $(cat "$resp_file")" ;;
|
|
||||||
esac
|
|
||||||
rm -f "$resp_file"
|
rm -f "$resp_file"
|
||||||
}
|
|
||||||
|
|
||||||
# redeem_platform_invite's signup already used up alice's one signup -- a
|
local team team_id
|
||||||
# second POST /api/signup would just 409 on the taken username, it doesn't
|
for team in terdutteam-platform terdutteam-payments; do
|
||||||
# join an existing account to another team. Payments is joined through the
|
team_id=""
|
||||||
# ordinary team-scoped member endpoint instead, using that team's own
|
local tries=0
|
||||||
# already-working team-scoped credential (owner-equivalent, same reach the
|
until team_id="$(kubectl -n "$NAMESPACE" get terdutteam "$team" -o jsonpath='{.status.teamID}' 2>/dev/null)" \
|
||||||
# invite route above relies on) and alice's user id resolved via
|
&& [ -n "$team_id" ]; do
|
||||||
# GET /api/users -- the same lookup tdclient.GetUserByUsername does
|
tries=$((tries + 1))
|
||||||
# operator-side. The endpoint upserts on (team_id, user_id), so this is
|
[ "$tries" -lt 60 ] || die "$team never reported status.teamID -- kubectl describe it"
|
||||||
# naturally idempotent across re-runs with no separate conflict handling
|
sleep 1
|
||||||
# needed.
|
done
|
||||||
join_payments_team() {
|
local alice_id
|
||||||
log "adding ${ALICE_USERNAME} to Payments"
|
alice_id="$(curl -sS -H "Authorization: Bearer $alice_key" "${BASE_URL}/api/users" \
|
||||||
local payments_id payments_secret payments_key alice_id resp_file code
|
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
|
||||||
payments_id="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments -o jsonpath='{.status.teamID}')"
|
code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
[ -n "$payments_id" ] || die "could not read status.teamID off terdutteam-payments"
|
-X POST "${BASE_URL}/api/teams/${team_id}/members" \
|
||||||
payments_secret="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments \
|
-H "Authorization: Bearer $alice_key" -H 'Content-Type: application/json' \
|
||||||
-o jsonpath='{.status.credentialsSecretRef.name}')"
|
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "owner"}')")"
|
||||||
[ -n "$payments_secret" ] || die "terdutteam-payments has no status.credentialsSecretRef yet"
|
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to ${team} failed (HTTP $code)"
|
||||||
payments_key="$(kubectl -n "$OPERATOR_NAMESPACE" get secret "$payments_secret" -o jsonpath='{.data.token}' | base64 -d)"
|
log "added ${ALICE_USERNAME} to ${team}"
|
||||||
|
done
|
||||||
alice_id="$(curl -sS -H "Authorization: Bearer $payments_key" "${BASE_URL}/api/users" \
|
|
||||||
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
|
|
||||||
[ -n "$alice_id" ] || die "could not resolve ${ALICE_USERNAME}'s user id via GET /api/users"
|
|
||||||
|
|
||||||
resp_file="$(mktemp)"
|
|
||||||
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
|
|
||||||
-X POST "${BASE_URL}/api/teams/${payments_id}/members" \
|
|
||||||
-H "Authorization: Bearer $payments_key" -H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "member"}')")"
|
|
||||||
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to Payments failed (HTTP $code): $(cat "$resp_file")"
|
|
||||||
log "added ${ALICE_USERNAME} to Payments"
|
|
||||||
rm -f "$resp_file"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fire_demo_alerts() {
|
fire_demo_alerts() {
|
||||||
@@ -350,10 +303,9 @@ main() {
|
|||||||
ensure_kind_cluster
|
ensure_kind_cluster
|
||||||
install_operator
|
install_operator
|
||||||
apply_demo
|
apply_demo
|
||||||
wait_for_teams_ready
|
wait_for_server_ready
|
||||||
start_port_forward
|
start_port_forward
|
||||||
redeem_platform_invite
|
create_alice_and_join_teams
|
||||||
join_payments_team
|
|
||||||
wait_for_remaining_ready
|
wait_for_remaining_ready
|
||||||
fire_demo_alerts
|
fire_demo_alerts
|
||||||
print_summary
|
print_summary
|
||||||
|
|||||||
@@ -76,15 +76,15 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/mod v0.38.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/net v0.60.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||||
|
|||||||
@@ -196,24 +196,24 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// childError carries a condition reason/message, the same role teamError
|
// childError carries a condition reason/message, the same role teamError and
|
||||||
// and databaseError play for their own controllers: an expected,
|
// databaseError play for their own controllers: an expected,
|
||||||
// requeue-and-retry outcome, not a reconcile failure.
|
// requeue-and-retry outcome, not a reconcile failure.
|
||||||
type childError struct {
|
type childError struct {
|
||||||
reason string
|
reason string
|
||||||
@@ -21,12 +22,13 @@ type childError struct {
|
|||||||
|
|
||||||
func (e *childError) Error() string { return e.message }
|
func (e *childError) Error() string { return e.message }
|
||||||
|
|
||||||
// resolveTeamAndClient implements DESIGN.md §5's "every child resolves its
|
// resolveTeamAndClient finds the TerdutTeam a child (TerdutAlertSource) names,
|
||||||
// own teamRef -> TerdutTeam.status, never chains up to TerdutServer"
|
// requires it to be Ready, and returns a client for its server authenticated
|
||||||
// rule -- shared by TerdutEscalationRule and TerdutDeadmanSwitch, which
|
// with that server's operator key. The server is read from the team's own
|
||||||
// both need exactly this and nothing else to call terdut-server's API.
|
// serverRef: consent was already checked when the team went Ready, and does not
|
||||||
|
// gate a resource the team owns.
|
||||||
func resolveTeamAndClient(
|
func resolveTeamAndClient(
|
||||||
ctx context.Context, c client.Client, operatorNamespace, namespace string,
|
ctx context.Context, c client.Client, namespace string,
|
||||||
ref terdutv1alpha1.TerdutTeamRef, newClient func(string) *tdclient.Client,
|
ref terdutv1alpha1.TerdutTeamRef, newClient func(string) *tdclient.Client,
|
||||||
) (*terdutv1alpha1.TerdutTeam, *tdclient.Client, *childError) {
|
) (*terdutv1alpha1.TerdutTeam, *tdclient.Client, *childError) {
|
||||||
var team terdutv1alpha1.TerdutTeam
|
var team terdutv1alpha1.TerdutTeam
|
||||||
@@ -39,16 +41,24 @@ func resolveTeamAndClient(
|
|||||||
}
|
}
|
||||||
return nil, nil, &childError{reason: terdutv1alpha1.ReasonTeamRefNotFound, message: err.Error()}
|
return nil, nil, &childError{reason: terdutv1alpha1.ReasonTeamRefNotFound, message: err.Error()}
|
||||||
}
|
}
|
||||||
if team.Status.CredentialsSecretRef == nil || team.Status.TeamID == 0 {
|
if team.Status.TeamID == 0 || !meta.IsStatusConditionTrue(team.Status.Conditions, terdutv1alpha1.ConditionReady) {
|
||||||
return nil, nil, &childError{
|
return nil, nil, &childError{
|
||||||
reason: terdutv1alpha1.ReasonWaitingForTeam,
|
reason: terdutv1alpha1.ReasonWaitingForTeam,
|
||||||
message: fmt.Sprintf("TerdutTeam %q is not Ready yet", ref.Name),
|
message: fmt.Sprintf("TerdutTeam %q is not Ready yet", ref.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
teamKey, err := readOperatorSecret(ctx, c, operatorNamespace, team.Status.CredentialsSecretRef)
|
srvNS := team.Spec.ServerRef.Namespace
|
||||||
|
if srvNS == "" {
|
||||||
|
srvNS = team.Namespace
|
||||||
|
}
|
||||||
|
var srv terdutv1alpha1.TerdutServer
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: srvNS, Name: team.Spec.ServerRef.Name}, &srv); err != nil {
|
||||||
|
return nil, nil, &childError{reason: terdutv1alpha1.ReasonWaitingForTeam, message: err.Error()}
|
||||||
|
}
|
||||||
|
key, err := readOperatorKey(ctx, c, &srv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, &childError{reason: terdutv1alpha1.ReasonWaitingForTeam, message: err.Error()}
|
return nil, nil, &childError{reason: terdutv1alpha1.ReasonWaitingForTeam, message: err.Error()}
|
||||||
}
|
}
|
||||||
return &team, newClient(team.Status.ServerEndpoint).WithToken(teamKey), nil
|
return &team, newClient(serviceURL(&srv)).WithToken(key), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errJSONKey is the key every error body the fake writes uses, and
|
||||||
|
// deadmanSwitchesPath the literal shared by its dead man's switch routes --
|
||||||
|
// goconst would otherwise flag the repeats.
|
||||||
|
const (
|
||||||
|
errJSONKey = "error"
|
||||||
|
deadmanSwitchesPath = "/deadman/switches"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTerdutServer reproduces the parts of terdut-server's API the controllers
|
||||||
|
// call, with the same status codes and idempotency rules (POST /api/teams
|
||||||
|
// keyed by external_id, unique team names, unique switch names per team), so a
|
||||||
|
// controller test exercises the real contract and not a canned reply.
|
||||||
|
type fakeTerdutServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// expectKey, when set, makes every request without that bearer token a 401.
|
||||||
|
expectKey string
|
||||||
|
|
||||||
|
nextTeamID int64
|
||||||
|
teams map[string]int64 // name -> id
|
||||||
|
teamNames map[int64]string // id -> current name
|
||||||
|
teamExt map[string]int64 // external_id -> id
|
||||||
|
teamOIDC map[int64][2]string
|
||||||
|
teamDelete map[int64]bool // id -> true once DELETEd
|
||||||
|
|
||||||
|
// unknownUsers are usernames the escalation PUT answers 400 "unknown user" for.
|
||||||
|
unknownUsers map[string]bool
|
||||||
|
escalation map[int64]tdclient.SetEscalationRequest
|
||||||
|
|
||||||
|
nextSwitchID int64
|
||||||
|
switches map[int64]map[int64]tdclient.DeadmanSwitch // teamID -> switchID -> switch
|
||||||
|
switchDelete map[int64]bool
|
||||||
|
|
||||||
|
nextIntegrationID int64
|
||||||
|
integrations map[int64]map[int64]tdclient.Integration
|
||||||
|
integrationDelete map[int64]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||||
|
f := &fakeTerdutServer{
|
||||||
|
teams: map[string]int64{},
|
||||||
|
teamNames: map[int64]string{},
|
||||||
|
teamExt: map[string]int64{},
|
||||||
|
teamOIDC: map[int64][2]string{},
|
||||||
|
teamDelete: map[int64]bool{},
|
||||||
|
unknownUsers: map[string]bool{},
|
||||||
|
escalation: map[int64]tdclient.SetEscalationRequest{},
|
||||||
|
switches: map[int64]map[int64]tdclient.DeadmanSwitch{},
|
||||||
|
switchDelete: map[int64]bool{},
|
||||||
|
integrations: map[int64]map[int64]tdclient.Integration{},
|
||||||
|
integrationDelete: map[int64]bool{},
|
||||||
|
}
|
||||||
|
return f, httptest.NewServer(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.expectKey != "" && r.Header.Get("Authorization") != "Bearer "+f.expectKey {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{errJSONKey: "invalid or expired API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/version":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"version": "test"})
|
||||||
|
|
||||||
|
case r.URL.Path == "/api/teams" && r.Method == http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if id, ok := f.teamExt[req.ExternalID]; ok && req.ExternalID != "" {
|
||||||
|
writeJSON(w, http.StatusOK, tdclient.Team{ID: id, Name: f.teamNames[id]})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.teams[req.Name]; exists {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.nextTeamID++
|
||||||
|
id := f.nextTeamID
|
||||||
|
f.teams[req.Name] = id
|
||||||
|
f.teamNames[id] = req.Name
|
||||||
|
if req.ExternalID != "" {
|
||||||
|
f.teamExt[req.ExternalID] = id
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, tdclient.Team{ID: id, Name: req.Name})
|
||||||
|
|
||||||
|
default:
|
||||||
|
if id, rest, ok := parseTeamSubPath(r.URL.Path); ok {
|
||||||
|
f.handleTeamSubPath(w, r, id, rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTeamSubPath answers everything under /api/teams/{id}. rest is whatever
|
||||||
|
// parseTeamSubPath found after "/api/teams/{id}" -- "" for the bare resource.
|
||||||
|
func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
||||||
|
switch {
|
||||||
|
case rest == "" && r.Method == http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
oldName, exists := f.teamNames[id]
|
||||||
|
if !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if other, taken := f.teams[req.Name]; taken && other != id {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.teams, oldName)
|
||||||
|
f.teamNames[id] = req.Name
|
||||||
|
f.teams[req.Name] = id
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "" && r.Method == http.MethodDelete:
|
||||||
|
name, exists := f.teamNames[id]
|
||||||
|
if !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.teams, name)
|
||||||
|
delete(f.teamNames, id)
|
||||||
|
f.teamDelete[id] = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "/oidc-groups" && r.Method == http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if _, exists := f.teamNames[id]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "/escalation" && r.Method == http.MethodPut:
|
||||||
|
var req tdclient.SetEscalationRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
for _, l := range req.Levels {
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
if t.Username != "" && f.unknownUsers[t.Username] {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{errJSONKey: fmt.Sprintf("unknown user %q", t.Username)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.escalation[id] = req
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == deadmanSwitchesPath || strings.HasPrefix(rest, deadmanSwitchesPath+"/"):
|
||||||
|
f.handleDeadmanSubPath(w, r, id, rest)
|
||||||
|
|
||||||
|
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
|
||||||
|
f.handleIntegrationSubPath(w, r, id, rest)
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeadmanSubPath answers GET/POST /api/teams/{id}/deadman/switches and
|
||||||
|
// PUT/DELETE .../deadman/switches/{switchID} -- split out of
|
||||||
|
// handleTeamSubPath for the same gocyclo reason as handleIntegrationSubPath.
|
||||||
|
func (f *fakeTerdutServer) handleDeadmanSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
||||||
|
switch {
|
||||||
|
case rest == deadmanSwitchesPath && r.Method == http.MethodGet:
|
||||||
|
existing := f.switches[id]
|
||||||
|
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
|
||||||
|
for _, s := range existing {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
|
||||||
|
case rest == deadmanSwitchesPath && r.Method == http.MethodPost:
|
||||||
|
var req deadmanSwitchFakeRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
name := req.Name
|
||||||
|
for _, s := range f.switches[id] {
|
||||||
|
if s.Name == name {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a switch with that name already exists in this team"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.nextSwitchID++
|
||||||
|
switchID := f.nextSwitchID
|
||||||
|
sw := tdclient.DeadmanSwitch{
|
||||||
|
ID: switchID, Name: name, Matcher: req.Matcher,
|
||||||
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
|
}
|
||||||
|
if f.switches[id] == nil {
|
||||||
|
f.switches[id] = map[int64]tdclient.DeadmanSwitch{}
|
||||||
|
}
|
||||||
|
f.switches[id][switchID] = sw
|
||||||
|
writeJSON(w, http.StatusCreated, sw)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodPut:
|
||||||
|
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.switches[id][switchID]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deadmanSwitchFakeRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
name := req.Name
|
||||||
|
if name == "" {
|
||||||
|
name = f.switches[id][switchID].Name
|
||||||
|
}
|
||||||
|
f.switches[id][switchID] = tdclient.DeadmanSwitch{
|
||||||
|
ID: switchID, Name: name, Matcher: req.Matcher,
|
||||||
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodDelete:
|
||||||
|
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.switches[id][switchID]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.switches[id], switchID)
|
||||||
|
f.switchDelete[switchID] = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIntegrationSubPath answers POST /api/teams/{id}/integrations,
|
||||||
|
// PATCH .../integrations/{integrationID} and DELETE .../integrations/{integrationID}
|
||||||
|
// -- split out of handleTeamSubPath so that switch's own cyclomatic
|
||||||
|
// complexity stays under golangci-lint's gocyclo threshold.
|
||||||
|
func (f *fakeTerdutServer) handleIntegrationSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
||||||
|
switch {
|
||||||
|
case rest == "/integrations" && r.Method == http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
f.nextIntegrationID++
|
||||||
|
integID := f.nextIntegrationID
|
||||||
|
integ := tdclient.Integration{
|
||||||
|
ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name,
|
||||||
|
// Key/URL are only ever in *this* response -- never again,
|
||||||
|
// matching terdut-server's own one-time-show semantics
|
||||||
|
// (DESIGN.md §4.5) -- so what's stored for later GET/PATCH
|
||||||
|
// calls in this fake deliberately omits them too.
|
||||||
|
Key: fmt.Sprintf("webhook-key-%d", integID),
|
||||||
|
URL: fmt.Sprintf("https://terdut.example.invalid/api/integrations/webhook-key-%d/%s", integID, req.Kind),
|
||||||
|
}
|
||||||
|
if f.integrations[id] == nil {
|
||||||
|
f.integrations[id] = map[int64]tdclient.Integration{}
|
||||||
|
}
|
||||||
|
f.integrations[id][integID] = tdclient.Integration{ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name}
|
||||||
|
writeJSON(w, http.StatusCreated, integ)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodPatch:
|
||||||
|
integID, ok := parseTrailingID(rest, "/integrations/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, exists := f.integrations[id][integID]
|
||||||
|
if !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
existing.Name = req.Name
|
||||||
|
f.integrations[id][integID] = existing
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodDelete:
|
||||||
|
integID, ok := parseTrailingID(rest, "/integrations/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.integrations[id][integID]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.integrations[id], integID)
|
||||||
|
f.integrationDelete[integID] = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSwitchFakeRequest mirrors tdclient's own (unexported)
|
||||||
|
// deadmanSwitchRequest -- the fake needs its own copy to decode the same
|
||||||
|
// wire shape without reaching across package boundaries for an internal type.
|
||||||
|
type deadmanSwitchFakeRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTeamSubPath splits "/api/teams/{id}" from anything after it --
|
||||||
|
// "" for an exact match, "/oidc-groups", "/escalation", "/deadman/switches"
|
||||||
|
// or "/deadman/switches/{switchID}" otherwise. Doesn't itself validate the
|
||||||
|
// suffix; handleTeamSubPath's own switch does that.
|
||||||
|
func parseTeamSubPath(path string) (id int64, rest string, ok bool) {
|
||||||
|
const prefix = "/api/teams/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
trimmed := path[len(prefix):]
|
||||||
|
parts := strings.SplitN(trimmed, "/", 2)
|
||||||
|
parsedID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parsedID, "", true
|
||||||
|
}
|
||||||
|
return parsedID, "/" + parts[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTrailingID parses the numeric id after prefix within rest, e.g.
|
||||||
|
// parseTrailingID("/deadman/switches/7", "/deadman/switches/") -> 7, true.
|
||||||
|
func parseTrailingID(rest, prefix string) (id int64, ok bool) {
|
||||||
|
parsedID, err := strconv.ParseInt(strings.TrimPrefix(rest, prefix), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsedID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// credentialsSecretDataKey is the fixed data key every generated
|
|
||||||
// credentials Secret this controller writes uses — "whatever a human chose"
|
|
||||||
// only applied to the bring-your-own input an earlier design draft had and
|
|
||||||
// removed (DESIGN.md §6); every Secret any controller in this repo
|
|
||||||
// generates itself uses this one key.
|
|
||||||
const credentialsSecretDataKey = "token"
|
|
||||||
|
|
||||||
// writeOperatorSecret creates or replaces a Secret in namespace (always the
|
|
||||||
// operator's own, DESIGN.md §6) holding one raw value under
|
|
||||||
// credentialsSecretDataKey. Shared by every controller that generates a
|
|
||||||
// credential there -- TerdutServer's instance-scoped key and the bootstrap
|
|
||||||
// admin-key checkpoint, TerdutTeam's team-scoped key.
|
|
||||||
func writeOperatorSecret(ctx context.Context, c client.Client, namespace, name, rawValue string) error {
|
|
||||||
secret := &corev1.Secret{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
|
||||||
Data: map[string][]byte{credentialsSecretDataKey: []byte(rawValue)},
|
|
||||||
}
|
|
||||||
if err := c.Create(ctx, secret); err != nil {
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
return c.Update(ctx, secret)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readOperatorSecret reads one generated credential back, by the
|
|
||||||
// SecretKeyRef a controller's own status stores (always resolved in the
|
|
||||||
// operator's own namespace, never the referencing CR's -- DESIGN.md §6).
|
|
||||||
func readOperatorSecret(ctx context.Context, c client.Client, namespace string, ref *terdutv1alpha1.SecretKeyRef) (string, error) {
|
|
||||||
var secret corev1.Secret
|
|
||||||
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(secret.Data[ref.Key]), nil
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,9 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,9 +41,8 @@ type TerdutAlertSourceReconciler struct {
|
|||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
|
|
||||||
OperatorNamespace string
|
Recorder recorder.EventRecorder
|
||||||
Recorder recorder.EventRecorder
|
NewClient func(endpoint string) *tdclient.Client
|
||||||
NewClient func(endpoint string) *tdclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutalertsources,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutalertsources,verbs=get;list;watch;create;update;patch;delete
|
||||||
@@ -79,7 +80,7 @@ func (r *TerdutAlertSourceReconciler) Reconcile(ctx context.Context, req ctrl.Re
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, as.Namespace, as.Spec.TeamRef, newClient)
|
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, as.Namespace, as.Spec.TeamRef, newClient)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return r.setNotReady(ctx, &as, resolveErr.reason, resolveErr.message, waitInterval)
|
return r.setNotReady(ctx, &as, resolveErr.reason, resolveErr.message, waitInterval)
|
||||||
}
|
}
|
||||||
@@ -111,7 +112,27 @@ func (r *TerdutAlertSourceReconciler) Reconcile(ctx context.Context, req ctrl.Re
|
|||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
} else if err := tc.RenameIntegration(ctx, teamID, as.Status.IntegrationID, as.Spec.Name); err != nil {
|
} else if err := tc.RenameIntegration(ctx, teamID, as.Status.IntegrationID, as.Spec.Name); err != nil {
|
||||||
return ctrl.Result{}, fmt.Errorf("PATCH /api/teams/%d/integrations/%d: %w", teamID, as.Status.IntegrationID, err)
|
se, ok := errors.AsType[*tdclient.StatusError](err)
|
||||||
|
if !ok || se.Code != http.StatusNotFound {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("PATCH /api/teams/%d/integrations/%d: %w", teamID, as.Status.IntegrationID, err)
|
||||||
|
}
|
||||||
|
// Deleted on the server behind our back, so the webhook URL
|
||||||
|
// already stopped working: recreate it (a new URL, in the same
|
||||||
|
// Secret) rather than fail forever. reconcileCreate trusts the
|
||||||
|
// Secret's existence as "already created", so drop it first.
|
||||||
|
lostID := as.Status.IntegrationID
|
||||||
|
if err := r.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("deleting stale webhook Secret %s/%s: %w", as.Namespace, secretName, err)
|
||||||
|
}
|
||||||
|
as.Status.IntegrationID = 0
|
||||||
|
if err := r.reconcileCreate(ctx, &as, tc, teamID); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(&as, nil, corev1.EventTypeWarning, "IntegrationRecreated", "IntegrationRecreated",
|
||||||
|
"integration %d no longer exists on the server; created %d with a new webhook URL (Secret %s)",
|
||||||
|
lostID, as.Status.IntegrationID, secretName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +300,7 @@ func (r *TerdutAlertSourceReconciler) reconcileDelete(
|
|||||||
|
|
||||||
if as.Status.IntegrationID != 0 {
|
if as.Status.IntegrationID != 0 {
|
||||||
if team, tc, resolveErr := resolveTeamAndClient(
|
if team, tc, resolveErr := resolveTeamAndClient(
|
||||||
ctx, r.Client, r.OperatorNamespace, as.Namespace, as.Spec.TeamRef, newClient,
|
ctx, r.Client, as.Namespace, as.Spec.TeamRef, newClient,
|
||||||
); resolveErr == nil {
|
); resolveErr == nil {
|
||||||
if err := tc.DeleteIntegration(ctx, team.Status.TeamID, as.Status.IntegrationID); err != nil {
|
if err := tc.DeleteIntegration(ctx, team.Status.TeamID, as.Status.IntegrationID); err != nil {
|
||||||
if r.Recorder != nil {
|
if r.Recorder != nil {
|
||||||
|
|||||||
@@ -34,14 +34,13 @@ var _ = Describe("TerdutAlertSource Controller", func() {
|
|||||||
fake, fakeSrv = newFakeTerdutServer()
|
fake, fakeSrv = newFakeTerdutServer()
|
||||||
DeferCleanup(fakeSrv.Close)
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
|
||||||
srv = bootstrapReadyTerdutServer(ctx, uniqueName("asserver"), fakeSrv.URL)
|
srv = readyTerdutServer(ctx, uniqueName("asserver"))
|
||||||
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("asteam"), srv, fakeSrv.URL)
|
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("asteam"), srv, fakeSrv.URL)
|
||||||
|
|
||||||
reconciler = &TerdutAlertSourceReconciler{
|
reconciler = &TerdutAlertSourceReconciler{
|
||||||
Client: k8sClient,
|
Client: k8sClient,
|
||||||
Scheme: k8sClient.Scheme(),
|
Scheme: k8sClient.Scheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
|
||||||
}
|
}
|
||||||
srcName = uniqueName("alertsource")
|
srcName = uniqueName("alertsource")
|
||||||
srcKey = types.NamespacedName{Name: srcName, Namespace: operatorNamespace}
|
srcKey = types.NamespacedName{Name: srcName, Namespace: operatorNamespace}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
const deadmanFinalizerName = "terdut.ryuvia.com/terdutdeadmanswitch"
|
|
||||||
|
|
||||||
// TerdutDeadmanSwitchReconciler reconciles a TerdutDeadmanSwitch object.
|
|
||||||
type TerdutDeadmanSwitchReconciler struct {
|
|
||||||
client.Client
|
|
||||||
Scheme *runtime.Scheme
|
|
||||||
|
|
||||||
OperatorNamespace string
|
|
||||||
Recorder recorder.EventRecorder
|
|
||||||
NewClient func(endpoint string) *tdclient.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches,verbs=get;list;watch;create;update;patch;delete
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/status,verbs=get;update;patch
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/finalizers,verbs=update
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch
|
|
||||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
|
||||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
|
||||||
|
|
||||||
func (r *TerdutDeadmanSwitchReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
||||||
log := logf.FromContext(ctx)
|
|
||||||
|
|
||||||
var sw terdutv1alpha1.TerdutDeadmanSwitch
|
|
||||||
if err := r.Get(ctx, req.NamespacedName, &sw); err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
newClient := r.NewClient
|
|
||||||
if newClient == nil {
|
|
||||||
newClient = tdclient.New
|
|
||||||
}
|
|
||||||
|
|
||||||
if !sw.DeletionTimestamp.IsZero() {
|
|
||||||
return r.reconcileDeadmanDelete(ctx, &sw, newClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !controllerutil.ContainsFinalizer(&sw, deadmanFinalizerName) {
|
|
||||||
controllerutil.AddFinalizer(&sw, deadmanFinalizerName)
|
|
||||||
if err := r.Update(ctx, &sw); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return r.setDeadmanNotReady(ctx, &sw, resolveErr.reason, resolveErr.message, waitInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout, err := time.ParseDuration(sw.Spec.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
return ctrl.Result{}, fmt.Errorf("spec.timeout %q: %w", sw.Spec.Timeout, err)
|
|
||||||
}
|
|
||||||
severity := sw.Spec.Severity
|
|
||||||
if severity == "" {
|
|
||||||
severity = "critical"
|
|
||||||
}
|
|
||||||
|
|
||||||
if sw.Status.SwitchID == 0 {
|
|
||||||
if err := r.createOrAdoptDeadmanSwitch(ctx, &sw, tc, team.Status.TeamID, timeout, severity); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
} else if err := tc.UpdateDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity); err != nil {
|
|
||||||
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/deadman/switches/%d: %w", team.Status.TeamID, sw.Status.SwitchID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
|
||||||
Status: metav1.ConditionTrue,
|
|
||||||
Reason: terdutv1alpha1.ReasonChildAdopted,
|
|
||||||
Message: fmt.Sprintf("switch %d applied on team %d", sw.Status.SwitchID, team.Status.TeamID),
|
|
||||||
})
|
|
||||||
sw.Status.ObservedGeneration = sw.Generation
|
|
||||||
if err := r.Status().Update(ctx, &sw); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(&sw, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
|
|
||||||
"dead man's switch applied")
|
|
||||||
}
|
|
||||||
log.Info("TerdutDeadmanSwitch applied", "name", sw.Name, "switchID", sw.Status.SwitchID)
|
|
||||||
|
|
||||||
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createOrAdoptDeadmanSwitch implements this resource's own idempotent-
|
|
||||||
// create shape (DESIGN.md §4.4, §5): there's no unique-name constraint
|
|
||||||
// server-side to 409 on, so this lists first and matches by name (the
|
|
||||||
// server's own derived name, when spec.name is empty) rather than adopting
|
|
||||||
// after a conflict the API would never actually raise.
|
|
||||||
func (r *TerdutDeadmanSwitchReconciler) createOrAdoptDeadmanSwitch(
|
|
||||||
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, tc *tdclient.Client,
|
|
||||||
teamID int64, timeout time.Duration, severity string,
|
|
||||||
) error {
|
|
||||||
existing, err := tc.ListDeadmanSwitches(ctx, teamID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("GET /api/teams/%d/deadman/switches: %w", teamID, err)
|
|
||||||
}
|
|
||||||
if sw.Spec.Name != "" {
|
|
||||||
for _, s := range existing {
|
|
||||||
if s.Name == sw.Spec.Name {
|
|
||||||
sw.Status.SwitchID = s.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := tc.CreateDeadmanSwitch(ctx, teamID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("POST /api/teams/%d/deadman/switches: %w", teamID, err)
|
|
||||||
}
|
|
||||||
sw.Status.SwitchID = created.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TerdutDeadmanSwitchReconciler) setDeadmanNotReady(
|
|
||||||
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, reason, message string, d time.Duration,
|
|
||||||
) (ctrl.Result, error) {
|
|
||||||
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
Reason: reason,
|
|
||||||
Message: message,
|
|
||||||
})
|
|
||||||
sw.Status.ObservedGeneration = sw.Generation
|
|
||||||
if err := r.Status().Update(ctx, sw); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, reason, reason, message)
|
|
||||||
}
|
|
||||||
return ctrl.Result{RequeueAfter: d}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileDeadmanDelete calls the real DELETE this resource actually has
|
|
||||||
// (unlike TerdutEscalationRule) if the team is still resolvable and a
|
|
||||||
// switch was ever created, then removes the finalizer unconditionally.
|
|
||||||
func (r *TerdutDeadmanSwitchReconciler) reconcileDeadmanDelete(
|
|
||||||
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, newClient func(string) *tdclient.Client,
|
|
||||||
) (ctrl.Result, error) {
|
|
||||||
if !controllerutil.ContainsFinalizer(sw, deadmanFinalizerName) {
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if sw.Status.SwitchID != 0 {
|
|
||||||
if team, tc, resolveErr := resolveTeamAndClient(
|
|
||||||
ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient,
|
|
||||||
); resolveErr == nil {
|
|
||||||
if err := tc.DeleteDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID); err != nil {
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controllerutil.RemoveFinalizer(sw, deadmanFinalizerName)
|
|
||||||
return ctrl.Result{}, r.Update(ctx, sw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupWithManager sets up the controller with the Manager.
|
|
||||||
func (r *TerdutDeadmanSwitchReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
||||||
if r.NewClient == nil {
|
|
||||||
r.NewClient = tdclient.New
|
|
||||||
}
|
|
||||||
if r.Recorder == nil {
|
|
||||||
r.Recorder = mgr.GetEventRecorder("terdutdeadmanswitch-controller")
|
|
||||||
}
|
|
||||||
return ctrl.NewControllerManagedBy(mgr).
|
|
||||||
For(&terdutv1alpha1.TerdutDeadmanSwitch{}).
|
|
||||||
Named("terdutdeadmanswitch").
|
|
||||||
Complete(r)
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http/httptest"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/types"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("TerdutDeadmanSwitch Controller", func() {
|
|
||||||
const operatorNamespace = "default"
|
|
||||||
|
|
||||||
var (
|
|
||||||
reconciler *TerdutDeadmanSwitchReconciler
|
|
||||||
fake *fakeTerdutServer
|
|
||||||
fakeSrv *httptest.Server
|
|
||||||
srv *terdutv1alpha1.TerdutServer
|
|
||||||
team *terdutv1alpha1.TerdutTeam
|
|
||||||
swName string
|
|
||||||
swKey types.NamespacedName
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func(ctx SpecContext) {
|
|
||||||
fake, fakeSrv = newFakeTerdutServer()
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
|
|
||||||
srv = bootstrapReadyTerdutServer(ctx, uniqueName("dmserver"), fakeSrv.URL)
|
|
||||||
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("dmteam"), srv, fakeSrv.URL)
|
|
||||||
|
|
||||||
reconciler = &TerdutDeadmanSwitchReconciler{
|
|
||||||
Client: k8sClient,
|
|
||||||
Scheme: k8sClient.Scheme(),
|
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
|
||||||
}
|
|
||||||
swName = uniqueName("switch")
|
|
||||||
swKey = types.NamespacedName{Name: swName, Namespace: operatorNamespace}
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterEach(func(ctx SpecContext) {
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
if err := k8sClient.Get(ctx, swKey, sw); err == nil {
|
|
||||||
sw.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, sw)
|
|
||||||
_ = k8sClient.Delete(ctx, sw)
|
|
||||||
}
|
|
||||||
|
|
||||||
teamKey := types.NamespacedName{Name: team.Name, Namespace: operatorNamespace}
|
|
||||||
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
|
|
||||||
team.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, team)
|
|
||||||
_ = k8sClient.Delete(ctx, team)
|
|
||||||
}
|
|
||||||
|
|
||||||
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
|
||||||
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
|
||||||
srv.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, srv)
|
|
||||||
_ = k8sClient.Delete(ctx, srv)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createSwitch := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, name, matcher, timeout string) {
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: swName, Namespace: operatorNamespace},
|
|
||||||
Spec: terdutv1alpha1.TerdutDeadmanSwitchSpec{
|
|
||||||
TeamRef: teamRef,
|
|
||||||
Name: name,
|
|
||||||
Matcher: matcher,
|
|
||||||
Timeout: timeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Expect(k8sClient.Create(ctx, sw)).To(Succeed())
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileOnce := func(ctx context.Context) {
|
|
||||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: swKey})
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
}
|
|
||||||
|
|
||||||
readyCondition := func(ctx context.Context) metav1.Condition {
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
|
||||||
c := meta.FindStatusCondition(sw.Status.Conditions, terdutv1alpha1.ConditionReady)
|
|
||||||
Expect(c).NotTo(BeNil())
|
|
||||||
return *c
|
|
||||||
}
|
|
||||||
|
|
||||||
sameTeamRef := func() terdutv1alpha1.TerdutTeamRef {
|
|
||||||
return terdutv1alpha1.TerdutTeamRef{Name: team.Name}
|
|
||||||
}
|
|
||||||
|
|
||||||
Describe("the happy path", func() {
|
|
||||||
It("creates the switch server-side", func(ctx SpecContext) {
|
|
||||||
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx) // create
|
|
||||||
|
|
||||||
cond := readyCondition(ctx)
|
|
||||||
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted))
|
|
||||||
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
|
||||||
Expect(sw.Status.SwitchID).NotTo(BeZero())
|
|
||||||
|
|
||||||
created, ok := fake.switches[team.Status.TeamID][sw.Status.SwitchID]
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(created.Matcher).To(Equal("alertname=Watchdog"))
|
|
||||||
Expect(created.TimeoutSeconds).To(Equal(int64(900)))
|
|
||||||
Expect(created.Severity).To(Equal("critical")) // kubebuilder default
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("list-and-match-by-name adoption", func() {
|
|
||||||
It("adopts an already-created switch instead of creating a duplicate", func(ctx SpecContext) {
|
|
||||||
// Simulates a prior, interrupted reconcile that got as far as
|
|
||||||
// POSTing the switch -- no 409 signal exists for this resource
|
|
||||||
// (DESIGN.md §4.4), so the recovery path is GET-list-and-match,
|
|
||||||
// not adopt-on-409.
|
|
||||||
fake.nextSwitchID = 1
|
|
||||||
fake.switches[team.Status.TeamID] = map[int64]tdclient.DeadmanSwitch{
|
|
||||||
1: {ID: 1, Name: "heartbeat", Matcher: "alertname=Watchdog", TimeoutSeconds: 900, Severity: "critical"},
|
|
||||||
}
|
|
||||||
|
|
||||||
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
|
||||||
Expect(sw.Status.SwitchID).To(Equal(int64(1)))
|
|
||||||
Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "should not have created a second switch")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("update-in-place on spec drift", func() {
|
|
||||||
It("PUTs the new spec rather than creating a second switch", func(ctx SpecContext) {
|
|
||||||
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
|
||||||
switchID := sw.Status.SwitchID
|
|
||||||
|
|
||||||
sw.Spec.Timeout = "30m"
|
|
||||||
Expect(k8sClient.Update(ctx, sw)).To(Succeed())
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "update-in-place, not a second switch")
|
|
||||||
Expect(fake.switches[team.Status.TeamID][switchID].TimeoutSeconds).To(Equal(int64(1800)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("waiting on the referenced TerdutTeam", func() {
|
|
||||||
It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) {
|
|
||||||
createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamRefNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports WaitingForTeam when the TerdutTeam exists but isn't Ready yet", func(ctx SpecContext) {
|
|
||||||
unreadyName := uniqueName("dmteam-unready")
|
|
||||||
unready := &terdutv1alpha1.TerdutTeam{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
|
||||||
Spec: terdutv1alpha1.TerdutTeamSpec{
|
|
||||||
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name},
|
|
||||||
DisplayName: testUnreadyDisplayName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
|
|
||||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
|
||||||
|
|
||||||
createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("deletion", func() {
|
|
||||||
It("deletes the switch server-side (the real DELETE this resource has) and removes the finalizer", func(ctx SpecContext) {
|
|
||||||
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
|
|
||||||
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
|
||||||
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
|
||||||
switchID := sw.Status.SwitchID
|
|
||||||
|
|
||||||
Expect(k8sClient.Delete(ctx, sw)).To(Succeed())
|
|
||||||
reconcileOnce(ctx) // runs the finalizer
|
|
||||||
|
|
||||||
Expect(fake.switchDelete[switchID]).To(BeTrue())
|
|
||||||
err := k8sClient.Get(ctx, swKey, sw)
|
|
||||||
Expect(err).To(HaveOccurred(), "the TerdutDeadmanSwitch itself should be gone once the finalizer clears")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// escalationFinalizerName exists for the one undo PUT /api/teams/{teamID}/escalation
|
|
||||||
// supports, on delete: an empty policy (DESIGN.md §5's general finalizer
|
|
||||||
// rule expects a server-side counterpart to be undone, but this resource's
|
|
||||||
// API is GET/PUT-only, with no DELETE at all -- a zeroed PUT is the closest
|
|
||||||
// equivalent terdut-server itself recognizes as "no policy"
|
|
||||||
// (escalationPolicy.configured(), confirmed against source: "a policy row
|
|
||||||
// with no levels is the same as no policy").
|
|
||||||
const escalationFinalizerName = "terdut.ryuvia.com/terdutescalationrule"
|
|
||||||
|
|
||||||
// TerdutEscalationRuleReconciler reconciles a TerdutEscalationRule object.
|
|
||||||
type TerdutEscalationRuleReconciler struct {
|
|
||||||
client.Client
|
|
||||||
Scheme *runtime.Scheme
|
|
||||||
|
|
||||||
OperatorNamespace string
|
|
||||||
Recorder recorder.EventRecorder
|
|
||||||
NewClient func(endpoint string) *tdclient.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules,verbs=get;list;watch;create;update;patch;delete
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/status,verbs=get;update;patch
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/finalizers,verbs=update
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch
|
|
||||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
|
||||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
|
||||||
|
|
||||||
func (r *TerdutEscalationRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
||||||
log := logf.FromContext(ctx)
|
|
||||||
|
|
||||||
var rule terdutv1alpha1.TerdutEscalationRule
|
|
||||||
if err := r.Get(ctx, req.NamespacedName, &rule); err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
newClient := r.NewClient
|
|
||||||
if newClient == nil {
|
|
||||||
newClient = tdclient.New
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rule.DeletionTimestamp.IsZero() {
|
|
||||||
return r.reconcileEscalationDelete(ctx, &rule, newClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !controllerutil.ContainsFinalizer(&rule, escalationFinalizerName) {
|
|
||||||
controllerutil.AddFinalizer(&rule, escalationFinalizerName)
|
|
||||||
if err := r.Update(ctx, &rule); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return r.setEscalationNotReady(ctx, &rule, resolveErr.reason, resolveErr.message, waitInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, unknownUser, err := buildEscalationRequest(ctx, tc, rule.Spec)
|
|
||||||
if err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
if unknownUser != "" {
|
|
||||||
return r.setEscalationNotReady(ctx, &rule, terdutv1alpha1.ReasonUnknownUser,
|
|
||||||
fmt.Sprintf("username %q does not resolve to any user", unknownUser), waitInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tc.SetEscalation(ctx, team.Status.TeamID, body); err != nil {
|
|
||||||
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/escalation: %w", team.Status.TeamID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionReady, // "Ready" -- same name, shared across every CRD (DESIGN.md §7)
|
|
||||||
Status: metav1.ConditionTrue,
|
|
||||||
Reason: terdutv1alpha1.ReasonChildAdopted,
|
|
||||||
Message: fmt.Sprintf("escalation policy applied to team %d", team.Status.TeamID),
|
|
||||||
})
|
|
||||||
rule.Status.ObservedGeneration = rule.Generation
|
|
||||||
if err := r.Status().Update(ctx, &rule); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(&rule, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
|
|
||||||
"escalation policy applied")
|
|
||||||
}
|
|
||||||
log.Info("TerdutEscalationRule applied", "name", rule.Name, "teamID", team.Status.TeamID)
|
|
||||||
|
|
||||||
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildEscalationRequest resolves every "user" target's username to a
|
|
||||||
// user_id (DESIGN.md §4.3) and translates spec into the wire shape
|
|
||||||
// SetEscalation sends. Returns the first unresolvable username, if any,
|
|
||||||
// distinct from a plain error: that's an expected, reportable condition
|
|
||||||
// (ReasonUnknownUser), not a reconcile failure.
|
|
||||||
func buildEscalationRequest(
|
|
||||||
ctx context.Context, tc *tdclient.Client, spec terdutv1alpha1.TerdutEscalationRuleSpec,
|
|
||||||
) (tdclient.SetEscalationRequest, string, error) {
|
|
||||||
levels := make([]tdclient.EscalationLevelRequest, len(spec.Levels))
|
|
||||||
for i, lvl := range spec.Levels {
|
|
||||||
timeout, err := time.ParseDuration(lvl.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("spec.levels[%d].timeout %q: %w", i, lvl.Timeout, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
targets := make([]tdclient.EscalationTargetRequest, len(lvl.Targets))
|
|
||||||
for j, t := range lvl.Targets {
|
|
||||||
if t.Kind == terdutv1alpha1.EscalationTargetOncall {
|
|
||||||
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetOncall)}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
user, err := tc.GetUserByUsername(ctx, t.Username)
|
|
||||||
if err != nil {
|
|
||||||
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("GET /api/users (resolving %q): %w", t.Username, err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return tdclient.SetEscalationRequest{}, t.Username, nil
|
|
||||||
}
|
|
||||||
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetUser), UserID: &user.ID}
|
|
||||||
}
|
|
||||||
|
|
||||||
levels[i] = tdclient.EscalationLevelRequest{
|
|
||||||
Position: int64(i + 1),
|
|
||||||
TimeoutSeconds: int64(timeout.Seconds()),
|
|
||||||
Targets: targets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tdclient.SetEscalationRequest{
|
|
||||||
RepeatCount: spec.RepeatCount,
|
|
||||||
FallbackTopic: spec.FallbackTopic,
|
|
||||||
Levels: levels,
|
|
||||||
}, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TerdutEscalationRuleReconciler) setEscalationNotReady(
|
|
||||||
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, reason, message string, d time.Duration,
|
|
||||||
) (ctrl.Result, error) {
|
|
||||||
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
Reason: reason,
|
|
||||||
Message: message,
|
|
||||||
})
|
|
||||||
rule.Status.ObservedGeneration = rule.Generation
|
|
||||||
if err := r.Status().Update(ctx, rule); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, reason, reason, message)
|
|
||||||
}
|
|
||||||
return ctrl.Result{RequeueAfter: d}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileEscalationDelete PUTs an empty policy (this resource's only
|
|
||||||
// available "undo", per this file's own const comment) if the team is
|
|
||||||
// still resolvable, then removes the finalizer unconditionally -- same
|
|
||||||
// "the parent's probably going away too" reasoning TerdutTeam's own delete
|
|
||||||
// path uses for a TerdutServer that's gone.
|
|
||||||
func (r *TerdutEscalationRuleReconciler) reconcileEscalationDelete(
|
|
||||||
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, newClient func(string) *tdclient.Client,
|
|
||||||
) (ctrl.Result, error) {
|
|
||||||
if !controllerutil.ContainsFinalizer(rule, escalationFinalizerName) {
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if team, tc, resolveErr := resolveTeamAndClient(
|
|
||||||
ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient,
|
|
||||||
); resolveErr == nil {
|
|
||||||
if err := tc.SetEscalation(ctx, team.Status.TeamID, tdclient.SetEscalationRequest{Levels: []tdclient.EscalationLevelRequest{}}); err != nil {
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controllerutil.RemoveFinalizer(rule, escalationFinalizerName)
|
|
||||||
return ctrl.Result{}, r.Update(ctx, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupWithManager sets up the controller with the Manager.
|
|
||||||
func (r *TerdutEscalationRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
||||||
if r.NewClient == nil {
|
|
||||||
r.NewClient = tdclient.New
|
|
||||||
}
|
|
||||||
if r.Recorder == nil {
|
|
||||||
r.Recorder = mgr.GetEventRecorder("terdutescalationrule-controller")
|
|
||||||
}
|
|
||||||
return ctrl.NewControllerManagedBy(mgr).
|
|
||||||
For(&terdutv1alpha1.TerdutEscalationRule{}).
|
|
||||||
Named("terdutescalationrule").
|
|
||||||
Complete(r)
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http/httptest"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/types"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("TerdutEscalationRule Controller", func() {
|
|
||||||
const operatorNamespace = "default"
|
|
||||||
|
|
||||||
var (
|
|
||||||
reconciler *TerdutEscalationRuleReconciler
|
|
||||||
fake *fakeTerdutServer
|
|
||||||
fakeSrv *httptest.Server
|
|
||||||
srv *terdutv1alpha1.TerdutServer
|
|
||||||
team *terdutv1alpha1.TerdutTeam
|
|
||||||
ruleName string
|
|
||||||
ruleKey types.NamespacedName
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func(ctx SpecContext) {
|
|
||||||
fake, fakeSrv = newFakeTerdutServer()
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
|
|
||||||
srv = bootstrapReadyTerdutServer(ctx, uniqueName("erserver"), fakeSrv.URL)
|
|
||||||
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("erteam"), srv, fakeSrv.URL)
|
|
||||||
|
|
||||||
reconciler = &TerdutEscalationRuleReconciler{
|
|
||||||
Client: k8sClient,
|
|
||||||
Scheme: k8sClient.Scheme(),
|
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
|
||||||
}
|
|
||||||
ruleName = uniqueName("escalation")
|
|
||||||
ruleKey = types.NamespacedName{Name: ruleName, Namespace: operatorNamespace}
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterEach(func(ctx SpecContext) {
|
|
||||||
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
|
||||||
if err := k8sClient.Get(ctx, ruleKey, rule); err == nil {
|
|
||||||
rule.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, rule)
|
|
||||||
_ = k8sClient.Delete(ctx, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// team/srv carry their own finalizers from readyTerdutTeam/
|
|
||||||
// bootstrapReadyTerdutServer -- clear them directly the same way
|
|
||||||
// terdutteam_controller_test.go's own AfterEach does, rather than
|
|
||||||
// relying on either reconciler to ever run again here.
|
|
||||||
teamKey := types.NamespacedName{Name: team.Name, Namespace: operatorNamespace}
|
|
||||||
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
|
|
||||||
team.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, team)
|
|
||||||
_ = k8sClient.Delete(ctx, team)
|
|
||||||
}
|
|
||||||
|
|
||||||
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
|
||||||
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
|
||||||
srv.Finalizers = nil
|
|
||||||
_ = k8sClient.Update(ctx, srv)
|
|
||||||
_ = k8sClient.Delete(ctx, srv)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createRule := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, levels []terdutv1alpha1.EscalationLevel) {
|
|
||||||
rule := &terdutv1alpha1.TerdutEscalationRule{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: ruleName, Namespace: operatorNamespace},
|
|
||||||
Spec: terdutv1alpha1.TerdutEscalationRuleSpec{
|
|
||||||
TeamRef: teamRef,
|
|
||||||
Levels: levels,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Expect(k8sClient.Create(ctx, rule)).To(Succeed())
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileOnce := func(ctx context.Context) {
|
|
||||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: ruleKey})
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
}
|
|
||||||
|
|
||||||
readyCondition := func(ctx context.Context) metav1.Condition {
|
|
||||||
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
|
||||||
Expect(k8sClient.Get(ctx, ruleKey, rule)).To(Succeed())
|
|
||||||
c := meta.FindStatusCondition(rule.Status.Conditions, terdutv1alpha1.ConditionReady)
|
|
||||||
Expect(c).NotTo(BeNil())
|
|
||||||
return *c
|
|
||||||
}
|
|
||||||
|
|
||||||
sameTeamRef := func() terdutv1alpha1.TerdutTeamRef {
|
|
||||||
return terdutv1alpha1.TerdutTeamRef{Name: team.Name}
|
|
||||||
}
|
|
||||||
|
|
||||||
Describe("the happy path", func() {
|
|
||||||
It("resolves usernames and applies the escalation policy", func(ctx SpecContext) {
|
|
||||||
fake.seedUser("alice")
|
|
||||||
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
|
||||||
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
|
||||||
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"},
|
|
||||||
}},
|
|
||||||
{Timeout: "10m", Targets: []terdutv1alpha1.EscalationTarget{
|
|
||||||
{Kind: terdutv1alpha1.EscalationTargetOncall},
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx) // resolve + apply
|
|
||||||
|
|
||||||
cond := readyCondition(ctx)
|
|
||||||
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted))
|
|
||||||
|
|
||||||
applied, ok := fake.escalation[team.Status.TeamID]
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(applied.Levels).To(HaveLen(2))
|
|
||||||
Expect(applied.Levels[0].Targets[0].Kind).To(Equal("user"))
|
|
||||||
Expect(applied.Levels[0].Targets[0].UserID).NotTo(BeNil())
|
|
||||||
Expect(*applied.Levels[0].Targets[0].UserID).To(Equal(fake.users["alice"]))
|
|
||||||
Expect(applied.Levels[1].Targets[0].Kind).To(Equal("oncall"))
|
|
||||||
Expect(applied.Levels[1].Targets[0].UserID).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("an unresolvable username", func() {
|
|
||||||
It("reports UnknownUser and never calls PUT /api/teams/{id}/escalation", func(ctx SpecContext) {
|
|
||||||
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
|
||||||
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
|
||||||
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "ghost"},
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
cond := readyCondition(ctx)
|
|
||||||
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
|
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonUnknownUser))
|
|
||||||
_, applied := fake.escalation[team.Status.TeamID]
|
|
||||||
Expect(applied).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("waiting on the referenced TerdutTeam", func() {
|
|
||||||
It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) {
|
|
||||||
createRule(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, []terdutv1alpha1.EscalationLevel{
|
|
||||||
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
|
|
||||||
})
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamRefNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports WaitingForTeam when the TerdutTeam exists but isn't Ready yet", func(ctx SpecContext) {
|
|
||||||
unreadyName := uniqueName("erteam-unready")
|
|
||||||
unready := &terdutv1alpha1.TerdutTeam{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
|
||||||
Spec: terdutv1alpha1.TerdutTeamSpec{
|
|
||||||
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name},
|
|
||||||
DisplayName: testUnreadyDisplayName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
|
|
||||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
|
||||||
|
|
||||||
createRule(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, []terdutv1alpha1.EscalationLevel{
|
|
||||||
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
|
|
||||||
})
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("deletion", func() {
|
|
||||||
It("PUTs an empty policy (this resource's only available undo) and removes the finalizer", func(ctx SpecContext) {
|
|
||||||
fake.seedUser("alice")
|
|
||||||
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
|
||||||
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
|
||||||
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"},
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
Expect(fake.escalation[team.Status.TeamID].Levels).To(HaveLen(1))
|
|
||||||
|
|
||||||
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
|
||||||
Expect(k8sClient.Get(ctx, ruleKey, rule)).To(Succeed())
|
|
||||||
Expect(k8sClient.Delete(ctx, rule)).To(Succeed())
|
|
||||||
reconcileOnce(ctx) // runs the finalizer
|
|
||||||
|
|
||||||
Expect(fake.escalation[team.Status.TeamID].Levels).To(BeEmpty())
|
|
||||||
err := k8sClient.Get(ctx, ruleKey, rule)
|
|
||||||
Expect(err).To(HaveOccurred(), "the TerdutEscalationRule itself should be gone once the finalizer clears")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bootstrapStateLostError is DESIGN.md §6's one genuinely pathological
|
|
||||||
// case: a checkpointed admin credential was used and then lost before the
|
|
||||||
// lasting credential it was for could be persisted. Distinct from a plain
|
|
||||||
// error so Reconcile can route it to a Ready: False condition (the
|
|
||||||
// documented recovery is delete-and-recreate, not an automatic retry) rather
|
|
||||||
// than treating it as a transient reconcile failure.
|
|
||||||
type bootstrapStateLostError struct{ detail string }
|
|
||||||
|
|
||||||
func (e *bootstrapStateLostError) Error() string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"server reports already bootstrapped, but neither status.credentialsSecretRef nor a "+
|
|
||||||
"checkpointed admin credential exist here: %s. This TerdutServer cannot recover a "+
|
|
||||||
"credential on its own; delete and recreate it", e.detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileBootstrap implements DESIGN.md §6 point 1's self-registration
|
|
||||||
// flow, checkpointed against the two real crash windows in it rather than
|
|
||||||
// leaving them as theoretical gaps. Only called once
|
|
||||||
// srv.Status.CredentialsSecretRef is nil and the Deployment has a ready
|
|
||||||
// replica.
|
|
||||||
func (r *TerdutServerReconciler) reconcileBootstrap(ctx context.Context, srv *terdutv1alpha1.TerdutServer) error {
|
|
||||||
adminKey, err := r.getOrCreateCheckpointedAdminKey(ctx, srv)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bc := r.NewClient(serviceURL(srv)).WithToken(adminKey)
|
|
||||||
instanceKey, err := r.getOrMintInstanceServiceAccountKey(ctx, bc)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
credsName := credentialsSecretName(srv)
|
|
||||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, instanceKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
srv.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
|
|
||||||
|
|
||||||
// Best-effort: the checkpoint has done its job. Leaving it behind on a
|
|
||||||
// delete failure here isn't a correctness problem (the next reconcile
|
|
||||||
// finds status.CredentialsSecretRef already set and never looks at the
|
|
||||||
// checkpoint again) — it would just be an unused Secret sitting around,
|
|
||||||
// cleaned up for real by the finalizer on delete.
|
|
||||||
checkpoint := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: checkpointSecretName(srv), Namespace: r.OperatorNamespace}}
|
|
||||||
_ = r.Delete(ctx, checkpoint)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOrCreateCheckpointedAdminKey returns a usable admin key: from the
|
|
||||||
// checkpoint Secret if an earlier, interrupted attempt already got one, or
|
|
||||||
// freshly from /api/bootstrap, immediately checkpointed before it's used
|
|
||||||
// for anything else.
|
|
||||||
func (r *TerdutServerReconciler) getOrCreateCheckpointedAdminKey(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (string, error) {
|
|
||||||
checkpointName := checkpointSecretName(srv)
|
|
||||||
|
|
||||||
var checkpoint corev1.Secret
|
|
||||||
err := r.Get(ctx, client.ObjectKey{Namespace: r.OperatorNamespace, Name: checkpointName}, &checkpoint)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
return string(checkpoint.Data[credentialsSecretDataKey]), nil
|
|
||||||
case !apierrors.IsNotFound(err):
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
bc := r.NewClient(serviceURL(srv))
|
|
||||||
result, err := bc.Bootstrap(ctx, bootstrapUsername, bootstrapEmail)
|
|
||||||
if err != nil {
|
|
||||||
if statusErr, ok := errors.AsType[*tdclient.StatusError](err); ok && statusErr.Code == http.StatusForbidden {
|
|
||||||
// §1: this operator is the only thing that ever bootstraps a
|
|
||||||
// server it created, so a 403 here (no checkpoint, no
|
|
||||||
// status.credentialsSecretRef) means a prior reconcile already
|
|
||||||
// won this exact race and its checkpoint was lost afterward --
|
|
||||||
// the one case §6 doesn't try to paper over.
|
|
||||||
return "", &bootstrapStateLostError{detail: "/api/bootstrap returned 403"}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("POST /api/bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, checkpointName, result.APIKey.Key); err != nil {
|
|
||||||
return "", fmt.Errorf("checkpointing admin key: %w", err)
|
|
||||||
}
|
|
||||||
return result.APIKey.Key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOrMintInstanceServiceAccountKey mints the operator's own instance-
|
|
||||||
// scoped service account, or, if an earlier interrupted attempt already
|
|
||||||
// created it (409), adopts it and mints a fresh key rather than treating
|
|
||||||
// the conflict as an error (DESIGN.md §6 point 1, §5's general
|
|
||||||
// adopt-on-conflict rule).
|
|
||||||
func (r *TerdutServerReconciler) getOrMintInstanceServiceAccountKey(ctx context.Context, bc *tdclient.Client) (string, error) {
|
|
||||||
result, err := bc.CreateInstanceServiceAccount(ctx, serviceAccountName)
|
|
||||||
if err == nil {
|
|
||||||
return result.Key.Key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
|
|
||||||
if !ok || statusErr.Code != http.StatusConflict {
|
|
||||||
return "", fmt.Errorf("POST /api/service-accounts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sa, err := bc.GetServiceAccountByName(ctx, serviceAccountName)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("GET /api/service-accounts?name=%s (adopting after 409): %w", serviceAccountName, err)
|
|
||||||
}
|
|
||||||
if sa == nil {
|
|
||||||
return "", fmt.Errorf("POST /api/service-accounts 409'd for %q but GET found nothing", serviceAccountName)
|
|
||||||
}
|
|
||||||
key, err := bc.CreateServiceAccountKey(ctx, sa.ID, "initial")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("POST /api/service-accounts/%d/keys (adopting after 409): %w", sa.ID, err)
|
|
||||||
}
|
|
||||||
return key.Key, nil
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,12 +16,10 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// resyncInterval is the periodic requeue on a successful reconcile (DESIGN.md
|
// resyncInterval is the periodic requeue on a successful reconcile (DESIGN.md
|
||||||
@@ -39,27 +37,6 @@ const resyncInterval = 5 * time.Minute
|
|||||||
// than "someone edited something out of band."
|
// than "someone edited something out of band."
|
||||||
const waitInterval = 15 * time.Second
|
const waitInterval = 15 * time.Second
|
||||||
|
|
||||||
// finalizerName cleans up the credentials Secret(s) this controller
|
|
||||||
// generates in the operator's own namespace on delete — the Deployment and
|
|
||||||
// Service are owned (OwnerReference, DESIGN.md §7) and need no finalizer of
|
|
||||||
// their own.
|
|
||||||
const finalizerName = "terdut.ryuvia.com/terdutserver"
|
|
||||||
|
|
||||||
// serviceAccountName is the name the operator registers itself under
|
|
||||||
// server-side (DESIGN.md §6) — a fixed, repo-wide constant, not a spec
|
|
||||||
// field: it names the automation, not anything about this one TerdutServer.
|
|
||||||
const serviceAccountName = "terdut-operator"
|
|
||||||
|
|
||||||
// bootstrapUsername/bootstrapEmail found the one human-shaped user every
|
|
||||||
// fresh install needs (terdut-server's handleBootstrap requires both).
|
|
||||||
// Nobody signs in as this user afterward — its only purpose is minting the
|
|
||||||
// admin key the controller immediately trades for a real service-account
|
|
||||||
// key — so these are fixed, not spec fields.
|
|
||||||
const (
|
|
||||||
bootstrapUsername = "terdut-operator-bootstrap"
|
|
||||||
bootstrapEmail = "bootstrap@terdut-operator.local"
|
|
||||||
)
|
|
||||||
|
|
||||||
// postgresqlGVK is the Zalando postgres-operator's CR (DESIGN.md §8).
|
// postgresqlGVK is the Zalando postgres-operator's CR (DESIGN.md §8).
|
||||||
// Resolved via unstructured rather than vendoring Zalando's own client, to
|
// Resolved via unstructured rather than vendoring Zalando's own client, to
|
||||||
// keep this operator's dependency on it to "an optional CRD read" rather
|
// keep this operator's dependency on it to "an optional CRD read" rather
|
||||||
@@ -75,21 +52,9 @@ type TerdutServerReconciler struct {
|
|||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
|
|
||||||
// OperatorNamespace is where every credentials Secret this controller
|
|
||||||
// generates lives (DESIGN.md §6) — never the TerdutServer's own
|
|
||||||
// namespace. Set from the POD_NAMESPACE downward-API env var in
|
|
||||||
// production (cmd/main.go); tests set it directly.
|
|
||||||
OperatorNamespace string
|
|
||||||
|
|
||||||
// Recorder emits the Kubernetes Events DESIGN.md §12 asks for on every
|
// Recorder emits the Kubernetes Events DESIGN.md §12 asks for on every
|
||||||
// externally-visible outcome.
|
// externally-visible outcome.
|
||||||
Recorder recorder.EventRecorder
|
Recorder recorder.EventRecorder
|
||||||
|
|
||||||
// NewClient builds the terdut-server API client for a given endpoint. A
|
|
||||||
// field, not a direct tdclient.New call, so tests can substitute an
|
|
||||||
// httptest.Server's client without a real network round trip. Defaults
|
|
||||||
// to tdclient.New via SetupWithManager.
|
|
||||||
NewClient func(endpoint string) *tdclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch;create;update;patch;delete
|
||||||
@@ -113,26 +78,18 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !srv.DeletionTimestamp.IsZero() {
|
|
||||||
return r.reconcileDelete(ctx, &srv)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !controllerutil.ContainsFinalizer(&srv, finalizerName) {
|
|
||||||
controllerutil.AddFinalizer(&srv, finalizerName)
|
|
||||||
if err := r.Update(ctx, &srv); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
// The Update above re-triggers a reconcile via the watch; nothing
|
|
||||||
// further to do on this pass.
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
dbEnv, dbErr := r.resolveDatabaseEnv(ctx, &srv)
|
dbEnv, dbErr := r.resolveDatabaseEnv(ctx, &srv)
|
||||||
if dbErr != nil {
|
if dbErr != nil {
|
||||||
return r.setNotReady(ctx, &srv, dbErr.reason, dbErr.message, waitInterval)
|
return r.setNotReady(ctx, &srv, dbErr.reason, dbErr.message, waitInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy, err := r.reconcileDeployment(ctx, &srv, dbEnv)
|
operatorKey, err := r.reconcileOperatorKey(ctx, &srv)
|
||||||
|
if err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
srv.Status.CredentialsSecretRef = operatorKeyRef(&srv)
|
||||||
|
|
||||||
|
deploy, err := r.reconcileDeployment(ctx, &srv, dbEnv, operatorKeyHash(operatorKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
@@ -143,13 +100,6 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionDatabaseReady,
|
|
||||||
Status: metav1.ConditionTrue,
|
|
||||||
Reason: terdutv1alpha1.ReasonAdopted,
|
|
||||||
Message: "database resolved",
|
|
||||||
})
|
|
||||||
|
|
||||||
if deploy.Status.ReadyReplicas < 1 {
|
if deploy.Status.ReadyReplicas < 1 {
|
||||||
return r.setNotReady(ctx, &srv,
|
return r.setNotReady(ctx, &srv,
|
||||||
terdutv1alpha1.ReasonWaitingForDeployment,
|
terdutv1alpha1.ReasonWaitingForDeployment,
|
||||||
@@ -157,28 +107,12 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
waitInterval)
|
waitInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srv.Status.CredentialsSecretRef == nil {
|
|
||||||
if err := r.reconcileBootstrap(ctx, &srv); err != nil {
|
|
||||||
if pending, ok := errors.AsType[*bootstrapStateLostError](err); ok {
|
|
||||||
return r.setNotReady(ctx, &srv, terdutv1alpha1.ReasonBootstrapStateLost, pending.Error(), waitInterval)
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
|
||||||
Type: terdutv1alpha1.ConditionBootstrapped,
|
|
||||||
Status: metav1.ConditionTrue,
|
|
||||||
Reason: terdutv1alpha1.ReasonAdopted,
|
|
||||||
Message: fmt.Sprintf("credentials in Secret %q", srv.Status.CredentialsSecretRef.Name),
|
|
||||||
})
|
|
||||||
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
Status: metav1.ConditionTrue,
|
Status: metav1.ConditionTrue,
|
||||||
Reason: terdutv1alpha1.ReasonAdopted,
|
Reason: terdutv1alpha1.ReasonAdopted,
|
||||||
Message: "deployment ready, database resolved, credentials bootstrapped",
|
Message: "deployment ready, database resolved, operator key seeded",
|
||||||
})
|
})
|
||||||
srv.Status.ServiceName = srv.Name
|
|
||||||
srv.Status.ObservedGeneration = srv.Generation
|
srv.Status.ObservedGeneration = srv.Generation
|
||||||
if err := r.Status().Update(ctx, &srv); err != nil {
|
if err := r.Status().Update(ctx, &srv); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
@@ -215,35 +149,8 @@ func (r *TerdutServerReconciler) setNotReady(
|
|||||||
return ctrl.Result{RequeueAfter: d}, nil
|
return ctrl.Result{RequeueAfter: d}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileDelete cleans up the credentials Secret(s) this controller
|
|
||||||
// generated in the operator's own namespace. The Deployment and Service are
|
|
||||||
// owned (OwnerReference, DESIGN.md §7) and need no attention here — normal
|
|
||||||
// GC handles them. There is no server-side "delete this install" call to
|
|
||||||
// make: bootstrap created a user and a service account, and terdut-server's
|
|
||||||
// API has no way to delete either (only to revoke individual keys), so
|
|
||||||
// there is nothing meaningful to undo there either.
|
|
||||||
func (r *TerdutServerReconciler) reconcileDelete(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (ctrl.Result, error) {
|
|
||||||
if !controllerutil.ContainsFinalizer(srv, finalizerName) {
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
for _, name := range []string{checkpointSecretName(srv), credentialsSecretName(srv)} {
|
|
||||||
sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.OperatorNamespace}}
|
|
||||||
if err := r.Delete(ctx, sec); err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controllerutil.RemoveFinalizer(srv, finalizerName)
|
|
||||||
if err := r.Update(ctx, srv); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupWithManager sets up the controller with the Manager.
|
// SetupWithManager sets up the controller with the Manager.
|
||||||
func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
if r.NewClient == nil {
|
|
||||||
r.NewClient = tdclient.New
|
|
||||||
}
|
|
||||||
if r.Recorder == nil {
|
if r.Recorder == nil {
|
||||||
r.Recorder = mgr.GetEventRecorder("terdutserver-controller")
|
r.Recorder = mgr.GetEventRecorder("terdutserver-controller")
|
||||||
}
|
}
|
||||||
@@ -252,20 +159,13 @@ func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
Owns(&appsv1.Deployment{}).
|
Owns(&appsv1.Deployment{}).
|
||||||
Owns(&corev1.Service{}).
|
Owns(&corev1.Service{}).
|
||||||
Owns(&policyv1.PodDisruptionBudget{}).
|
Owns(&policyv1.PodDisruptionBudget{}).
|
||||||
|
Owns(&corev1.Secret{}).
|
||||||
Named("terdutserver").
|
Named("terdutserver").
|
||||||
Complete(r)
|
Complete(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- naming ---
|
// --- naming ---
|
||||||
|
|
||||||
func checkpointSecretName(srv *terdutv1alpha1.TerdutServer) string {
|
|
||||||
return fmt.Sprintf("%s.%s-bootstrap-admin", srv.Namespace, srv.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func credentialsSecretName(srv *terdutv1alpha1.TerdutServer) string {
|
|
||||||
return fmt.Sprintf("%s.%s-instance-credentials", srv.Namespace, srv.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func serviceURL(srv *terdutv1alpha1.TerdutServer) string {
|
func serviceURL(srv *terdutv1alpha1.TerdutServer) string {
|
||||||
port := srv.Spec.Networking.ServicePort
|
port := srv.Spec.Networking.ServicePort
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@@ -27,514 +20,8 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeVersionString/errJSONKey are shared by every response fakeTerdutServer
|
|
||||||
// writes -- goconst would otherwise flag "test" and "error" as repeated
|
|
||||||
// literals across its handlers.
|
|
||||||
const (
|
|
||||||
fakeVersionString = "test"
|
|
||||||
errJSONKey = "error"
|
|
||||||
// deadmanSwitchesPath is the literal path (not Printf'd like the others
|
|
||||||
// below) shared by the exact-match collection route and the dispatcher
|
|
||||||
// that routes into it -- goconst flags three occurrences of the same
|
|
||||||
// string, so this is that string, once.
|
|
||||||
deadmanSwitchesPath = "/deadman/switches"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeTerdutServer reproduces the exact stateful semantics of
|
|
||||||
// /api/bootstrap, /api/service-accounts and /api/version that the
|
|
||||||
// bootstrap flow depends on (DESIGN.md §6, §11: "terdut-server's REST API
|
|
||||||
// is faked with a small httptest.Server per controller test ... matching
|
|
||||||
// the real handlers' request/response shapes"), including the 403-after-
|
|
||||||
// first-success and 409-on-name-conflict behavior the adopt-on-conflict
|
|
||||||
// recovery path exists for.
|
|
||||||
type fakeTerdutServer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
bootstrapped bool
|
|
||||||
bootstrap403 bool // force every /api/bootstrap call to 403, even the first
|
|
||||||
nextID int64
|
|
||||||
accounts map[string]int64 // name -> id
|
|
||||||
keyMints map[int64]int // id -> number of keys minted so far
|
|
||||||
|
|
||||||
nextTeamID int64
|
|
||||||
teams map[string]int64 // name -> id
|
|
||||||
teamNames map[int64]string // id -> current name (renames update this)
|
|
||||||
teamOIDC map[int64][2]string
|
|
||||||
teamDelete map[int64]bool // id -> true once DELETEd, for 404-on-redelete
|
|
||||||
|
|
||||||
// users backs GET /api/users for TerdutEscalationRule's username
|
|
||||||
// resolution (DESIGN.md §4.3) -- a fixed, pre-seeded directory, since
|
|
||||||
// nothing in this controller's own flow ever creates a user.
|
|
||||||
users map[string]int64 // username -> id
|
|
||||||
|
|
||||||
// escalation backs PUT /api/teams/{id}/escalation -- an upsert
|
|
||||||
// server-side (confirmed against source), so this is just "the last
|
|
||||||
// body PUT for this team", keyed by teamID, with no separate create
|
|
||||||
// step to model.
|
|
||||||
escalation map[int64]tdclient.SetEscalationRequest
|
|
||||||
|
|
||||||
// switches/nextSwitchID/switchDelete back the dead man's switch
|
|
||||||
// endpoints -- no unique-name constraint server-side (DESIGN.md §4.4),
|
|
||||||
// so switches is keyed by id, not name, same as the real API's own
|
|
||||||
// GET-list-and-match-by-name idempotent-create shape requires.
|
|
||||||
nextSwitchID int64
|
|
||||||
switches map[int64]map[int64]tdclient.DeadmanSwitch // teamID -> switchID -> switch
|
|
||||||
switchDelete map[int64]bool // switchID -> true once DELETEd, for 404-on-redelete
|
|
||||||
|
|
||||||
// integrations/nextIntegrationID/integrationDelete back the alert
|
|
||||||
// source endpoints -- no unique-name constraint server-side either
|
|
||||||
// (DESIGN.md §4.5), same shape as switches, keyed by id.
|
|
||||||
nextIntegrationID int64
|
|
||||||
integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration
|
|
||||||
integrationDelete map[int64]bool // integrationID -> true once DELETEd, for 404-on-redelete
|
|
||||||
|
|
||||||
// invites/nextInviteID/inviteDelete back TerdutTeam's own invite-minting
|
|
||||||
// feature -- no unique constraint on an invite server-side either (every
|
|
||||||
// POST mints a brand new row, confirmed against source), same keyed-by-id
|
|
||||||
// shape as switches/integrations.
|
|
||||||
nextInviteID int64
|
|
||||||
invites map[int64]map[int64]tdclient.Invite // teamID -> inviteID -> invite
|
|
||||||
inviteDelete map[int64]bool // inviteID -> true once DELETEd, for 404-on-redelete
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
|
||||||
f := &fakeTerdutServer{
|
|
||||||
accounts: map[string]int64{},
|
|
||||||
keyMints: map[int64]int{},
|
|
||||||
teams: map[string]int64{},
|
|
||||||
teamNames: map[int64]string{},
|
|
||||||
teamOIDC: map[int64][2]string{},
|
|
||||||
teamDelete: map[int64]bool{},
|
|
||||||
users: map[string]int64{},
|
|
||||||
escalation: map[int64]tdclient.SetEscalationRequest{},
|
|
||||||
switches: map[int64]map[int64]tdclient.DeadmanSwitch{},
|
|
||||||
switchDelete: map[int64]bool{},
|
|
||||||
|
|
||||||
integrations: map[int64]map[int64]tdclient.Integration{},
|
|
||||||
integrationDelete: map[int64]bool{},
|
|
||||||
|
|
||||||
invites: map[int64]map[int64]tdclient.Invite{},
|
|
||||||
inviteDelete: map[int64]bool{},
|
|
||||||
}
|
|
||||||
return f, httptest.NewServer(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedUser registers a username the fake GET /api/users will return --
|
|
||||||
// called from test setup, before the controller under test ever runs.
|
|
||||||
// Callers read the assigned id back from f.users themselves, so this has
|
|
||||||
// nothing left to return.
|
|
||||||
func (f *fakeTerdutServer) seedUser(username string) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
f.nextID++
|
|
||||||
f.users[username] = f.nextID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case r.URL.Path == "/api/version":
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"version": fakeVersionString})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/bootstrap" && r.Method == http.MethodPost:
|
|
||||||
if f.bootstrapped || f.bootstrap403 {
|
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{errJSONKey: "bootstrap already completed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.bootstrapped = true
|
|
||||||
writeJSON(w, http.StatusCreated, tdclient.BootstrapResult{
|
|
||||||
APIKey: tdclient.APIKey{ID: 1, Name: "bootstrap", Key: "admin-key-raw"},
|
|
||||||
})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/service-accounts" && r.Method == http.MethodPost:
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if _, exists := f.accounts[req.Name]; exists {
|
|
||||||
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a service account with that name already exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.nextID++
|
|
||||||
id := f.nextID
|
|
||||||
f.accounts[req.Name] = id
|
|
||||||
f.keyMints[id] = 1
|
|
||||||
writeJSON(w, http.StatusCreated, tdclient.CreateServiceAccountResult{
|
|
||||||
ServiceAccount: tdclient.ServiceAccount{ID: id, Name: req.Name, Scope: req.Scope},
|
|
||||||
Key: tdclient.APIKey{ID: 1, Name: "initial", Key: fmt.Sprintf("instance-key-%d-initial", id)},
|
|
||||||
})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/service-accounts" && r.Method == http.MethodGet:
|
|
||||||
name := r.URL.Query().Get("name")
|
|
||||||
id, exists := f.accounts[name]
|
|
||||||
if !exists {
|
|
||||||
writeJSON(w, http.StatusOK, []tdclient.ServiceAccount{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, []tdclient.ServiceAccount{{ID: id, Name: name, Scope: "instance"}})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/teams" && r.Method == http.MethodPost:
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if _, exists := f.teams[req.Name]; exists {
|
|
||||||
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.nextTeamID++
|
|
||||||
id := f.nextTeamID
|
|
||||||
f.teams[req.Name] = id
|
|
||||||
f.teamNames[id] = req.Name
|
|
||||||
writeJSON(w, http.StatusCreated, tdclient.Team{ID: id, Name: req.Name})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/teams" && r.Method == http.MethodGet:
|
|
||||||
// The controller never calls this without ?name= (TEAM-LOOKUP.md's
|
|
||||||
// own lookup shape) -- the fake only needs to answer that form.
|
|
||||||
name := r.URL.Query().Get("name")
|
|
||||||
id, exists := f.teams[name]
|
|
||||||
if !exists {
|
|
||||||
writeJSON(w, http.StatusOK, []tdclient.Team{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, []tdclient.Team{{ID: id, Name: f.teamNames[id]}})
|
|
||||||
|
|
||||||
case r.URL.Path == "/api/users" && r.Method == http.MethodGet:
|
|
||||||
// No query filter -- GetUserByUsername fetches the whole list and
|
|
||||||
// matches client-side (confirmed against source: no server-side
|
|
||||||
// filter either), so the fake does the same.
|
|
||||||
users := make([]tdclient.User, 0, len(f.users))
|
|
||||||
for name, id := range f.users {
|
|
||||||
users = append(users, tdclient.User{ID: id, Username: name})
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, users)
|
|
||||||
|
|
||||||
default:
|
|
||||||
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
|
|
||||||
f.keyMints[id]++
|
|
||||||
writeJSON(w, http.StatusCreated, tdclient.APIKey{
|
|
||||||
ID: int64(f.keyMints[id]), Name: name,
|
|
||||||
Key: fmt.Sprintf("instance-key-%d-mint%d", id, f.keyMints[id]),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if id, rest, ok := parseTeamSubPath(r.URL.Path); ok {
|
|
||||||
f.handleTeamSubPath(w, r, id, rest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTeamSubPath answers everything under /api/teams/{id}: PUT (rename),
|
|
||||||
// DELETE, PUT .../oidc-groups, PUT .../escalation, and the dead man's
|
|
||||||
// switch collection/item endpoints. rest is whatever parseTeamSubPath found
|
|
||||||
// after "/api/teams/{id}" -- "" for the bare resource.
|
|
||||||
func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
|
||||||
switch {
|
|
||||||
case rest == "" && r.Method == http.MethodPut:
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
oldName, exists := f.teamNames[id]
|
|
||||||
if !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(f.teams, oldName)
|
|
||||||
f.teamNames[id] = req.Name
|
|
||||||
f.teams[req.Name] = id
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case rest == "" && r.Method == http.MethodDelete:
|
|
||||||
name, exists := f.teamNames[id]
|
|
||||||
if !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(f.teams, name)
|
|
||||||
delete(f.teamNames, id)
|
|
||||||
f.teamDelete[id] = true
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case rest == "/oidc-groups" && r.Method == http.MethodPut:
|
|
||||||
var req struct {
|
|
||||||
MemberGroup string `json:"member_group"`
|
|
||||||
OwnerGroup string `json:"owner_group"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if _, exists := f.teamNames[id]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case rest == "/escalation" && r.Method == http.MethodPut:
|
|
||||||
var req tdclient.SetEscalationRequest
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
f.escalation[id] = req
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case rest == deadmanSwitchesPath || strings.HasPrefix(rest, deadmanSwitchesPath+"/"):
|
|
||||||
f.handleDeadmanSubPath(w, r, id, rest)
|
|
||||||
|
|
||||||
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
|
|
||||||
f.handleIntegrationSubPath(w, r, id, rest)
|
|
||||||
|
|
||||||
case rest == "/invites" || strings.HasPrefix(rest, "/invites/"):
|
|
||||||
f.handleInviteSubPath(w, r, id, rest)
|
|
||||||
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeadmanSubPath answers GET/POST /api/teams/{id}/deadman/switches and
|
|
||||||
// PUT/DELETE .../deadman/switches/{switchID} -- split out of
|
|
||||||
// handleTeamSubPath for the same gocyclo reason as handleIntegrationSubPath.
|
|
||||||
func (f *fakeTerdutServer) handleDeadmanSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
|
||||||
switch {
|
|
||||||
case rest == deadmanSwitchesPath && r.Method == http.MethodGet:
|
|
||||||
existing := f.switches[id]
|
|
||||||
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
|
|
||||||
for _, s := range existing {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
|
|
||||||
case rest == deadmanSwitchesPath && r.Method == http.MethodPost:
|
|
||||||
var req deadmanSwitchFakeRequest
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
f.nextSwitchID++
|
|
||||||
switchID := f.nextSwitchID
|
|
||||||
name := req.Name
|
|
||||||
if name == "" {
|
|
||||||
name = "derived-" + req.Matcher
|
|
||||||
}
|
|
||||||
sw := tdclient.DeadmanSwitch{
|
|
||||||
ID: switchID, Name: name, Matcher: req.Matcher,
|
|
||||||
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
|
||||||
}
|
|
||||||
if f.switches[id] == nil {
|
|
||||||
f.switches[id] = map[int64]tdclient.DeadmanSwitch{}
|
|
||||||
}
|
|
||||||
f.switches[id][switchID] = sw
|
|
||||||
writeJSON(w, http.StatusCreated, sw)
|
|
||||||
|
|
||||||
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodPut:
|
|
||||||
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := f.switches[id][switchID]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req deadmanSwitchFakeRequest
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
name := req.Name
|
|
||||||
if name == "" {
|
|
||||||
name = f.switches[id][switchID].Name
|
|
||||||
}
|
|
||||||
f.switches[id][switchID] = tdclient.DeadmanSwitch{
|
|
||||||
ID: switchID, Name: name, Matcher: req.Matcher,
|
|
||||||
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodDelete:
|
|
||||||
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := f.switches[id][switchID]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(f.switches[id], switchID)
|
|
||||||
f.switchDelete[switchID] = true
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInviteSubPath answers POST /api/teams/{id}/invites and
|
|
||||||
// DELETE .../invites/{inviteID} -- split out for the same gocyclo reason as
|
|
||||||
// handleIntegrationSubPath.
|
|
||||||
func (f *fakeTerdutServer) handleInviteSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
|
||||||
switch {
|
|
||||||
case rest == "/invites" && r.Method == http.MethodPost:
|
|
||||||
var req struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
MaxUses int64 `json:"max_uses"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
f.nextInviteID++
|
|
||||||
inviteID := f.nextInviteID
|
|
||||||
inv := tdclient.Invite{
|
|
||||||
ID: inviteID, TeamID: id, Role: req.Role, MaxUses: req.MaxUses,
|
|
||||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
|
||||||
URL: fmt.Sprintf("https://terdut.example.invalid/signup?invite=invite-token-%d", inviteID),
|
|
||||||
}
|
|
||||||
if f.invites[id] == nil {
|
|
||||||
f.invites[id] = map[int64]tdclient.Invite{}
|
|
||||||
}
|
|
||||||
f.invites[id][inviteID] = inv
|
|
||||||
writeJSON(w, http.StatusCreated, inv)
|
|
||||||
|
|
||||||
case strings.HasPrefix(rest, "/invites/") && r.Method == http.MethodDelete:
|
|
||||||
inviteID, ok := parseTrailingID(rest, "/invites/")
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := f.invites[id][inviteID]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(f.invites[id], inviteID)
|
|
||||||
f.inviteDelete[inviteID] = true
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleIntegrationSubPath answers POST /api/teams/{id}/integrations,
|
|
||||||
// PATCH .../integrations/{integrationID} and DELETE .../integrations/{integrationID}
|
|
||||||
// -- split out of handleTeamSubPath so that switch's own cyclomatic
|
|
||||||
// complexity stays under golangci-lint's gocyclo threshold.
|
|
||||||
func (f *fakeTerdutServer) handleIntegrationSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
|
||||||
switch {
|
|
||||||
case rest == "/integrations" && r.Method == http.MethodPost:
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
f.nextIntegrationID++
|
|
||||||
integID := f.nextIntegrationID
|
|
||||||
integ := tdclient.Integration{
|
|
||||||
ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name,
|
|
||||||
// Key/URL are only ever in *this* response -- never again,
|
|
||||||
// matching terdut-server's own one-time-show semantics
|
|
||||||
// (DESIGN.md §4.5) -- so what's stored for later GET/PATCH
|
|
||||||
// calls in this fake deliberately omits them too.
|
|
||||||
Key: fmt.Sprintf("webhook-key-%d", integID),
|
|
||||||
URL: fmt.Sprintf("https://terdut.example.invalid/api/integrations/webhook-key-%d/%s", integID, req.Kind),
|
|
||||||
}
|
|
||||||
if f.integrations[id] == nil {
|
|
||||||
f.integrations[id] = map[int64]tdclient.Integration{}
|
|
||||||
}
|
|
||||||
f.integrations[id][integID] = tdclient.Integration{ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name}
|
|
||||||
writeJSON(w, http.StatusCreated, integ)
|
|
||||||
|
|
||||||
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodPatch:
|
|
||||||
integID, ok := parseTrailingID(rest, "/integrations/")
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existing, exists := f.integrations[id][integID]
|
|
||||||
if !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
existing.Name = req.Name
|
|
||||||
f.integrations[id][integID] = existing
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodDelete:
|
|
||||||
integID, ok := parseTrailingID(rest, "/integrations/")
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := f.integrations[id][integID]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(f.integrations[id], integID)
|
|
||||||
f.integrationDelete[integID] = true
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deadmanSwitchFakeRequest mirrors tdclient's own (unexported)
|
|
||||||
// deadmanSwitchRequest -- the fake needs its own copy to decode the same
|
|
||||||
// wire shape without reaching across package boundaries for an internal type.
|
|
||||||
type deadmanSwitchFakeRequest struct {
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Matcher string `json:"matcher"`
|
|
||||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTeamSubPath splits "/api/teams/{id}" from anything after it --
|
|
||||||
// "" for an exact match, "/oidc-groups", "/escalation", "/deadman/switches"
|
|
||||||
// or "/deadman/switches/{switchID}" otherwise. Doesn't itself validate the
|
|
||||||
// suffix; handleTeamSubPath's own switch does that.
|
|
||||||
func parseTeamSubPath(path string) (id int64, rest string, ok bool) {
|
|
||||||
const prefix = "/api/teams/"
|
|
||||||
if !strings.HasPrefix(path, prefix) {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
trimmed := path[len(prefix):]
|
|
||||||
parts := strings.SplitN(trimmed, "/", 2)
|
|
||||||
parsedID, err := strconv.ParseInt(parts[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
if len(parts) == 1 {
|
|
||||||
return parsedID, "", true
|
|
||||||
}
|
|
||||||
return parsedID, "/" + parts[1], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTrailingID parses the numeric id after prefix within rest, e.g.
|
|
||||||
// parseTrailingID("/deadman/switches/7", "/deadman/switches/") -> 7, true.
|
|
||||||
func parseTrailingID(rest, prefix string) (id int64, ok bool) {
|
|
||||||
parsedID, err := strconv.ParseInt(strings.TrimPrefix(rest, prefix), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return parsedID, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKeysPath(path string) (id int64, mintName string, ok bool) {
|
|
||||||
var parsedID int64
|
|
||||||
n, err := fmt.Sscanf(path, "/api/service-accounts/%d/keys", &parsedID)
|
|
||||||
if err != nil || n != 1 {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
return parsedID, "minted", true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = Describe("TerdutServer Controller", func() {
|
var _ = Describe("TerdutServer Controller", func() {
|
||||||
const operatorNamespace = "default"
|
const operatorNamespace = "default"
|
||||||
|
|
||||||
@@ -546,9 +33,8 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
reconciler = &TerdutServerReconciler{
|
reconciler = &TerdutServerReconciler{
|
||||||
Client: k8sClient,
|
Client: k8sClient,
|
||||||
Scheme: k8sClient.Scheme(),
|
Scheme: k8sClient.Scheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
|
||||||
}
|
}
|
||||||
name = fmt.Sprintf("test-server-%d-%d", GinkgoRandomSeed(), GinkgoParallelProcess())
|
name = fmt.Sprintf("test-server-%d-%d", GinkgoRandomSeed(), GinkgoParallelProcess())
|
||||||
objKey = types.NamespacedName{Name: name, Namespace: operatorNamespace}
|
objKey = types.NamespacedName{Name: name, Namespace: operatorNamespace}
|
||||||
@@ -561,9 +47,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
_ = k8sClient.Update(ctx, srv)
|
_ = k8sClient.Update(ctx, srv)
|
||||||
_ = k8sClient.Delete(ctx, srv)
|
_ = k8sClient.Delete(ctx, srv)
|
||||||
}
|
}
|
||||||
for _, n := range []string{checkpointSecretNameFor(name), credentialsSecretNameFor(name)} {
|
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name + "-operator-key", Namespace: operatorNamespace}})
|
||||||
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: n, Namespace: operatorNamespace}})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
dsnSpec := func() terdutv1alpha1.TerdutServerSpec {
|
dsnSpec := func() terdutv1alpha1.TerdutServerSpec {
|
||||||
@@ -600,127 +84,117 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
srv := &terdutv1alpha1.TerdutServer{}
|
srv := &terdutv1alpha1.TerdutServer{}
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
||||||
c := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
|
c := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
Expect(c).NotTo(BeNil(), "Ready condition should always be set after a reconcile past the finalizer-add pass")
|
Expect(c).NotTo(BeNil(), "Ready condition should always be set after a reconcile")
|
||||||
return *c
|
return *c
|
||||||
}
|
}
|
||||||
|
|
||||||
Describe("bring-your-own DSN path", func() {
|
envOf := func(ctx context.Context) map[string]corev1.EnvVar {
|
||||||
It("reaches Ready through the full lifecycle: finalizer, Deployment/Service, wait, bootstrap", func(ctx SpecContext) {
|
var deploy appsv1.Deployment
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
DeferCleanup(fakeSrv.Close)
|
out := map[string]corev1.EnvVar{}
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
for _, e := range deploy.Spec.Template.Spec.Containers[0].Env {
|
||||||
|
out[e.Name] = e
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("bring-your-own DSN path", func() {
|
||||||
|
It("creates Deployment, Service and operator key, then reaches Ready once a replica is up", func(ctx SpecContext) {
|
||||||
createServer(ctx, dsnSpec())
|
createServer(ctx, dsnSpec())
|
||||||
|
|
||||||
// Pass 1: adds the finalizer and returns early.
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
srv := &terdutv1alpha1.TerdutServer{}
|
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
|
||||||
Expect(srv.Finalizers).To(ContainElement(finalizerName))
|
|
||||||
|
|
||||||
// Pass 2: creates Deployment + Service, waits for readiness.
|
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForDeployment))
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForDeployment))
|
||||||
|
|
||||||
var deploy appsv1.Deployment
|
var deploy appsv1.Deployment
|
||||||
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
Expect(deploy.Spec.Template.Spec.Containers[0].Image).To(Equal("example.invalid/terdut-server:test"))
|
Expect(deploy.Spec.Template.Spec.Containers[0].Image).To(Equal("example.invalid/terdut-server:test"))
|
||||||
envNames := map[string]string{}
|
env := envOf(ctx)
|
||||||
for _, e := range deploy.Spec.Template.Spec.Containers[0].Env {
|
Expect(env["TERDUT_DB_DSN"].Value).To(Equal(testDSN))
|
||||||
envNames[e.Name] = e.Value
|
Expect(env["TERDUT_OPERATOR_MODE"].Value).To(Equal("true"))
|
||||||
}
|
Expect(env).NotTo(HaveKey("TERDUT_DEADMAN_MATCHERS"), "dead man's switches are per team now")
|
||||||
Expect(envNames).To(HaveKeyWithValue("TERDUT_DB_DSN", testDSN))
|
|
||||||
Expect(envNames).To(HaveKeyWithValue("TERDUT_OPERATOR_MODE", "true"))
|
|
||||||
|
|
||||||
var svc corev1.Service
|
var svc corev1.Service
|
||||||
Expect(k8sClient.Get(ctx, objKey, &svc)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, &svc)).To(Succeed())
|
||||||
Expect(svc.Spec.Ports[0].Port).To(Equal(int32(8080)))
|
Expect(svc.Spec.Ports[0].Port).To(Equal(int32(8080)))
|
||||||
|
|
||||||
// Simulate the Deployment becoming ready (envtest has no
|
|
||||||
// kubelet/deployment-controller to do this for real).
|
|
||||||
markDeploymentReady(ctx)
|
markDeploymentReady(ctx)
|
||||||
|
|
||||||
// Pass 3: bootstraps for real against the fake server.
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(fake.bootstrapped).To(BeTrue())
|
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
|
||||||
ready := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
|
|
||||||
Expect(ready.Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
Expect(ready.Reason).To(Equal(terdutv1alpha1.ReasonAdopted))
|
|
||||||
Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil())
|
|
||||||
Expect(srv.Status.CredentialsSecretRef.Key).To(Equal(credentialsSecretDataKey))
|
|
||||||
|
|
||||||
var credsSecret corev1.Secret
|
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
||||||
Name: srv.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
|
||||||
}, &credsSecret)).To(Succeed())
|
|
||||||
Expect(string(credsSecret.Data[credentialsSecretDataKey])).To(Equal("instance-key-1-initial"))
|
|
||||||
|
|
||||||
// The checkpoint is cleaned up once the lasting credential is
|
|
||||||
// written (DESIGN.md §6 point 2).
|
|
||||||
var checkpoint corev1.Secret
|
|
||||||
err := k8sClient.Get(ctx, types.NamespacedName{Name: checkpointSecretName(srv), Namespace: operatorNamespace}, &checkpoint)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("the adopt-on-409 recovery path", func() {
|
|
||||||
It("mints a fresh key instead of erroring when the service account already exists", func(ctx SpecContext) {
|
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
fake.bootstrapped = true // an earlier attempt already bootstrapped...
|
|
||||||
fake.nextID = 1
|
|
||||||
fake.accounts[serviceAccountName] = 1 // ...and already created the service account.
|
|
||||||
fake.keyMints[1] = 1
|
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
createServer(ctx, dsnSpec())
|
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx) // Deployment/Service, WaitingForDeployment
|
|
||||||
markDeploymentReady(ctx)
|
|
||||||
|
|
||||||
// The earlier attempt's checkpoint survived (that's how this
|
|
||||||
// reconcile can authenticate at all to recover).
|
|
||||||
Expect(writeOperatorSecret(ctx, k8sClient, operatorNamespace, checkpointSecretName(&terdutv1alpha1.TerdutServer{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace},
|
|
||||||
}), "admin-key-raw")).To(Succeed())
|
|
||||||
|
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
srv := &terdutv1alpha1.TerdutServer{}
|
srv := &terdutv1alpha1.TerdutServer{}
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
||||||
Expect(meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady).Status).To(Equal(metav1.ConditionTrue))
|
ready := meta.FindStatusCondition(srv.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
|
Expect(ready.Status).To(Equal(metav1.ConditionTrue))
|
||||||
var credsSecret corev1.Secret
|
Expect(ready.Reason).To(Equal(terdutv1alpha1.ReasonAdopted))
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
Expect(srv.Status.CredentialsSecretRef).To(Equal(&terdutv1alpha1.SecretKeyRef{
|
||||||
Name: srv.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
Name: name + "-operator-key", Key: operatorKeyDataKey,
|
||||||
}, &credsSecret)).To(Succeed())
|
}))
|
||||||
// Minted fresh, not the (never-seen-by-this-reconcile) "initial"
|
|
||||||
// key from the account's original creation.
|
|
||||||
Expect(string(credsSecret.Data[credentialsSecretDataKey])).To(Equal("instance-key-1-mint2"))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("the BootstrapStateLost path", func() {
|
Describe("the operator key", func() {
|
||||||
It("fails closed when the server reports already-bootstrapped with no checkpoint to recover from", func(ctx SpecContext) {
|
keySecret := func(ctx context.Context) corev1.Secret {
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
var s corev1.Secret
|
||||||
_ = fake
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name + "-operator-key", Namespace: operatorNamespace}, &s)).To(Succeed())
|
||||||
DeferCleanup(fakeSrv.Close)
|
return s
|
||||||
fake.bootstrap403 = true
|
}
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
|
It("is generated once, owned by the TerdutServer, and wired into the pod", func(ctx SpecContext) {
|
||||||
createServer(ctx, dsnSpec())
|
createServer(ctx, dsnSpec())
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx) // Deployment/Service
|
|
||||||
markDeploymentReady(ctx)
|
|
||||||
|
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
cond := readyCondition(ctx)
|
s := keySecret(ctx)
|
||||||
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
|
value := string(s.Data[operatorKeyDataKey])
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonBootstrapStateLost))
|
Expect(len(value)).To(BeNumerically(">=", 32), "terdut-server refuses a shorter TERDUT_OPERATOR_KEY")
|
||||||
|
Expect(value).To(HavePrefix(operatorKeyPrefix))
|
||||||
|
Expect(s.OwnerReferences).To(ContainElement(HaveField("Name", name)))
|
||||||
|
|
||||||
|
env := envOf(ctx)
|
||||||
|
Expect(env["TERDUT_OPERATOR_KEY"].ValueFrom).NotTo(BeNil())
|
||||||
|
Expect(env["TERDUT_OPERATOR_KEY"].ValueFrom.SecretKeyRef.Name).To(Equal(name + "-operator-key"))
|
||||||
|
Expect(env["TERDUT_OPERATOR_KEY"].Value).To(BeEmpty(), "the key itself must never appear in the pod spec")
|
||||||
|
|
||||||
|
var deploy appsv1.Deployment
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
|
Expect(deploy.Spec.Template.Annotations).To(HaveKeyWithValue(operatorKeyHashAnnotation, operatorKeyHash(value)))
|
||||||
|
|
||||||
|
// A later reconcile keeps the same key: the running server was seeded with it.
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
Expect(string(keySecret(ctx).Data[operatorKeyDataKey])).To(Equal(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rolls the Deployment when the Secret is replaced", func(ctx SpecContext) {
|
||||||
|
createServer(ctx, dsnSpec())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
first := string(keySecret(ctx).Data[operatorKeyDataKey])
|
||||||
|
|
||||||
|
s := keySecret(ctx)
|
||||||
|
Expect(k8sClient.Delete(ctx, &s)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
second := string(keySecret(ctx).Data[operatorKeyDataKey])
|
||||||
|
Expect(second).NotTo(Equal(first))
|
||||||
|
var deploy appsv1.Deployment
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
|
Expect(deploy.Spec.Template.Annotations).To(HaveKeyWithValue(operatorKeyHashAnnotation, operatorKeyHash(second)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("spec.oidc claims", func() {
|
||||||
|
It("passes the claim names and trustEmail through", func(ctx SpecContext) {
|
||||||
|
spec := dsnSpec()
|
||||||
|
spec.OIDC = terdutv1alpha1.OIDCSpec{
|
||||||
|
Enabled: true, Issuer: "https://sso.example.invalid", ClientID: "terdut",
|
||||||
|
UsernameClaim: "upn", EmailClaim: "mail", GroupsClaim: "roles", TrustEmail: true,
|
||||||
|
SessionMaxAge: "12h",
|
||||||
|
}
|
||||||
|
createServer(ctx, spec)
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
env := envOf(ctx)
|
||||||
|
Expect(env["TERDUT_OIDC_USERNAME_CLAIM"].Value).To(Equal("upn"))
|
||||||
|
Expect(env["TERDUT_OIDC_EMAIL_CLAIM"].Value).To(Equal("mail"))
|
||||||
|
Expect(env["TERDUT_OIDC_GROUPS_CLAIM"].Value).To(Equal("roles"))
|
||||||
|
Expect(env["TERDUT_OIDC_TRUST_EMAIL"].Value).To(Equal("true"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -735,7 +209,6 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
|
|
||||||
It("waits with reason PostgresClusterNotFound when the postgresql CR doesn't exist yet", func(ctx SpecContext) {
|
It("waits with reason PostgresClusterNotFound when the postgresql CR doesn't exist yet", func(ctx SpecContext) {
|
||||||
createServer(ctx, zalandoSpec("missing-cluster"))
|
createServer(ctx, zalandoSpec("missing-cluster"))
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonPostgresClusterNotFound))
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonPostgresClusterNotFound))
|
||||||
@@ -759,13 +232,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
Expect(k8sClient.Create(ctx, zalandoSecret)).To(Succeed())
|
Expect(k8sClient.Create(ctx, zalandoSecret)).To(Succeed())
|
||||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, zalandoSecret) })
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, zalandoSecret) })
|
||||||
|
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
|
||||||
_ = fake
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
createServer(ctx, zalandoSpec(clusterName))
|
createServer(ctx, zalandoSpec(clusterName))
|
||||||
reconcileOnce(ctx) // finalizer
|
|
||||||
reconcileOnce(ctx) // Deployment/Service created with resolved DB env
|
reconcileOnce(ctx) // Deployment/Service created with resolved DB env
|
||||||
|
|
||||||
var deploy appsv1.Deployment
|
var deploy appsv1.Deployment
|
||||||
@@ -787,11 +254,6 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
|
|
||||||
Describe("spec.pod", func() {
|
Describe("spec.pod", func() {
|
||||||
It("wires pod-level customization onto the right spot on the Deployment", func(ctx SpecContext) {
|
It("wires pod-level customization onto the right spot on the Deployment", func(ctx SpecContext) {
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
|
||||||
_ = fake
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
spec := dsnSpec()
|
spec := dsnSpec()
|
||||||
qty := resource.MustParse("250m")
|
qty := resource.MustParse("250m")
|
||||||
spec.Pod = terdutv1alpha1.PodSpec{
|
spec.Pod = terdutv1alpha1.PodSpec{
|
||||||
@@ -803,8 +265,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
ExtraVolumeMounts: []corev1.VolumeMount{{Name: "extra-ca", MountPath: "/etc/extra-ca"}},
|
ExtraVolumeMounts: []corev1.VolumeMount{{Name: "extra-ca", MountPath: "/etc/extra-ca"}},
|
||||||
}
|
}
|
||||||
createServer(ctx, spec)
|
createServer(ctx, spec)
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileOnce(ctx)
|
||||||
reconcileOnce(ctx) // Deployment/Service/PDB
|
|
||||||
|
|
||||||
var deploy appsv1.Deployment
|
var deploy appsv1.Deployment
|
||||||
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
@@ -828,17 +289,11 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
|
|
||||||
Describe("spec.pod.disruptionBudget", func() {
|
Describe("spec.pod.disruptionBudget", func() {
|
||||||
It("creates an owned PodDisruptionBudget when set, and deletes it once cleared", func(ctx SpecContext) {
|
It("creates an owned PodDisruptionBudget when set, and deletes it once cleared", func(ctx SpecContext) {
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
|
||||||
_ = fake
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
spec := dsnSpec()
|
spec := dsnSpec()
|
||||||
minAvail := intstr.FromInt32(1)
|
minAvail := intstr.FromInt32(1)
|
||||||
spec.Pod.DisruptionBudget = &terdutv1alpha1.PodDisruptionBudgetSpec{MinAvailable: &minAvail}
|
spec.Pod.DisruptionBudget = &terdutv1alpha1.PodDisruptionBudgetSpec{MinAvailable: &minAvail}
|
||||||
createServer(ctx, spec)
|
createServer(ctx, spec)
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileOnce(ctx)
|
||||||
reconcileOnce(ctx) // Deployment/Service/PDB
|
|
||||||
|
|
||||||
var pdb policyv1.PodDisruptionBudget
|
var pdb policyv1.PodDisruptionBudget
|
||||||
Expect(k8sClient.Get(ctx, objKey, &pdb)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, &pdb)).To(Succeed())
|
||||||
@@ -874,35 +329,6 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("deletion", func() {
|
|
||||||
It("removes the credentials and checkpoint Secrets and the finalizer", func(ctx SpecContext) {
|
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
|
||||||
_ = fake
|
|
||||||
DeferCleanup(fakeSrv.Close)
|
|
||||||
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
|
||||||
|
|
||||||
createServer(ctx, dsnSpec())
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
markDeploymentReady(ctx)
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
srv := &terdutv1alpha1.TerdutServer{}
|
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
|
||||||
credsName := srv.Status.CredentialsSecretRef.Name
|
|
||||||
|
|
||||||
Expect(k8sClient.Delete(ctx, srv)).To(Succeed())
|
|
||||||
reconcileOnce(ctx) // runs the finalizer
|
|
||||||
|
|
||||||
err := k8sClient.Get(ctx, objKey, srv)
|
|
||||||
Expect(err).To(HaveOccurred(), "the TerdutServer itself should be gone once the finalizer clears")
|
|
||||||
|
|
||||||
var leftover corev1.Secret
|
|
||||||
err = k8sClient.Get(ctx, types.NamespacedName{Name: credsName, Namespace: operatorNamespace}, &leftover)
|
|
||||||
Expect(err).To(HaveOccurred(), "the credentials Secret should have been cleaned up by the finalizer")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
When("the TerdutServer object no longer exists", func() {
|
When("the TerdutServer object no longer exists", func() {
|
||||||
It("returns no error (deleted between enqueue and reconcile)", func(ctx SpecContext) {
|
It("returns no error (deleted between enqueue and reconcile)", func(ctx SpecContext) {
|
||||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{
|
||||||
@@ -912,13 +338,3 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// checkpointSecretNameFor/credentialsSecretNameFor let AfterEach clean up
|
|
||||||
// without needing a live TerdutServer object (it may already be gone by
|
|
||||||
// then in the deletion test).
|
|
||||||
func checkpointSecretNameFor(name string) string {
|
|
||||||
return fmt.Sprintf("default.%s-bootstrap-admin", name)
|
|
||||||
}
|
|
||||||
func credentialsSecretNameFor(name string) string {
|
|
||||||
return fmt.Sprintf("default.%s-instance-credentials", name)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// operatorKeyDataKey is the data key of the operator key Secret.
|
||||||
|
const operatorKeyDataKey = "token"
|
||||||
|
|
||||||
|
// operatorKeyPrefix marks the key as a service-account credential in logs, the
|
||||||
|
// way terdut-server's own generated ones are.
|
||||||
|
const operatorKeyPrefix = "tdsa_"
|
||||||
|
|
||||||
|
// operatorKeySecretName is the Secret holding a TerdutServer's operator key. It
|
||||||
|
// lives beside the Deployment because the pod mounts it (TERDUT_OPERATOR_KEY)
|
||||||
|
// and a pod can only reference Secrets of its own namespace; it is owned by the
|
||||||
|
// TerdutServer, so deleting that deletes the key and a recreated one starts
|
||||||
|
// with a fresh one the server re-seeds on its next start.
|
||||||
|
func operatorKeySecretName(srv *terdutv1alpha1.TerdutServer) string {
|
||||||
|
return srv.Name + "-operator-key"
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileOperatorKey returns this server's operator key, generating the Secret
|
||||||
|
// on first use. An existing Secret is never overwritten: the running server was
|
||||||
|
// seeded with that value, and replacing it would lock the operator out until
|
||||||
|
// every pod restarted.
|
||||||
|
func (r *TerdutServerReconciler) reconcileOperatorKey(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (string, error) {
|
||||||
|
var secret corev1.Secret
|
||||||
|
key := client.ObjectKey{Namespace: srv.Namespace, Name: operatorKeySecretName(srv)}
|
||||||
|
err := r.Get(ctx, key, &secret)
|
||||||
|
if err == nil {
|
||||||
|
if v := string(secret.Data[operatorKeyDataKey]); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
// Present but empty or foreign: treat as ours to fill rather than
|
||||||
|
// failing every reconcile on it.
|
||||||
|
} else if !apierrors.IsNotFound(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("generating operator key: %w", err)
|
||||||
|
}
|
||||||
|
value := operatorKeyPrefix + hex.EncodeToString(raw)
|
||||||
|
|
||||||
|
secret = corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace},
|
||||||
|
Data: map[string][]byte{operatorKeyDataKey: []byte(value)},
|
||||||
|
}
|
||||||
|
if err := controllerutil.SetControllerReference(srv, &secret, r.Scheme); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := r.Create(ctx, &secret); err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
// Lost a race with a stale cache read: the next reconcile finds it.
|
||||||
|
return "", fmt.Errorf("operator key Secret %s appeared during creation; retrying", key.Name)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorKeyHash is a short digest of the key, stamped on the pod template so
|
||||||
|
// a replaced Secret rolls the Deployment: the server only reads the env var at
|
||||||
|
// start.
|
||||||
|
func operatorKeyHash(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorKeyRef is the reference status.credentialsSecretRef reports.
|
||||||
|
func operatorKeyRef(srv *terdutv1alpha1.TerdutServer) *terdutv1alpha1.SecretKeyRef {
|
||||||
|
return &terdutv1alpha1.SecretKeyRef{Name: operatorKeySecretName(srv), Key: operatorKeyDataKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOperatorKey reads a TerdutServer's key from its own namespace, for the
|
||||||
|
// controllers that call the server's API.
|
||||||
|
func readOperatorKey(ctx context.Context, c client.Client, srv *terdutv1alpha1.TerdutServer) (string, error) {
|
||||||
|
var secret corev1.Secret
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: srv.Namespace, Name: operatorKeySecretName(srv)}, &secret); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
v := string(secret.Data[operatorKeyDataKey])
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("operator key Secret %s/%s is empty", srv.Namespace, secret.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
@@ -31,7 +32,7 @@ func labelsFor(srv *terdutv1alpha1.TerdutServer) map[string]string {
|
|||||||
// object (not just the desired one) so the caller can check
|
// object (not just the desired one) so the caller can check
|
||||||
// status.readyReplicas.
|
// status.readyReplicas.
|
||||||
func (r *TerdutServerReconciler) reconcileDeployment(
|
func (r *TerdutServerReconciler) reconcileDeployment(
|
||||||
ctx context.Context, srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar,
|
ctx context.Context, srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar, keyHash string,
|
||||||
) (*appsv1.Deployment, error) {
|
) (*appsv1.Deployment, error) {
|
||||||
deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: srv.Name, Namespace: srv.Namespace}}
|
deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: srv.Name, Namespace: srv.Namespace}}
|
||||||
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
|
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
|
||||||
@@ -60,13 +61,8 @@ func (r *TerdutServerReconciler) reconcileDeployment(
|
|||||||
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RollingUpdateDeploymentStrategyType}
|
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RollingUpdateDeploymentStrategyType}
|
||||||
pod := srv.Spec.Pod
|
pod := srv.Spec.Pod
|
||||||
deploy.Spec.Template = corev1.PodTemplateSpec{
|
deploy.Spec.Template = corev1.PodTemplateSpec{
|
||||||
// pod.Annotations is assigned directly, not merged -- nothing
|
// The controller's own annotation wins over a same-named user one.
|
||||||
// else sets pod-template annotations today. If a future change
|
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: podAnnotations(pod.Annotations, keyHash)},
|
||||||
// needs the controller to set one of its own (e.g. a
|
|
||||||
// Prometheus-scrape annotation), this needs to become a real
|
|
||||||
// map merge with a stated precedence rather than silently
|
|
||||||
// clobbering one side.
|
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: pod.Annotations},
|
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
EnableServiceLinks: new(false),
|
EnableServiceLinks: new(false),
|
||||||
NodeSelector: pod.NodeSelector,
|
NodeSelector: pod.NodeSelector,
|
||||||
@@ -127,6 +123,17 @@ func (r *TerdutServerReconciler) reconcileService(ctx context.Context, srv *terd
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// operatorKeyHashAnnotation records which operator key the pods were started
|
||||||
|
// with, so replacing the key's Secret rolls them.
|
||||||
|
const operatorKeyHashAnnotation = "terdut.ryuvia.com/operator-key-hash"
|
||||||
|
|
||||||
|
func podAnnotations(user map[string]string, keyHash string) map[string]string {
|
||||||
|
out := make(map[string]string, len(user)+1)
|
||||||
|
maps.Copy(out, user)
|
||||||
|
out[operatorKeyHashAnnotation] = keyHash
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func servicePort(srv *terdutv1alpha1.TerdutServer) int32 {
|
func servicePort(srv *terdutv1alpha1.TerdutServer) int32 {
|
||||||
if srv.Spec.Networking.ServicePort == 0 {
|
if srv.Spec.Networking.ServicePort == 0 {
|
||||||
return 8080
|
return 8080
|
||||||
@@ -194,13 +201,6 @@ func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.
|
|||||||
corev1.EnvVar{Name: "TERDUT_ARCHIVE_AFTER", Value: sweeper.ArchiveAfter},
|
corev1.EnvVar{Name: "TERDUT_ARCHIVE_AFTER", Value: sweeper.ArchiveAfter},
|
||||||
)
|
)
|
||||||
|
|
||||||
deadman := srv.Spec.Deadman
|
|
||||||
env = append(env,
|
|
||||||
corev1.EnvVar{Name: "TERDUT_DEADMAN_MATCHERS", Value: deadman.Matchers},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_DEADMAN_TIMEOUT", Value: deadman.Timeout},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_DEADMAN_SEVERITY", Value: deadman.Severity},
|
|
||||||
)
|
|
||||||
|
|
||||||
notify := srv.Spec.Notify
|
notify := srv.Spec.Notify
|
||||||
if notify.NtfyURL != "" {
|
if notify.NtfyURL != "" {
|
||||||
env = append(env,
|
env = append(env,
|
||||||
@@ -230,6 +230,9 @@ func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.
|
|||||||
// here on" -- which is unconditionally true for anything this
|
// here on" -- which is unconditionally true for anything this
|
||||||
// operator creates).
|
// operator creates).
|
||||||
corev1.EnvVar{Name: "TERDUT_OPERATOR_MODE", Value: "true"},
|
corev1.EnvVar{Name: "TERDUT_OPERATOR_MODE", Value: "true"},
|
||||||
|
// The credential this operator calls the API with; the server
|
||||||
|
// creates or re-keys its instance-scoped account from it at start.
|
||||||
|
corev1.EnvVar{Name: "TERDUT_OPERATOR_KEY", ValueFrom: secretEnvSource(operatorKeyRef(srv))},
|
||||||
)
|
)
|
||||||
|
|
||||||
if oidc := srv.Spec.OIDC; oidc.Enabled {
|
if oidc := srv.Spec.OIDC; oidc.Enabled {
|
||||||
@@ -238,14 +241,10 @@ func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.
|
|||||||
corev1.EnvVar{Name: "TERDUT_OIDC_CLIENT_ID", Value: oidc.ClientID},
|
corev1.EnvVar{Name: "TERDUT_OIDC_CLIENT_ID", Value: oidc.ClientID},
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_NAME", Value: oidc.Name},
|
corev1.EnvVar{Name: "TERDUT_OIDC_NAME", Value: oidc.Name},
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_SCOPES", Value: oidc.Scopes},
|
corev1.EnvVar{Name: "TERDUT_OIDC_SCOPES", Value: oidc.Scopes},
|
||||||
// terdut-server's own defaults for the claims/trust-email knobs
|
corev1.EnvVar{Name: "TERDUT_OIDC_USERNAME_CLAIM", Value: oidc.UsernameClaim},
|
||||||
// the chart exposes but DESIGN.md's spec doesn't (§4.1's doc
|
corev1.EnvVar{Name: "TERDUT_OIDC_EMAIL_CLAIM", Value: oidc.EmailClaim},
|
||||||
// comment on OIDCSpec) -- not configurable here, not an
|
corev1.EnvVar{Name: "TERDUT_OIDC_GROUPS_CLAIM", Value: oidc.GroupsClaim},
|
||||||
// oversight.
|
corev1.EnvVar{Name: "TERDUT_OIDC_TRUST_EMAIL", Value: boolString(oidc.TrustEmail)},
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_USERNAME_CLAIM", Value: "preferred_username"},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_EMAIL_CLAIM", Value: "email"},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_GROUPS_CLAIM", Value: "groups"},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_TRUST_EMAIL", Value: "false"},
|
|
||||||
corev1.EnvVar{Name: "TERDUT_OIDC_SESSION_MAX_AGE", Value: oidc.SessionMaxAge},
|
corev1.EnvVar{Name: "TERDUT_OIDC_SESSION_MAX_AGE", Value: oidc.SessionMaxAge},
|
||||||
)
|
)
|
||||||
if oidc.ClientSecretRef != nil {
|
if oidc.ClientSecretRef != nil {
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// createOrAdoptTeam calls POST /api/teams with the TerdutServer's
|
|
||||||
// instance-scoped credential, or, if an earlier interrupted attempt
|
|
||||||
// already created this name (409), adopts it via GET /api/teams?name=
|
|
||||||
// (TEAM-LOOKUP.md) rather than treating the conflict as an error --
|
|
||||||
// DESIGN.md §5's general adopt-on-conflict rule, the same shape
|
|
||||||
// TerdutServer's own bootstrap flow uses for minting its instance account.
|
|
||||||
func (r *TerdutTeamReconciler) createOrAdoptTeam(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
|
|
||||||
created, err := instanceClient.CreateTeam(ctx, team.Spec.DisplayName)
|
|
||||||
if err == nil {
|
|
||||||
team.Status.TeamID = created.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
|
|
||||||
if !ok || statusErr.Code != http.StatusConflict {
|
|
||||||
return fmt.Errorf("POST /api/teams: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found, err := instanceClient.GetTeamByName(ctx, team.Spec.DisplayName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("GET /api/teams?name=%s (adopting after 409): %w", team.Spec.DisplayName, err)
|
|
||||||
}
|
|
||||||
if found == nil {
|
|
||||||
// Genuinely pathological, not just a narrow crash window: the name
|
|
||||||
// was taken a moment ago and isn't now. Surfaced as a plain error
|
|
||||||
// (standard requeue-with-backoff) rather than a dedicated
|
|
||||||
// condition -- there's no documented recovery to point at that
|
|
||||||
// differs from "try again".
|
|
||||||
return fmt.Errorf("POST /api/teams 409'd for %q but GET found nothing", team.Spec.DisplayName)
|
|
||||||
}
|
|
||||||
team.Status.TeamID = found.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mintTeamCredential mints this team's own team-scoped service account,
|
|
||||||
// using the TerdutServer's instance-scoped credential (DESIGN.md §6 point
|
|
||||||
// 3: an instance-scoped caller may do this against any team). Adopts via
|
|
||||||
// GET+mint-new-key on a 409, the same pattern TerdutServer's own bootstrap
|
|
||||||
// flow uses.
|
|
||||||
func (r *TerdutTeamReconciler) mintTeamCredential(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
|
|
||||||
saName := teamServiceAccountName(team)
|
|
||||||
|
|
||||||
result, err := instanceClient.CreateTeamServiceAccount(ctx, saName, team.Status.TeamID)
|
|
||||||
var key string
|
|
||||||
if err == nil {
|
|
||||||
key = result.Key.Key
|
|
||||||
} else {
|
|
||||||
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
|
|
||||||
if !ok || statusErr.Code != http.StatusConflict {
|
|
||||||
return fmt.Errorf("POST /api/service-accounts (team scope): %w", err)
|
|
||||||
}
|
|
||||||
sa, err := instanceClient.GetServiceAccountByName(ctx, saName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("GET /api/service-accounts?name=%s (adopting after 409): %w", saName, err)
|
|
||||||
}
|
|
||||||
if sa == nil {
|
|
||||||
return fmt.Errorf("POST /api/service-accounts 409'd for %q but GET found nothing", saName)
|
|
||||||
}
|
|
||||||
minted, err := instanceClient.CreateServiceAccountKey(ctx, sa.ID, "initial")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("POST /api/service-accounts/%d/keys (adopting after 409): %w", sa.ID, err)
|
|
||||||
}
|
|
||||||
key = minted.Key
|
|
||||||
}
|
|
||||||
|
|
||||||
credsName := teamCredentialsSecretName(team)
|
|
||||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
team.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reconcileEscalation applies spec.escalation as the team's whole ladder, or
|
||||||
|
// clears it when the field is absent (an empty ladder is the server's "none").
|
||||||
|
// The second return is an expected, reportable condition (a bad duration, an
|
||||||
|
// unknown user), the third a failure to retry.
|
||||||
|
func (r *TerdutTeamReconciler) reconcileEscalation(
|
||||||
|
ctx context.Context, tc *tdclient.Client, team *terdutv1alpha1.TerdutTeam,
|
||||||
|
) (*teamError, error) {
|
||||||
|
body, err := buildEscalationRequest(team.Spec.Escalation)
|
||||||
|
if err != nil {
|
||||||
|
return &teamError{reason: terdutv1alpha1.ReasonInvalidSpec, message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
if err := tc.SetEscalation(ctx, team.Status.TeamID, body); err != nil {
|
||||||
|
// The server resolves usernames; one it does not know is a state to
|
||||||
|
// wait out (the person may be created later), not a failure.
|
||||||
|
if se, ok := errors.AsType[*tdclient.StatusError](err); ok &&
|
||||||
|
se.Code == http.StatusBadRequest && strings.HasPrefix(se.Message, "unknown user") {
|
||||||
|
return &teamError{reason: terdutv1alpha1.ReasonUnknownUser, message: se.Message}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("PUT /api/teams/%d/escalation: %w", team.Status.TeamID, err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEscalationRequest(spec *terdutv1alpha1.EscalationSpec) (tdclient.SetEscalationRequest, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return tdclient.SetEscalationRequest{Levels: []tdclient.EscalationLevelRequest{}}, nil
|
||||||
|
}
|
||||||
|
levels := make([]tdclient.EscalationLevelRequest, len(spec.Levels))
|
||||||
|
for i, lvl := range spec.Levels {
|
||||||
|
timeout, err := time.ParseDuration(lvl.Timeout)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return tdclient.SetEscalationRequest{}, fmt.Errorf("spec.escalation.levels[%d].timeout %q is not a positive duration", i, lvl.Timeout)
|
||||||
|
}
|
||||||
|
targets := make([]tdclient.EscalationTargetRequest, len(lvl.Targets))
|
||||||
|
for j, t := range lvl.Targets {
|
||||||
|
targets[j] = tdclient.EscalationTargetRequest{Kind: string(t.Kind), Username: t.Username}
|
||||||
|
}
|
||||||
|
levels[i] = tdclient.EscalationLevelRequest{
|
||||||
|
Position: int64(i + 1),
|
||||||
|
TimeoutSeconds: int64(timeout.Seconds()),
|
||||||
|
Targets: targets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tdclient.SetEscalationRequest{
|
||||||
|
RepeatCount: spec.RepeatCount,
|
||||||
|
FallbackTopic: spec.FallbackTopic,
|
||||||
|
Levels: levels,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileDeadmanSwitches makes the team's switches on the server exactly
|
||||||
|
// spec.deadmanSwitches, matched by name (unique per team server-side): create
|
||||||
|
// what is missing, update what differs, delete what is not listed. In operator
|
||||||
|
// mode nobody else can add one, so anything extra is leftover to remove.
|
||||||
|
func (r *TerdutTeamReconciler) reconcileDeadmanSwitches(
|
||||||
|
ctx context.Context, tc *tdclient.Client, team *terdutv1alpha1.TerdutTeam,
|
||||||
|
) (*teamError, error) {
|
||||||
|
type want struct {
|
||||||
|
spec terdutv1alpha1.DeadmanSwitchSpec
|
||||||
|
seconds int64
|
||||||
|
}
|
||||||
|
desired := make(map[string]want, len(team.Spec.DeadmanSwitches))
|
||||||
|
for _, sw := range team.Spec.DeadmanSwitches {
|
||||||
|
timeout, err := time.ParseDuration(sw.Timeout)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return &teamError{
|
||||||
|
reason: terdutv1alpha1.ReasonInvalidSpec,
|
||||||
|
message: fmt.Sprintf("spec.deadmanSwitches[%q].timeout %q is not a positive duration", sw.Name, sw.Timeout),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
desired[sw.Name] = want{spec: sw, seconds: int64(timeout.Seconds())}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := tc.ListDeadmanSwitches(ctx, team.Status.TeamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GET /api/teams/%d/deadman/switches: %w", team.Status.TeamID, err)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(existing))
|
||||||
|
for _, have := range existing {
|
||||||
|
w, keep := desired[have.Name]
|
||||||
|
if !keep {
|
||||||
|
if err := tc.DeleteDeadmanSwitch(ctx, team.Status.TeamID, have.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("DELETE deadman switch %q: %w", have.Name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[have.Name] = true
|
||||||
|
severity := severityOrDefault(w.spec.Severity)
|
||||||
|
if have.Matcher == w.spec.Matcher && have.TimeoutSeconds == w.seconds && have.Severity == severity {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tc.UpdateDeadmanSwitch(ctx, team.Status.TeamID, have.ID, w.spec.Name, w.spec.Matcher, w.seconds, severity); err != nil {
|
||||||
|
return nil, fmt.Errorf("PUT deadman switch %q: %w", have.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sw := range team.Spec.DeadmanSwitches {
|
||||||
|
if seen[sw.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := desired[sw.Name]
|
||||||
|
if _, err := tc.CreateDeadmanSwitch(ctx, team.Status.TeamID, sw.Name, sw.Matcher, w.seconds, severityOrDefault(sw.Severity)); err != nil {
|
||||||
|
return nil, fmt.Errorf("POST deadman switch %q: %w", sw.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityOrDefault(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
@@ -15,33 +14,25 @@ import (
|
|||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// teamFinalizerName cleans up the team-scoped credentials Secret this
|
// teamFinalizerName deletes the team on the server before the CR goes.
|
||||||
// controller generates in the operator's own namespace on delete.
|
|
||||||
const teamFinalizerName = "terdut.ryuvia.com/terdutteam"
|
const teamFinalizerName = "terdut.ryuvia.com/terdutteam"
|
||||||
|
|
||||||
// TerdutTeamReconciler reconciles a TerdutTeam object.
|
// TerdutTeamReconciler reconciles a TerdutTeam: the team itself plus its
|
||||||
//
|
// escalation ladder and dead man's switches, all through the referenced
|
||||||
// Every child resolves its own teamRef/serverRef independently and never
|
// TerdutServer's operator key.
|
||||||
// chains up through another controller (DESIGN.md §5) — this one talks
|
|
||||||
// directly to the TerdutServer it references and to terdut-server's API,
|
|
||||||
// never to TerdutServerReconciler.
|
|
||||||
type TerdutTeamReconciler struct {
|
type TerdutTeamReconciler struct {
|
||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
|
|
||||||
// OperatorNamespace is where every credentials Secret this controller
|
|
||||||
// reads (the referenced TerdutServer's) or writes (this team's own)
|
|
||||||
// lives (DESIGN.md §6) — never a TerdutTeam's or TerdutServer's own
|
|
||||||
// namespace.
|
|
||||||
OperatorNamespace string
|
|
||||||
|
|
||||||
Recorder recorder.EventRecorder
|
Recorder recorder.EventRecorder
|
||||||
NewClient func(endpoint string) *tdclient.Client
|
NewClient func(endpoint string) *tdclient.Client
|
||||||
}
|
}
|
||||||
@@ -50,10 +41,16 @@ type TerdutTeamReconciler struct {
|
|||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/status,verbs=get;update;patch
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/status,verbs=get;update;patch
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/finalizers,verbs=update
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/finalizers,verbs=update
|
||||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch
|
||||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
||||||
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
|
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
|
||||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
||||||
|
|
||||||
|
// teamExternalID is the identity this CR gives its team on the server, so the
|
||||||
|
// team is found by who owns it and not by its (editable, global) display name.
|
||||||
|
func teamExternalID(team *terdutv1alpha1.TerdutTeam) string {
|
||||||
|
return team.Namespace + "/" + team.Name
|
||||||
|
}
|
||||||
|
|
||||||
func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
log := logf.FromContext(ctx)
|
log := logf.FromContext(ctx)
|
||||||
|
|
||||||
@@ -79,67 +76,56 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
|
|
||||||
srv, resolveErr := r.resolveServerRef(ctx, &team)
|
srv, resolveErr := r.resolveServerRef(ctx, &team)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return r.setTeamNotReady(ctx, &team, resolveErr.reason, resolveErr.message, waitInterval)
|
return r.setTeamNotReady(ctx, &team, resolveErr.reason, resolveErr.message)
|
||||||
}
|
}
|
||||||
if srv.Status.CredentialsSecretRef == nil {
|
if !meta.IsStatusConditionTrue(srv.Status.Conditions, terdutv1alpha1.ConditionReady) {
|
||||||
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer,
|
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer,
|
||||||
fmt.Sprintf("TerdutServer %q is not Bootstrapped yet", srv.Name), waitInterval)
|
fmt.Sprintf("TerdutServer %q is not Ready yet", srv.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
newClient := r.NewClient
|
tc, err := r.serverClient(ctx, srv)
|
||||||
if newClient == nil {
|
|
||||||
newClient = tdclient.New
|
|
||||||
}
|
|
||||||
|
|
||||||
instanceKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, srv.Status.CredentialsSecretRef)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
endpoint := serviceURL(srv)
|
|
||||||
team.Status.ServerEndpoint = endpoint
|
|
||||||
instanceClient := newClient(endpoint).WithToken(instanceKey)
|
|
||||||
|
|
||||||
if team.Status.TeamID == 0 {
|
// Idempotent on the external id: the same team comes back whether this is
|
||||||
if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil {
|
// the first reconcile, a retry after a lost status write, or a repair after
|
||||||
return ctrl.Result{}, err
|
// somebody deleted the team behind our back.
|
||||||
}
|
created, err := tc.CreateTeam(ctx, team.Spec.DisplayName, teamExternalID(&team))
|
||||||
}
|
|
||||||
|
|
||||||
if team.Status.CredentialsSecretRef == nil {
|
|
||||||
if err := r.mintTeamCredential(ctx, &team, instanceClient); err != nil {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
if se, ok := errors.AsType[*tdclient.StatusError](err); ok && se.Code == http.StatusConflict {
|
||||||
|
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonTeamNameTaken, nameTakenMessage(&team))
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, fmt.Errorf("POST /api/teams: %w", err)
|
||||||
}
|
}
|
||||||
teamClient := newClient(endpoint).WithToken(teamKey)
|
team.Status.TeamID = created.ID
|
||||||
|
|
||||||
// Owner-gated on terdut-server, so this always runs with the
|
if err := tc.RenameTeam(ctx, created.ID, team.Spec.DisplayName); err != nil {
|
||||||
// team-scoped credential just minted above, never the instance-scoped
|
if se, ok := errors.AsType[*tdclient.StatusError](err); ok && se.Code == http.StatusConflict {
|
||||||
// one used to create the team (internal/api/teams.go's requireTeamOwner
|
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonTeamNameTaken, nameTakenMessage(&team))
|
||||||
// has no branch for an instance-scoped service account, confirmed
|
}
|
||||||
// against source). Applied unconditionally rather than diffed against a
|
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", created.ID, err)
|
||||||
// stored "last-applied" value: both calls are idempotent PUTs of the
|
|
||||||
// whole resource, the same "cheap because it's small" reasoning §5
|
|
||||||
// already applies to the escalation policy's whole-policy PUT.
|
|
||||||
if err := teamClient.RenameTeam(ctx, team.Status.TeamID, team.Spec.DisplayName); err != nil {
|
|
||||||
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", team.Status.TeamID, err)
|
|
||||||
}
|
}
|
||||||
if err := teamClient.SetTeamOIDCGroups(ctx, team.Status.TeamID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil {
|
if err := tc.SetTeamOIDCGroups(ctx, created.ID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil {
|
||||||
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", team.Status.TeamID, err)
|
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", created.ID, err)
|
||||||
}
|
}
|
||||||
if err := r.reconcileInvite(ctx, &team, teamClient); err != nil {
|
|
||||||
|
if condErr, err := r.reconcileEscalation(ctx, tc, &team); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
|
} else if condErr != nil {
|
||||||
|
return r.setTeamNotReady(ctx, &team, condErr.reason, condErr.message)
|
||||||
|
}
|
||||||
|
if condErr, err := r.reconcileDeadmanSwitches(ctx, tc, &team); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
} else if condErr != nil {
|
||||||
|
return r.setTeamNotReady(ctx, &team, condErr.reason, condErr.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
Status: metav1.ConditionTrue,
|
Status: metav1.ConditionTrue,
|
||||||
Reason: terdutv1alpha1.ReasonTeamAdopted,
|
Reason: terdutv1alpha1.ReasonTeamAdopted,
|
||||||
Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name),
|
Message: fmt.Sprintf("team %d applied", team.Status.TeamID),
|
||||||
})
|
})
|
||||||
team.Status.ObservedGeneration = team.Generation
|
team.Status.ObservedGeneration = team.Generation
|
||||||
if err := r.Status().Update(ctx, &team); err != nil {
|
if err := r.Status().Update(ctx, &team); err != nil {
|
||||||
@@ -147,13 +133,31 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
}
|
}
|
||||||
if r.Recorder != nil {
|
if r.Recorder != nil {
|
||||||
r.Recorder.Eventf(&team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonTeamAdopted, terdutv1alpha1.ReasonTeamAdopted,
|
r.Recorder.Eventf(&team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonTeamAdopted, terdutv1alpha1.ReasonTeamAdopted,
|
||||||
"team ready")
|
"team applied")
|
||||||
}
|
}
|
||||||
log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID)
|
log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID)
|
||||||
|
|
||||||
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nameTakenMessage(team *terdutv1alpha1.TerdutTeam) string {
|
||||||
|
return fmt.Sprintf("the server already has a different team named %q; choose another spec.displayName, "+
|
||||||
|
"or remove that team", team.Spec.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverClient builds a client for srv, authenticated with its operator key.
|
||||||
|
func (r *TerdutTeamReconciler) serverClient(ctx context.Context, srv *terdutv1alpha1.TerdutServer) (*tdclient.Client, error) {
|
||||||
|
key, err := readOperatorKey(ctx, r.Client, srv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newClient := r.NewClient
|
||||||
|
if newClient == nil {
|
||||||
|
newClient = tdclient.New
|
||||||
|
}
|
||||||
|
return newClient(serviceURL(srv)).WithToken(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
// teamError carries a condition reason/message, the same role databaseError
|
// teamError carries a condition reason/message, the same role databaseError
|
||||||
// plays for TerdutServer: an expected, requeue-and-retry outcome, not a
|
// plays for TerdutServer: an expected, requeue-and-retry outcome, not a
|
||||||
// reconcile failure.
|
// reconcile failure.
|
||||||
@@ -168,6 +172,21 @@ func (e *teamError) Error() string { return e.message }
|
|||||||
// consent check (DESIGN.md §4.6) when serverRef.namespace differs from this
|
// consent check (DESIGN.md §4.6) when serverRef.namespace differs from this
|
||||||
// TerdutTeam's own.
|
// TerdutTeam's own.
|
||||||
func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, *teamError) {
|
func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, *teamError) {
|
||||||
|
srv, ns, getErr := r.getServerRef(ctx, team)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
if ns == team.Namespace {
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
return r.checkConsent(ctx, team, srv, ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServerRef fetches the referenced TerdutServer and the namespace it was
|
||||||
|
// looked up in, without the consent check. Deleting a team uses it directly:
|
||||||
|
// consent gates what the operator will start acting on, not whether it may
|
||||||
|
// clean up after itself once the server owner has narrowed allowedTeams.
|
||||||
|
func (r *TerdutTeamReconciler) getServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, string, *teamError) {
|
||||||
ns := team.Spec.ServerRef.Namespace
|
ns := team.Spec.ServerRef.Namespace
|
||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = team.Namespace
|
ns = team.Namespace
|
||||||
@@ -176,18 +195,19 @@ func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdu
|
|||||||
var srv terdutv1alpha1.TerdutServer
|
var srv terdutv1alpha1.TerdutServer
|
||||||
if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &srv); err != nil {
|
if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &srv); err != nil {
|
||||||
if apierrors.IsNotFound(err) {
|
if apierrors.IsNotFound(err) {
|
||||||
return nil, &teamError{
|
return nil, ns, &teamError{
|
||||||
reason: terdutv1alpha1.ReasonServerRefNotFound,
|
reason: terdutv1alpha1.ReasonServerRefNotFound,
|
||||||
message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns),
|
message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()}
|
return nil, ns, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()}
|
||||||
}
|
|
||||||
|
|
||||||
if ns == team.Namespace {
|
|
||||||
return &srv, nil
|
|
||||||
}
|
}
|
||||||
|
return &srv, ns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkConsent applies DESIGN.md §4.6's allowedTeams gate to a cross-namespace
|
||||||
|
// reference.
|
||||||
|
func (r *TerdutTeamReconciler) checkConsent(ctx context.Context, team *terdutv1alpha1.TerdutTeam, srv *terdutv1alpha1.TerdutServer, ns string) (*terdutv1alpha1.TerdutServer, *teamError) {
|
||||||
var ownNamespace corev1.Namespace
|
var ownNamespace corev1.Namespace
|
||||||
if err := r.Get(ctx, client.ObjectKey{Name: team.Namespace}, &ownNamespace); err != nil {
|
if err := r.Get(ctx, client.ObjectKey{Name: team.Namespace}, &ownNamespace); err != nil {
|
||||||
return nil, &teamError{
|
return nil, &teamError{
|
||||||
@@ -206,11 +226,11 @@ func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdu
|
|||||||
ns, team.Spec.ServerRef.Name, team.Namespace),
|
ns, team.Spec.ServerRef.Name, team.Namespace),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *TerdutTeamReconciler) setTeamNotReady(
|
func (r *TerdutTeamReconciler) setTeamNotReady(
|
||||||
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration,
|
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string,
|
||||||
) (ctrl.Result, error) {
|
) (ctrl.Result, error) {
|
||||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
@@ -225,42 +245,18 @@ func (r *TerdutTeamReconciler) setTeamNotReady(
|
|||||||
if r.Recorder != nil {
|
if r.Recorder != nil {
|
||||||
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, reason, reason, message)
|
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, reason, reason, message)
|
||||||
}
|
}
|
||||||
return ctrl.Result{RequeueAfter: d}, nil
|
return ctrl.Result{RequeueAfter: waitInterval}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// teamCredentialsSecretName/teamServiceAccountName follow the same
|
// reconcileTeamDelete removes the team on the server, then the finalizer. The
|
||||||
// <namespace>.<name>-suffix convention TerdutServer's secrets use
|
// server refuses while the team has open incidents (409), which surfaces as a
|
||||||
// (DESIGN.md §6 point 3), keyed on the TerdutTeam CR's own identity rather
|
// retried error: tidying up must not be how a live page disappears.
|
||||||
// than its (mutable) displayName -- a service account's own name is
|
|
||||||
// globally unique across the whole install (internal/db/migrations/
|
|
||||||
// 014_service_accounts.sql's UNIQUE constraint, confirmed against source),
|
|
||||||
// so this has to be collision-safe the same way the credentials Secret
|
|
||||||
// names already are.
|
|
||||||
func teamCredentialsSecretName(team *terdutv1alpha1.TerdutTeam) string {
|
|
||||||
return fmt.Sprintf("%s.%s-team-credentials", team.Namespace, team.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func teamServiceAccountName(team *terdutv1alpha1.TerdutTeam) string {
|
|
||||||
return fmt.Sprintf("terdut-team.%s.%s", team.Namespace, team.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileTeamDelete cleans up the team-scoped credentials Secret and, if
|
|
||||||
// one was ever minted, deletes the team server-side first. If
|
|
||||||
// status.teamID or status.credentialsSecretRef was never set (the CR was
|
|
||||||
// deleted before reconciliation ever got that far), there is deliberately
|
|
||||||
// no attempt to clean up server-side: terdut-server's DELETE /api/teams/
|
|
||||||
// {teamID} is owner-gated (requireTeamOwner), and an instance-scoped
|
|
||||||
// credential -- the only one this controller would otherwise hold -- does
|
|
||||||
// not satisfy that check (confirmed against source, same finding as
|
|
||||||
// TEAM-LOOKUP.md's). A team created but never fully reconciled to Ready is
|
|
||||||
// left orphaned server-side for a human with real owner/admin access to
|
|
||||||
// clean up -- a known, documented limitation, not a silent gap.
|
|
||||||
func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (ctrl.Result, error) {
|
func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (ctrl.Result, error) {
|
||||||
if !controllerutil.ContainsFinalizer(team, teamFinalizerName) {
|
if !controllerutil.ContainsFinalizer(team, teamFinalizerName) {
|
||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if team.Status.TeamID != 0 && team.Status.CredentialsSecretRef != nil {
|
if team.Status.TeamID != 0 {
|
||||||
if err := r.deleteTeamServerSide(ctx, team); err != nil {
|
if err := r.deleteTeamServerSide(ctx, team); err != nil {
|
||||||
if r.Recorder != nil {
|
if r.Recorder != nil {
|
||||||
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
||||||
@@ -269,40 +265,33 @@ func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: teamCredentialsSecretName(team), Namespace: r.OperatorNamespace}}
|
|
||||||
if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
return ctrl.Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
controllerutil.RemoveFinalizer(team, teamFinalizerName)
|
controllerutil.RemoveFinalizer(team, teamFinalizerName)
|
||||||
return ctrl.Result{}, r.Update(ctx, team)
|
return ctrl.Result{}, r.Update(ctx, team)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error {
|
func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error {
|
||||||
srv, resolveErr := r.resolveServerRef(ctx, team)
|
// Consent is deliberately not checked here: it gates what the operator
|
||||||
if resolveErr != nil {
|
// starts acting on, not whether it may clean up after itself once the
|
||||||
// The TerdutServer (or the namespace consent for it) is gone too --
|
// server owner has narrowed allowedTeams. Only a TerdutServer that is
|
||||||
// most likely the whole install is being torn down together.
|
// really gone lets the delete proceed (there is nothing to delete against);
|
||||||
// Nothing to delete against; proceed rather than block forever on
|
// any other failure is retried rather than orphaning the team.
|
||||||
// a parent that no longer exists.
|
srv, ns, getErr := r.getServerRef(ctx, team)
|
||||||
return nil
|
if getErr != nil {
|
||||||
}
|
var probe terdutv1alpha1.TerdutServer
|
||||||
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
|
err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &probe)
|
||||||
if err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
if apierrors.IsNotFound(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("reading TerdutServer %q/%q: %w", ns, team.Spec.ServerRef.Name, getErr)
|
||||||
|
}
|
||||||
|
tc, err := r.serverClient(ctx, srv)
|
||||||
|
if err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil // the key Secret went with its TerdutServer
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
newClient := r.NewClient
|
return tc.DeleteTeam(ctx, team.Status.TeamID)
|
||||||
if newClient == nil {
|
|
||||||
newClient = tdclient.New
|
|
||||||
}
|
|
||||||
err = newClient(serviceURL(srv)).WithToken(teamKey).DeleteTeam(ctx, team.Status.TeamID)
|
|
||||||
if statusErr, ok := errors.AsType[*tdclient.StatusError](err); ok && statusErr.Code == http.StatusNotFound {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupWithManager sets up the controller with the Manager.
|
// SetupWithManager sets up the controller with the Manager.
|
||||||
@@ -315,6 +304,28 @@ func (r *TerdutTeamReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
}
|
}
|
||||||
return ctrl.NewControllerManagedBy(mgr).
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
For(&terdutv1alpha1.TerdutTeam{}).
|
For(&terdutv1alpha1.TerdutTeam{}).
|
||||||
|
Watches(&terdutv1alpha1.TerdutServer{}, handler.EnqueueRequestsFromMapFunc(r.teamsForServer)).
|
||||||
Named("terdutteam").
|
Named("terdutteam").
|
||||||
Complete(r)
|
Complete(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// teamsForServer re-reconciles every TerdutTeam that references a TerdutServer
|
||||||
|
// when it changes (becomes Ready, say), instead of polling for it.
|
||||||
|
func (r *TerdutTeamReconciler) teamsForServer(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||||
|
var teams terdutv1alpha1.TerdutTeamList
|
||||||
|
if err := r.List(ctx, &teams); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []reconcile.Request
|
||||||
|
for i := range teams.Items {
|
||||||
|
t := &teams.Items[i]
|
||||||
|
ns := t.Spec.ServerRef.Namespace
|
||||||
|
if ns == "" {
|
||||||
|
ns = t.Namespace
|
||||||
|
}
|
||||||
|
if t.Spec.ServerRef.Name == obj.GetName() && ns == obj.GetNamespace() {
|
||||||
|
out = append(out, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(t)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"time"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@@ -18,6 +16,12 @@ import (
|
|||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// platformName is the display name most specs give their team.
|
||||||
|
const platformName = "platform"
|
||||||
|
|
||||||
|
// allNamespaces is allowedTeams.namespaces.from admitting every namespace.
|
||||||
|
const allNamespaces = "All"
|
||||||
|
|
||||||
var _ = Describe("TerdutTeam Controller", func() {
|
var _ = Describe("TerdutTeam Controller", func() {
|
||||||
const operatorNamespace = "default"
|
const operatorNamespace = "default"
|
||||||
|
|
||||||
@@ -34,13 +38,12 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
fake, fakeSrv = newFakeTerdutServer()
|
fake, fakeSrv = newFakeTerdutServer()
|
||||||
DeferCleanup(fakeSrv.Close)
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
|
||||||
srv = bootstrapReadyTerdutServer(ctx, uniqueName("ttserver"), fakeSrv.URL)
|
srv = readyTerdutServer(ctx, uniqueName("ttserver"))
|
||||||
|
|
||||||
reconciler = &TerdutTeamReconciler{
|
reconciler = &TerdutTeamReconciler{
|
||||||
Client: k8sClient,
|
Client: k8sClient,
|
||||||
Scheme: k8sClient.Scheme(),
|
Scheme: k8sClient.Scheme(),
|
||||||
OperatorNamespace: operatorNamespace,
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
|
||||||
}
|
}
|
||||||
teamName = uniqueName("team")
|
teamName = uniqueName("team")
|
||||||
teamKey = types.NamespacedName{Name: teamName, Namespace: operatorNamespace}
|
teamKey = types.NamespacedName{Name: teamName, Namespace: operatorNamespace}
|
||||||
@@ -53,13 +56,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
_ = k8sClient.Update(ctx, team)
|
_ = k8sClient.Update(ctx, team)
|
||||||
_ = k8sClient.Delete(ctx, team)
|
_ = k8sClient.Delete(ctx, team)
|
||||||
}
|
}
|
||||||
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: fmt.Sprintf("%s.%s-team-credentials", operatorNamespace, teamName), Namespace: operatorNamespace,
|
|
||||||
}})
|
|
||||||
|
|
||||||
// The TerdutServer bootstrapReadyTerdutServer created in BeforeEach
|
|
||||||
// carries its own finalizer; clear it directly the same way, rather
|
|
||||||
// than relying on TerdutServerReconciler to ever run again here.
|
|
||||||
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
||||||
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
||||||
srv.Finalizers = nil
|
srv.Finalizers = nil
|
||||||
@@ -67,7 +64,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
_ = k8sClient.Delete(ctx, srv)
|
_ = k8sClient.Delete(ctx, srv)
|
||||||
}
|
}
|
||||||
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: fmt.Sprintf("%s.%s-instance-credentials", operatorNamespace, srv.Name), Namespace: operatorNamespace,
|
Name: srv.Name + "-operator-key", Namespace: operatorNamespace,
|
||||||
}})
|
}})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,39 +93,56 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
return terdutv1alpha1.TerdutServerRef{Name: srv.Name}
|
return terdutv1alpha1.TerdutServerRef{Name: srv.Name}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTeam := func(ctx context.Context) *terdutv1alpha1.TerdutTeam {
|
||||||
|
team := &terdutv1alpha1.TerdutTeam{}
|
||||||
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileToReady drives a freshly created team through the finalizer pass
|
||||||
|
// and one apply pass.
|
||||||
|
reconcileToReady := func(ctx context.Context) {
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx) // apply
|
||||||
|
}
|
||||||
|
|
||||||
Describe("the happy path", func() {
|
Describe("the happy path", func() {
|
||||||
It("creates the team, mints its credential, and applies rename/oidc-groups", func(ctx SpecContext) {
|
It("creates the team under its CR identity with the operator key, and applies oidc groups", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
// The fake only answers a request carrying the server's operator key.
|
||||||
reconcileOnce(ctx) // finalizer
|
var keySecret corev1.Secret
|
||||||
reconcileOnce(ctx) // full create+mint+apply
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name + "-operator-key", Namespace: operatorNamespace}, &keySecret)).To(Succeed())
|
||||||
|
fake.expectKey = string(keySecret.Data[operatorKeyDataKey])
|
||||||
|
|
||||||
|
team := &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||||
|
ServerRef: sameNSRef(), DisplayName: platformName,
|
||||||
|
OIDC: terdutv1alpha1.TerdutTeamOIDC{MemberGroup: "members", OwnerGroup: "owners"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, team)).To(Succeed())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
|
||||||
cond := readyCondition(ctx)
|
cond := readyCondition(ctx)
|
||||||
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
team = getTeam(ctx)
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
Expect(team.Status.TeamID).To(Equal(fake.teams[platformName]))
|
||||||
Expect(team.Status.TeamID).To(Equal(fake.teams["platform"]))
|
Expect(fake.teamExt).To(HaveKeyWithValue(operatorNamespace+"/"+teamName, team.Status.TeamID))
|
||||||
Expect(team.Status.CredentialsSecretRef).NotTo(BeNil())
|
Expect(fake.teamOIDC[team.Status.TeamID]).To(Equal([2]string{"members", "owners"}))
|
||||||
|
|
||||||
var credsSecret corev1.Secret
|
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
||||||
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
|
||||||
}, &credsSecret)).To(Succeed())
|
|
||||||
Expect(credsSecret.Data[credentialsSecretDataKey]).NotTo(BeEmpty())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("waiting on the referenced TerdutServer", func() {
|
Describe("waiting on the referenced TerdutServer", func() {
|
||||||
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
|
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: testRefNotFoundName})
|
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: testRefNotFoundName})
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileToReady(ctx)
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonServerRefNotFound))
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonServerRefNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports WaitingForServer when the TerdutServer exists but isn't Bootstrapped yet", func(ctx SpecContext) {
|
It("reports WaitingForServer when the TerdutServer exists but isn't Ready yet", func(ctx SpecContext) {
|
||||||
unreadyName := uniqueName("ttserver-unready")
|
unreadyName := uniqueName("ttserver-unready")
|
||||||
unready := &terdutv1alpha1.TerdutServer{
|
unready := &terdutv1alpha1.TerdutServer{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
||||||
@@ -142,8 +156,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
||||||
|
|
||||||
createTeam(ctx, "waiting", terdutv1alpha1.TerdutServerRef{Name: unreadyName})
|
createTeam(ctx, "waiting", terdutv1alpha1.TerdutServerRef{Name: unreadyName})
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileToReady(ctx)
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForServer))
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForServer))
|
||||||
})
|
})
|
||||||
@@ -182,7 +195,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
|
|
||||||
It("permits when the TerdutServer's allowedTeams.namespaces.from is All", func(ctx SpecContext) {
|
It("permits when the TerdutServer's allowedTeams.namespaces.from is All", func(ctx SpecContext) {
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
|
||||||
srv.Spec.AllowedTeams.Namespaces.From = "All"
|
srv.Spec.AllowedTeams.Namespaces.From = allNamespaces
|
||||||
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{
|
team := &terdutv1alpha1.TerdutTeam{
|
||||||
@@ -209,154 +222,292 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("adopt-on-409 recovery", func() {
|
Describe("team identity", func() {
|
||||||
It("adopts an already-created team instead of erroring", func(ctx SpecContext) {
|
It("refuses a name that belongs to a team this CR did not create", func(ctx SpecContext) {
|
||||||
fake.nextTeamID = 1
|
fake.nextTeamID = 1
|
||||||
fake.teams["platform"] = 1
|
fake.teams[platformName] = 1
|
||||||
fake.teamNames[1] = "platform"
|
fake.teamNames[1] = platformName
|
||||||
|
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileToReady(ctx)
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
cond := readyCondition(ctx)
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
|
||||||
Expect(team.Status.TeamID).To(Equal(int64(1)))
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamNameTaken))
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
Expect(getTeam(ctx).Status.TeamID).To(BeZero())
|
||||||
|
Expect(fake.teamOIDC).To(BeEmpty(), "nothing is configured on a team that is not ours")
|
||||||
})
|
})
|
||||||
|
|
||||||
It("adopts an already-created team-scoped service account instead of erroring", func(ctx SpecContext) {
|
It("keeps the same team when spec.displayName changes", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
|
||||||
// Pre-seed the service account the mint step is about to try to
|
team := getTeam(ctx)
|
||||||
// create, simulating an attempt that got this far before being
|
team.Spec.DisplayName = "platform-renamed"
|
||||||
// interrupted.
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
||||||
fake.nextID = 1
|
reconcileOnce(ctx)
|
||||||
saName := fmt.Sprintf("terdut-team.%s.%s", operatorNamespace, teamName)
|
|
||||||
fake.accounts[saName] = 1
|
Expect(getTeam(ctx).Status.TeamID).To(Equal(id))
|
||||||
fake.keyMints[1] = 1
|
Expect(fake.teamNames[id]).To(Equal("platform-renamed"))
|
||||||
|
Expect(fake.nextTeamID).To(Equal(id), "renamed in place, not recreated")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports TeamNameTaken when a rename collides with another team", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
fake.nextTeamID++
|
||||||
|
fake.teams["other"] = fake.nextTeamID
|
||||||
|
fake.teamNames[fake.nextTeamID] = "other"
|
||||||
|
|
||||||
|
team := getTeam(ctx)
|
||||||
|
team.Spec.DisplayName = "other"
|
||||||
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamNameTaken))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("finds its team again when the status was lost", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
|
||||||
|
team := getTeam(ctx)
|
||||||
|
team.Status.TeamID = 0
|
||||||
|
Expect(k8sClient.Status().Update(ctx, team)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(getTeam(ctx).Status.TeamID).To(Equal(id))
|
||||||
|
Expect(fake.nextTeamID).To(Equal(id), "no second team was created")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("recreates a team deleted behind its back", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
delete(fake.teams, platformName)
|
||||||
|
delete(fake.teamNames, id)
|
||||||
|
delete(fake.teamExt, operatorNamespace+"/"+teamName)
|
||||||
|
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(getTeam(ctx).Status.TeamID).NotTo(Equal(id))
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
||||||
var credsSecret corev1.Secret
|
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
||||||
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
|
||||||
}, &credsSecret)).To(Succeed())
|
|
||||||
// Minted fresh (mint2), not the pre-seeded account's original
|
|
||||||
// (never-issued-to-this-reconcile) key.
|
|
||||||
Expect(string(credsSecret.Data[credentialsSecretDataKey])).To(Equal("instance-key-1-mint2"))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("spec.invite", func() {
|
Describe("spec.escalation", func() {
|
||||||
It("mints a link into the TerdutTeam's own namespace, not the operator's", func(ctx SpecContext) {
|
ladder := func() *terdutv1alpha1.EscalationSpec {
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
return &terdutv1alpha1.EscalationSpec{
|
||||||
reconcileOnce(ctx) // finalizer
|
RepeatCount: 2, FallbackTopic: "oncall",
|
||||||
reconcileOnce(ctx) // create+mint+apply
|
Levels: []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
|
||||||
|
{Timeout: "15m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createWithLadder := func(ctx context.Context, e *terdutv1alpha1.EscalationSpec) {
|
||||||
|
Expect(k8sClient.Create(ctx, &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{ServerRef: sameNSRef(), DisplayName: platformName, Escalation: e},
|
||||||
|
})).To(Succeed())
|
||||||
|
}
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
It("PUTs the ladder with usernames for the server to resolve", func(ctx SpecContext) {
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
createWithLadder(ctx, ladder())
|
||||||
team.Spec.Invite.Enabled = true
|
reconcileToReady(ctx)
|
||||||
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
got := fake.escalation[getTeam(ctx).Status.TeamID]
|
||||||
Expect(team.Status.InviteSecretRef).NotTo(BeNil())
|
Expect(got.RepeatCount).To(Equal(int64(2)))
|
||||||
|
Expect(got.FallbackTopic).To(Equal("oncall"))
|
||||||
var secret corev1.Secret
|
Expect(got.Levels).To(HaveLen(2))
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
Expect(got.Levels[0].TimeoutSeconds).To(Equal(int64(300)))
|
||||||
Name: team.Status.InviteSecretRef.Name, Namespace: team.Namespace,
|
Expect(got.Levels[1].Targets[0]).To(Equal(tdclient.EscalationTargetRequest{Kind: "user", Username: "alice"}))
|
||||||
}, &secret)).To(Succeed())
|
|
||||||
Expect(string(secret.Data[inviteSecretURLKey])).To(ContainSubstring("invite="))
|
|
||||||
Expect(string(secret.Data[inviteSecretInviteIDKey])).To(Equal("1"))
|
|
||||||
|
|
||||||
inv := fake.invites[team.Status.TeamID][1]
|
|
||||||
Expect(inv.Role).To(Equal("member"), "default role")
|
|
||||||
Expect(inv.MaxUses).To(Equal(int64(1)), "default max uses")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("refreshes a link that's within a day of terdut-server's 7-day TTL", func(ctx SpecContext) {
|
It("waits with UnknownUser while the server does not know a username", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
fake.unknownUsers["alice"] = true
|
||||||
|
createWithLadder(ctx, ladder())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
|
||||||
|
cond := readyCondition(ctx)
|
||||||
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonUnknownUser))
|
||||||
|
Expect(cond.Message).To(ContainSubstring("alice"))
|
||||||
|
|
||||||
|
delete(fake.unknownUsers, "alice")
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
reconcileOnce(ctx)
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
||||||
team.Spec.Invite.Enabled = true
|
|
||||||
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
||||||
firstSecretName := team.Status.InviteSecretRef.Name
|
|
||||||
var secret corev1.Secret
|
|
||||||
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: firstSecretName, Namespace: team.Namespace}, &secret)).To(Succeed())
|
|
||||||
|
|
||||||
// Simulate the stored link being within the refresh window of
|
|
||||||
// expiry, the way it genuinely would be six days from now,
|
|
||||||
// without the test waiting six days.
|
|
||||||
secret.Data[inviteSecretExpiresAtKey] = []byte(time.Now().Add(12 * time.Hour).Format(time.RFC3339)) // inside inviteRefreshWindow
|
|
||||||
Expect(k8sClient.Update(ctx, &secret)).To(Succeed())
|
|
||||||
|
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
Expect(fake.inviteDelete[1]).To(BeTrue(), "the stale invite should have been revoked")
|
|
||||||
Expect(fake.invites[team.Status.TeamID]).To(HaveKey(int64(2)), "a replacement should have been minted")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("revokes the invite when spec.invite.enabled flips back to false", func(ctx SpecContext) {
|
It("clears the ladder when spec.escalation is removed", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "platform", sameNSRef())
|
createWithLadder(ctx, ladder())
|
||||||
reconcileOnce(ctx)
|
reconcileToReady(ctx)
|
||||||
reconcileOnce(ctx)
|
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
team := getTeam(ctx)
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
team.Spec.Escalation = nil
|
||||||
team.Spec.Invite.Enabled = true
|
|
||||||
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
Expect(fake.escalation[team.Status.TeamID].Levels).To(BeEmpty())
|
||||||
secretName := team.Status.InviteSecretRef.Name
|
})
|
||||||
team.Spec.Invite.Enabled = false
|
|
||||||
|
It("reports InvalidSpec for a duration that does not parse", func(ctx SpecContext) {
|
||||||
|
bad := ladder()
|
||||||
|
bad.Levels[0].Timeout = "5 minutes"
|
||||||
|
// The CRD pattern rejects it at admission when the API server enforces it;
|
||||||
|
// envtest does, so write it past the pattern by creating a valid one first.
|
||||||
|
bad.Levels[0].Timeout = "5m"
|
||||||
|
createWithLadder(ctx, bad)
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
team := getTeam(ctx)
|
||||||
|
team.Spec.Escalation.Levels[0].Timeout = "0s"
|
||||||
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
Expect(fake.inviteDelete[1]).To(BeTrue())
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonInvalidSpec))
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
})
|
||||||
Expect(team.Status.InviteSecretRef).To(BeNil())
|
})
|
||||||
|
|
||||||
var secret corev1.Secret
|
Describe("spec.deadmanSwitches", func() {
|
||||||
err := k8sClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: team.Namespace}, &secret)
|
sw := func(name, matcher, timeout string) terdutv1alpha1.DeadmanSwitchSpec {
|
||||||
Expect(err).To(HaveOccurred(), "the invite Secret should have been deleted")
|
return terdutv1alpha1.DeadmanSwitchSpec{Name: name, Matcher: matcher, Timeout: timeout, Severity: "critical"}
|
||||||
|
}
|
||||||
|
setSwitches := func(ctx context.Context, switches ...terdutv1alpha1.DeadmanSwitchSpec) {
|
||||||
|
team := getTeam(ctx)
|
||||||
|
team.Spec.DeadmanSwitches = switches
|
||||||
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
}
|
||||||
|
names := func(teamID int64) []string {
|
||||||
|
out := make([]string, 0, len(fake.switches[teamID]))
|
||||||
|
for _, s := range fake.switches[teamID] {
|
||||||
|
out = append(out, s.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
It("creates, updates and prunes switches by name", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
|
||||||
|
setSwitches(ctx, sw("watchdog", "alertname=Watchdog", "15m"), sw("edge", "alertname=Edge", "5m"))
|
||||||
|
Expect(names(id)).To(ConsistOf("watchdog", "edge"))
|
||||||
|
|
||||||
|
// Same name, new timeout: updated in place, same id.
|
||||||
|
var watchdogID int64
|
||||||
|
for _, s := range fake.switches[id] {
|
||||||
|
if s.Name == "watchdog" {
|
||||||
|
watchdogID = s.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSwitches(ctx, sw("watchdog", "alertname=Watchdog", "30m"))
|
||||||
|
Expect(names(id)).To(ConsistOf("watchdog"))
|
||||||
|
Expect(fake.switches[id][watchdogID].TimeoutSeconds).To(Equal(int64(1800)))
|
||||||
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("removes a switch somebody added on the server", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
fake.nextSwitchID++
|
||||||
|
fake.switches[id] = map[int64]tdclient.DeadmanSwitch{
|
||||||
|
fake.nextSwitchID: {ID: fake.nextSwitchID, Name: "stray", Matcher: "alertname=X", TimeoutSeconds: 60, Severity: "critical"},
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(fake.switches[id]).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("recreates a switch deleted on the server", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
id := getTeam(ctx).Status.TeamID
|
||||||
|
setSwitches(ctx, sw("watchdog", "alertname=Watchdog", "15m"))
|
||||||
|
fake.switches[id] = map[int64]tdclient.DeadmanSwitch{}
|
||||||
|
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(names(id)).To(ConsistOf("watchdog"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports InvalidSpec for a timeout that does not parse", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, platformName, sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
setSwitches(ctx, sw("watchdog", "alertname=Watchdog", "0s"))
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonInvalidSpec))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("deletion", func() {
|
Describe("deletion", func() {
|
||||||
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
|
It("deletes the team on the server and clears the finalizer", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "to-delete", sameNSRef())
|
createTeam(ctx, "to-delete", sameNSRef())
|
||||||
reconcileOnce(ctx)
|
reconcileToReady(ctx)
|
||||||
reconcileOnce(ctx)
|
teamID := getTeam(ctx).Status.TeamID
|
||||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
|
||||||
|
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
Expect(k8sClient.Delete(ctx, getTeam(ctx))).To(Succeed())
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
||||||
teamID := team.Status.TeamID
|
|
||||||
credsName := team.Status.CredentialsSecretRef.Name
|
|
||||||
|
|
||||||
Expect(k8sClient.Delete(ctx, team)).To(Succeed())
|
|
||||||
reconcileOnce(ctx) // runs the finalizer
|
reconcileOnce(ctx) // runs the finalizer
|
||||||
|
|
||||||
Expect(fake.teamDelete[teamID]).To(BeTrue())
|
Expect(fake.teamDelete[teamID]).To(BeTrue())
|
||||||
|
Expect(k8sClient.Get(ctx, teamKey, &terdutv1alpha1.TerdutTeam{})).NotTo(Succeed(),
|
||||||
|
"the TerdutTeam should be gone once the finalizer clears")
|
||||||
|
})
|
||||||
|
|
||||||
err := k8sClient.Get(ctx, teamKey, team)
|
It("still deletes the team after the server's allowedTeams consent is revoked", func(ctx SpecContext) {
|
||||||
Expect(err).To(HaveOccurred(), "the TerdutTeam itself should be gone once the finalizer clears")
|
otherNS := uniqueName("ns")
|
||||||
|
Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}})).To(Succeed())
|
||||||
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}}) })
|
||||||
|
|
||||||
var leftover corev1.Secret
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
|
||||||
err = k8sClient.Get(ctx, types.NamespacedName{Name: credsName, Namespace: operatorNamespace}, &leftover)
|
srv.Spec.AllowedTeams.Namespaces.From = allNamespaces
|
||||||
Expect(err).To(HaveOccurred(), "the team-credentials Secret should have been cleaned up")
|
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||||
|
|
||||||
|
crossKey := types.NamespacedName{Name: teamName, Namespace: otherNS}
|
||||||
|
Expect(k8sClient.Create(ctx, &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: otherNS},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||||
|
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name, Namespace: operatorNamespace},
|
||||||
|
DisplayName: "revoked",
|
||||||
|
},
|
||||||
|
})).To(Succeed())
|
||||||
|
for range 2 {
|
||||||
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
}
|
||||||
|
cross := &terdutv1alpha1.TerdutTeam{}
|
||||||
|
Expect(k8sClient.Get(ctx, crossKey, cross)).To(Succeed())
|
||||||
|
teamID := cross.Status.TeamID
|
||||||
|
Expect(teamID).NotTo(BeZero())
|
||||||
|
|
||||||
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
|
||||||
|
srv.Spec.AllowedTeams.Namespaces.From = "None"
|
||||||
|
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(k8sClient.Delete(ctx, cross)).To(Succeed())
|
||||||
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(fake.teamDelete[teamID]).To(BeTrue(), "revoking consent must not orphan the team")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("lets the CR go when its TerdutServer is already gone", func(ctx SpecContext) {
|
||||||
|
createTeam(ctx, "to-delete", sameNSRef())
|
||||||
|
reconcileToReady(ctx)
|
||||||
|
|
||||||
|
serverKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
||||||
|
Expect(k8sClient.Get(ctx, serverKey, srv)).To(Succeed())
|
||||||
|
srv.Finalizers = nil
|
||||||
|
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||||
|
Expect(k8sClient.Delete(ctx, srv)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(k8sClient.Delete(ctx, getTeam(ctx))).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(k8sClient.Get(ctx, teamKey, &terdutv1alpha1.TerdutTeam{})).NotTo(Succeed())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
||||||
|
|
||||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Data keys inside the generated invite Secret, following the same naming
|
|
||||||
// shape as TerdutAlertSource's webhookSecret*Key constants.
|
|
||||||
const (
|
|
||||||
inviteSecretURLKey = "url"
|
|
||||||
inviteSecretInviteIDKey = "inviteID"
|
|
||||||
inviteSecretExpiresAtKey = "expiresAt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// inviteRefreshWindow is how far ahead of expiry this controller mints a
|
|
||||||
// replacement link, so a human reading status.inviteSecretRef never finds a
|
|
||||||
// dead link mid-use. terdut-server's invite TTL is a fixed, unconfigurable
|
|
||||||
// 7 days (internal/api/signup.go's inviteTTL) -- refreshing a full day
|
|
||||||
// ahead of that leaves comfortable margin against this controller's own
|
|
||||||
// 5-minute resync interval ever being delayed.
|
|
||||||
const inviteRefreshWindow = 24 * time.Hour
|
|
||||||
|
|
||||||
func inviteSecretName(team *terdutv1alpha1.TerdutTeam) string {
|
|
||||||
return team.Name + "-terdut-invite"
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileInvite applies spec.invite against teamClient -- this team's own
|
|
||||||
// team-scoped credential, already owner-equivalent for every /invites route
|
|
||||||
// (terdut-server's SERVICE-ACCOUNTS.md, ratified not accidental). Mints,
|
|
||||||
// refreshes ahead of expiry, or revokes, entirely independent of this
|
|
||||||
// team's own Ready condition: an invite is a convenience for onboarding a
|
|
||||||
// human, never something anything else in this reconcile waits on.
|
|
||||||
func (r *TerdutTeamReconciler) reconcileInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error {
|
|
||||||
if !team.Spec.Invite.Enabled {
|
|
||||||
return r.revokeInvite(ctx, team, teamClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
secretName := inviteSecretName(team)
|
|
||||||
var secret corev1.Secret
|
|
||||||
err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: secretName}, &secret)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
expiresAt, parseErr := time.Parse(time.RFC3339, string(secret.Data[inviteSecretExpiresAtKey]))
|
|
||||||
if parseErr == nil && time.Until(expiresAt) > inviteRefreshWindow {
|
|
||||||
team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName}
|
|
||||||
return nil // still fresh, nothing to do this reconcile
|
|
||||||
}
|
|
||||||
// Expired, about to expire, or unreadable: mint a replacement.
|
|
||||||
// Revoke the old row by id first (best-effort) so a leaked old link
|
|
||||||
// stops working immediately rather than lingering unrevoked until
|
|
||||||
// its own TTL -- failure here is not fatal, since the replacement
|
|
||||||
// below is what actually matters.
|
|
||||||
if oldID, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil {
|
|
||||||
_ = teamClient.RevokeInvite(ctx, team.Status.TeamID, oldID)
|
|
||||||
}
|
|
||||||
return r.mintInvite(ctx, team, teamClient, secretName)
|
|
||||||
case apierrors.IsNotFound(err):
|
|
||||||
// Low stakes, unlike TerdutAlertSource's webhook URL: nothing
|
|
||||||
// external holds a durable dependency on one specific invite link
|
|
||||||
// staying stable the way an Alertmanager config depends on a
|
|
||||||
// webhook URL -- it's read once by one human and handed out. So
|
|
||||||
// this silently re-mints rather than failing closed the way
|
|
||||||
// TerdutAlertSource's ReasonWebhookSecretLost does for its Secret.
|
|
||||||
return r.mintInvite(ctx, team, teamClient, secretName)
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TerdutTeamReconciler) mintInvite(
|
|
||||||
ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client, secretName string,
|
|
||||||
) error {
|
|
||||||
role := team.Spec.Invite.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "member"
|
|
||||||
}
|
|
||||||
maxUses := team.Spec.Invite.MaxUses
|
|
||||||
if maxUses == 0 {
|
|
||||||
maxUses = 1
|
|
||||||
}
|
|
||||||
inv, err := teamClient.CreateInvite(ctx, team.Status.TeamID, role, maxUses)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("POST /api/teams/%d/invites: %w", team.Status.TeamID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: team.Namespace}}
|
|
||||||
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error {
|
|
||||||
secret.Data = map[string][]byte{
|
|
||||||
inviteSecretURLKey: []byte(inv.URL),
|
|
||||||
inviteSecretInviteIDKey: []byte(strconv.FormatInt(inv.ID, 10)),
|
|
||||||
inviteSecretExpiresAtKey: []byte(inv.ExpiresAt.Format(time.RFC3339)),
|
|
||||||
}
|
|
||||||
return controllerutil.SetControllerReference(team, secret, r.Scheme)
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName}
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteMinted, terdutv1alpha1.ReasonInviteMinted,
|
|
||||||
"invite link minted into Secret %q", secretName)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// revokeInvite tears down spec.invite's Secret and server-side row when
|
|
||||||
// spec.invite.enabled is false (or was never set). Same-namespace and
|
|
||||||
// OwnerReference'd, so deleting the TerdutTeam itself already garbage-
|
|
||||||
// collects this Secret -- this path exists for the narrower case of
|
|
||||||
// flipping enabled back to false on an otherwise-live TerdutTeam.
|
|
||||||
func (r *TerdutTeamReconciler) revokeInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error {
|
|
||||||
if team.Status.InviteSecretRef == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name := team.Status.InviteSecretRef.Name
|
|
||||||
var secret corev1.Secret
|
|
||||||
err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: name}, &secret)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
if id, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil {
|
|
||||||
if err := teamClient.RevokeInvite(ctx, team.Status.TeamID, id); err != nil {
|
|
||||||
return fmt.Errorf("DELETE /api/teams/%d/invites/%d: %w", team.Status.TeamID, id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := r.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case !apierrors.IsNotFound(err):
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
team.Status.InviteSecretRef = nil
|
|
||||||
if r.Recorder != nil {
|
|
||||||
r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteRevoked, terdutv1alpha1.ReasonInviteRevoked,
|
|
||||||
"invite link revoked")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
@@ -47,21 +48,15 @@ const (
|
|||||||
testOperatorNamespace = "default"
|
testOperatorNamespace = "default"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bootstrapReadyTerdutServer creates a TerdutServer with a bring-your-own
|
// readyTerdutServer creates a TerdutServer with a bring-your-own DSN and drives
|
||||||
// DSN and drives it to Ready against fakeURL, the same three-pass sequence
|
// it to Ready the way the happy-path test does -- shared so the tests of
|
||||||
// terdutserver_controller_test.go's own happy-path test exercises directly
|
// everything that references a server (TerdutTeam, TerdutAlertSource) start
|
||||||
// -- shared here so TerdutTeam's tests (which need a real, Ready
|
// from a real, Ready one.
|
||||||
// TerdutServer to resolve against) don't duplicate it.
|
func readyTerdutServer(ctx context.Context, name string) *terdutv1alpha1.TerdutServer {
|
||||||
func bootstrapReadyTerdutServer(ctx context.Context, name, fakeURL string) *terdutv1alpha1.TerdutServer {
|
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
namespace := testOperatorNamespace
|
namespace := testOperatorNamespace
|
||||||
|
|
||||||
reconciler := &TerdutServerReconciler{
|
reconciler := &TerdutServerReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
|
||||||
Client: k8sClient,
|
|
||||||
Scheme: k8sClient.Scheme(),
|
|
||||||
OperatorNamespace: namespace,
|
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
|
|
||||||
}
|
|
||||||
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
||||||
|
|
||||||
srv := &terdutv1alpha1.TerdutServer{
|
srv := &terdutv1alpha1.TerdutServer{
|
||||||
@@ -74,9 +69,7 @@ func bootstrapReadyTerdutServer(ctx context.Context, name, fakeURL string) *terd
|
|||||||
}
|
}
|
||||||
Expect(k8sClient.Create(ctx, srv)).To(Succeed())
|
Expect(k8sClient.Create(ctx, srv)).To(Succeed())
|
||||||
|
|
||||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // Deployment, Service, key
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // Deployment/Service
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
var deploy appsv1.Deployment
|
var deploy appsv1.Deployment
|
||||||
@@ -85,27 +78,25 @@ func bootstrapReadyTerdutServer(ctx context.Context, name, fakeURL string) *terd
|
|||||||
deploy.Status.Replicas = 1
|
deploy.Status.Replicas = 1
|
||||||
Expect(k8sClient.Status().Update(ctx, &deploy)).To(Succeed())
|
Expect(k8sClient.Status().Update(ctx, &deploy)).To(Succeed())
|
||||||
|
|
||||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // bootstrap
|
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // Ready
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
||||||
Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil(), "test setup: TerdutServer %s/%s did not reach Bootstrapped", namespace, name)
|
Expect(meta.IsStatusConditionTrue(srv.Status.Conditions, terdutv1alpha1.ConditionReady)).To(BeTrue(),
|
||||||
|
"test setup: TerdutServer %s/%s did not reach Ready", namespace, name)
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// readyTerdutTeam creates a TerdutTeam under srv (an already-Ready
|
// readyTerdutTeam creates a TerdutTeam under srv (an already-Ready
|
||||||
// TerdutServer, e.g. from bootstrapReadyTerdutServer) and drives it to
|
// TerdutServer, from readyTerdutServer) and drives it to Ready against the fake
|
||||||
// Ready against fakeURL -- shared by TerdutEscalationRule's and
|
// at fakeURL.
|
||||||
// TerdutDeadmanSwitch's own tests, which both just need a resolvable
|
|
||||||
// teamRef (DESIGN.md §5), not TerdutTeam's own behavior.
|
|
||||||
func readyTerdutTeam(ctx context.Context, namespace, name string, srv *terdutv1alpha1.TerdutServer, fakeURL string) *terdutv1alpha1.TerdutTeam {
|
func readyTerdutTeam(ctx context.Context, namespace, name string, srv *terdutv1alpha1.TerdutServer, fakeURL string) *terdutv1alpha1.TerdutTeam {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
|
|
||||||
reconciler := &TerdutTeamReconciler{
|
reconciler := &TerdutTeamReconciler{
|
||||||
Client: k8sClient,
|
Client: k8sClient,
|
||||||
Scheme: k8sClient.Scheme(),
|
Scheme: k8sClient.Scheme(),
|
||||||
OperatorNamespace: namespace,
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
|
||||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
|
|
||||||
}
|
}
|
||||||
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
||||||
|
|
||||||
@@ -120,11 +111,11 @@ func readyTerdutTeam(ctx context.Context, namespace, name string, srv *terdutv1a
|
|||||||
|
|
||||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // create+mint+apply
|
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // apply
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, objKey, team)).To(Succeed())
|
Expect(k8sClient.Get(ctx, objKey, team)).To(Succeed())
|
||||||
Expect(team.Status.CredentialsSecretRef).NotTo(BeNil(), "test setup: TerdutTeam %s/%s did not reach Ready", namespace, name)
|
Expect(team.Status.TeamID).NotTo(BeZero(), "test setup: TerdutTeam %s/%s did not reach Ready", namespace, name)
|
||||||
return team
|
return team
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-277
@@ -10,6 +10,7 @@ package tdclient
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -57,6 +58,16 @@ type StatusError struct {
|
|||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignoreNotFound turns a 404 into success. Every DELETE here is idempotent: the
|
||||||
|
// thing already being gone is the outcome the caller wanted, and a finalizer
|
||||||
|
// that failed on it could never be removed.
|
||||||
|
func ignoreNotFound(err error) error {
|
||||||
|
if se, ok := errors.AsType[*StatusError](err); ok && se.Code == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (e *StatusError) Error() string {
|
func (e *StatusError) Error() string {
|
||||||
if e.Message != "" {
|
if e.Message != "" {
|
||||||
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
||||||
@@ -116,187 +127,23 @@ func (c *Client) do(req *http.Request, out any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version calls GET /api/version — unauthenticated, per terdut-server's own
|
|
||||||
// router.go comment ("a client deciding whether it can talk to this server —
|
|
||||||
// terdut-tui, terdut-operator — needs to ask before it holds a credential
|
|
||||||
// for it"). Used here purely as a reachability probe: a bad endpoint fails
|
|
||||||
// here, clearly, rather than on whatever the controller tries first.
|
|
||||||
func (c *Client) Version(ctx context.Context) (string, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var v struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
if err := c.do(req, &v); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return v.Version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIKey is the raw key a bootstrap or service-account-key mint hands back —
|
|
||||||
// the one moment its value exists outside the request that generated it.
|
|
||||||
// Mirrors terdut-server's models.APIKey/models.ServiceAccountKey shape
|
|
||||||
// (internal/models in that repo) for the fields this client actually reads.
|
|
||||||
type APIKey struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BootstrapResult is /api/bootstrap's 201 response body.
|
|
||||||
type BootstrapResult struct {
|
|
||||||
User struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
} `json:"user"`
|
|
||||||
APIKey APIKey `json:"api_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap calls POST /api/bootstrap — unauthenticated, single-shot per
|
|
||||||
// install (internal/api/users.go's handleBootstrap in terdut-server:
|
|
||||||
// gated on SELECT COUNT(*) FROM users). Returns the raw admin key directly;
|
|
||||||
// DESIGN.md §6 has the controller use it for exactly one further call
|
|
||||||
// (CreateServiceAccount) and discard it, never storing it as the lasting
|
|
||||||
// credential.
|
|
||||||
//
|
|
||||||
// A StatusError with Code 403 means this install already has a user —
|
|
||||||
// per this operator's design (DESIGN.md §1), that only happens if this
|
|
||||||
// exact TerdutServer's own controller already won this race on an earlier,
|
|
||||||
// interrupted reconcile; see the checkpoint-Secret handling in the
|
|
||||||
// controller, not a retry loop here.
|
|
||||||
func (c *Client) Bootstrap(ctx context.Context, username, email string) (*BootstrapResult, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/bootstrap", map[string]string{
|
|
||||||
"username": username,
|
|
||||||
"email": email,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result BootstrapResult
|
|
||||||
if err := c.do(req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceAccount mirrors terdut-server's models.ServiceAccount
|
|
||||||
// (internal/models/service_account.go), minus fields this client never
|
|
||||||
// reads.
|
|
||||||
type ServiceAccount struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateServiceAccountResult is POST /api/service-accounts' 201 response.
|
|
||||||
type CreateServiceAccountResult struct {
|
|
||||||
ServiceAccount ServiceAccount `json:"service_account"`
|
|
||||||
Key APIKey `json:"key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateInstanceServiceAccount calls POST /api/service-accounts with
|
|
||||||
// scope "instance", authenticated with c's current token (the raw admin key
|
|
||||||
// from Bootstrap, for the operator's own first-ever call). A StatusError
|
|
||||||
// with Code 409 means a prior, interrupted attempt already created this
|
|
||||||
// name — DESIGN.md §6's adopt-rather-than-error rule: the caller should
|
|
||||||
// fall back to GetServiceAccountByName + CreateServiceAccountKey, not treat
|
|
||||||
// this as a hard failure.
|
|
||||||
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
|
|
||||||
fieldName: name,
|
|
||||||
"scope": "instance",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result CreateServiceAccountResult
|
|
||||||
if err := c.do(req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTeamServiceAccount calls POST /api/service-accounts with scope
|
|
||||||
// "team" for teamID, authenticated with c's current token -- the
|
|
||||||
// TerdutServer's instance-scoped credential, per DESIGN.md §6 point 3: an
|
|
||||||
// instance-scoped caller may mint a team-scoped account against any team
|
|
||||||
// (internal/api/service_accounts.go's handleCreateServiceAccount,
|
|
||||||
// confirmed against source), which is what lets TerdutTeam's own
|
|
||||||
// controller do this without ever touching a human credential. Same
|
|
||||||
// adopt-on-409 contract as CreateInstanceServiceAccount.
|
|
||||||
func (c *Client) CreateTeamServiceAccount(ctx context.Context, name string, teamID int64) (*CreateServiceAccountResult, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]any{
|
|
||||||
fieldName: name,
|
|
||||||
"scope": "team",
|
|
||||||
"team_id": teamID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result CreateServiceAccountResult
|
|
||||||
if err := c.do(req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetServiceAccountByName calls GET /api/service-accounts?name=... --
|
|
||||||
// authenticated (terdut-server's AuthMiddleware hard-rejects any
|
|
||||||
// unauthenticated request before this endpoint's own, more permissive
|
|
||||||
// internal check ever runs; DESIGN.md §6). Returns nil, nil if nothing
|
|
||||||
// matches, not an error -- the server's own distinction between "found
|
|
||||||
// nothing" and "the call failed".
|
|
||||||
func (c *Client) GetServiceAccountByName(ctx context.Context, name string) (*ServiceAccount, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/service-accounts?name="+name, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var accounts []ServiceAccount
|
|
||||||
if err := c.do(req, &accounts); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(accounts) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &accounts[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateServiceAccountKey calls POST /api/service-accounts/{id}/keys to
|
|
||||||
// mint an additional key on an existing account -- rotation (DESIGN.md §6
|
|
||||||
// point 6), and the adopt-on-409 recovery path in point 1.
|
|
||||||
func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodPost,
|
|
||||||
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
|
|
||||||
map[string]string{fieldName: name})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var key APIKey
|
|
||||||
if err := c.do(req, &key); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
|
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
|
||||||
// Role/Source, which are only ever populated for a human caller's own
|
// Role/Source, which are only ever populated for a human caller's own
|
||||||
// membership and never apply to a service account's view of a team.
|
// membership and never apply to a service account's view of a team.
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
ExternalID *string `json:"external_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTeam calls POST /api/teams, authenticated with c's current token --
|
// CreateTeam calls POST /api/teams with an external_id, which makes it
|
||||||
// the TerdutServer's instance-scoped credential (DESIGN.md §6 point 3). A
|
// idempotent: a team that already carries that id is returned (200) instead of
|
||||||
// StatusError with Code 409 means a prior, interrupted attempt already
|
// created (201), so a client that crashed before recording the id finds its own
|
||||||
// created this name — GetTeamByName (TEAM-LOOKUP.md) is the adopt-rather-
|
// team again. A StatusError with Code 409 means the name belongs to a different
|
||||||
// than-error recovery, the same contract CreateInstanceServiceAccount has.
|
// team. Needs an instance-scoped credential.
|
||||||
func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
|
func (c *Client) CreateTeam(ctx context.Context, name, externalID string) (*Team, error) {
|
||||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name})
|
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams",
|
||||||
|
map[string]string{fieldName: name, "external_id": externalID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -307,24 +154,6 @@ func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
|
|||||||
return &team, nil
|
return &team, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTeamByName calls GET /api/teams?name=... (TEAM-LOOKUP.md) — open to
|
|
||||||
// any authenticated caller, not just the team's own members. Returns nil,
|
|
||||||
// nil on no match, not an error.
|
|
||||||
func (c *Client) GetTeamByName(ctx context.Context, name string) (*Team, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/teams?name="+name, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var teams []Team
|
|
||||||
if err := c.do(req, &teams); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(teams) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &teams[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
|
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
|
||||||
// (requireTeamOwner), so c must hold this team's own team-scoped
|
// (requireTeamOwner), so c must hold this team's own team-scoped
|
||||||
// credential, not the instance-scoped one CreateTeam used.
|
// credential, not the instance-scoped one CreateTeam used.
|
||||||
@@ -346,7 +175,7 @@ func (c *Client) DeleteTeam(ctx context.Context, teamID int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.do(req, nil)
|
return ignoreNotFound(c.do(req, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
|
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
|
||||||
@@ -362,46 +191,15 @@ func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGrou
|
|||||||
return c.do(req, nil)
|
return c.do(req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User mirrors terdut-server's models.User, minus fields this client never
|
|
||||||
// reads.
|
|
||||||
type User struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserByUsername calls GET /api/users and finds the one matching exactly
|
|
||||||
// -- confirmed open to any authenticated caller, not gated by team
|
|
||||||
// membership or admin (internal/api/router.go's own comment: "readable by
|
|
||||||
// anyone signed in"), so the team-scoped credential a TerdutEscalationRule's
|
|
||||||
// controller already holds is enough. There is no server-side filter, so
|
|
||||||
// this always fetches the whole list; terdut-server's own query has no
|
|
||||||
// pagination either (confirmed against source), so this matches what the
|
|
||||||
// server itself considers an acceptable cost. Returns nil, nil on no match.
|
|
||||||
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/users", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var users []User
|
|
||||||
if err := c.do(req, &users); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, u := range users {
|
|
||||||
if u.Username == username {
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
|
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
|
||||||
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
|
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
|
||||||
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
|
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
|
||||||
// (escalationView), which this client never needs to decode since the
|
// (escalationView), which this client never needs to decode since the
|
||||||
// controller always computes its own desired state fresh from spec.
|
// controller always computes its own desired state fresh from spec.
|
||||||
type EscalationTargetRequest struct {
|
type EscalationTargetRequest struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
UserID *int64 `json:"user_id,omitempty"`
|
// Username names the person for a "user" target; the server resolves it.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EscalationLevelRequest struct {
|
type EscalationLevelRequest struct {
|
||||||
@@ -498,7 +296,7 @@ func (c *Client) DeleteDeadmanSwitch(ctx context.Context, teamID, switchID int64
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.do(req, nil)
|
return ignoreNotFound(c.do(req, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integration mirrors terdut-server's models.Integration, minus
|
// Integration mirrors terdut-server's models.Integration, minus
|
||||||
@@ -564,52 +362,5 @@ func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.do(req, nil)
|
return ignoreNotFound(c.do(req, nil))
|
||||||
}
|
|
||||||
|
|
||||||
// Invite is a standing link into a team (POST /api/teams/{teamID}/invites'
|
|
||||||
// own response shape). URL carries the raw token exactly once, at creation
|
|
||||||
// -- terdut-server never shows it again (same one-time-shown shape as an
|
|
||||||
// integration's webhook key) -- so a caller that needs it later has to have
|
|
||||||
// kept this response, not re-fetched it.
|
|
||||||
type Invite struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TeamID int64 `json:"team_id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
|
||||||
MaxUses int64 `json:"max_uses"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated
|
|
||||||
// (requireTeamOwner), so c must hold this team's own team-scoped
|
|
||||||
// credential, which already satisfies that check via its synthetic owner
|
|
||||||
// membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling
|
|
||||||
// needed: unlike a team or a service account, an invite has no unique name
|
|
||||||
// to collide on -- every call mints a brand new row.
|
|
||||||
func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID),
|
|
||||||
map[string]any{"role": role, "max_uses": maxUses})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var inv Invite
|
|
||||||
if err := c.do(req, &inv); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &inv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same
|
|
||||||
// credential requirement as CreateInvite. A 404 (already revoked, or never
|
|
||||||
// existed) is the caller's to treat as success if it wants to, the same way
|
|
||||||
// DeleteTeam's own 404 handling works -- this method itself just reports
|
|
||||||
// whatever terdut-server said.
|
|
||||||
func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error {
|
|
||||||
req, err := c.newRequest(ctx, http.MethodDelete,
|
|
||||||
fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.do(req, nil)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,85 +7,46 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersion(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/api/version" {
|
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
|
||||||
}
|
|
||||||
// Unauthenticated per terdut-server's own router.go comment: no
|
|
||||||
// Authorization header should be required, and none is sent here.
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := New(srv.URL).Version(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Version() error = %v", err)
|
|
||||||
}
|
|
||||||
if got != "v0.20.0" {
|
|
||||||
t.Errorf("Version() = %q, want %q", got, "v0.20.0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVersionUnreachable(t *testing.T) {
|
|
||||||
// A closed server: connection refused, the same shape a bad
|
|
||||||
// spec.endpoint produces against a real cluster.
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
||||||
srv.Close()
|
|
||||||
|
|
||||||
if _, err := New(srv.URL).Version(context.Background()); err == nil {
|
|
||||||
t.Fatal("Version() error = nil, want a connection error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVersionErrorStatus(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(`{"error":"internal error"}`)) //nolint:errcheck
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
_, err := New(srv.URL).Version(context.Background())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Version() error = nil, want a StatusError")
|
|
||||||
}
|
|
||||||
var statusErr *StatusError
|
|
||||||
if !asStatusError(err, &statusErr) {
|
|
||||||
t.Fatalf("Version() error = %v (%T), want *StatusError", err, err)
|
|
||||||
}
|
|
||||||
if statusErr.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("StatusError.Code = %d, want %d", statusErr.Code, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
if statusErr.Message != "internal error" {
|
|
||||||
t.Errorf("StatusError.Message = %q, want %q", statusErr.Message, "internal error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// asStatusError is errors.As without importing errors twice in a tiny test
|
|
||||||
// file — kept local since no other test here needs it.
|
|
||||||
func asStatusError(err error, target **StatusError) bool {
|
|
||||||
se, ok := err.(*StatusError)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
*target = se
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithTokenSetsAuthorizationHeader(t *testing.T) {
|
func TestWithTokenSetsAuthorizationHeader(t *testing.T) {
|
||||||
var gotAuth string
|
var gotAuth string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotAuth = r.Header.Get("Authorization")
|
gotAuth = r.Header.Get("Authorization")
|
||||||
w.Write([]byte(`{"version":"v0.20.0"}`)) //nolint:errcheck
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := New(srv.URL).WithToken("tdsa_abc123")
|
c := New(srv.URL).WithToken("tdsa_abc123")
|
||||||
if _, err := c.Version(context.Background()); err != nil {
|
if err := c.DeleteTeam(context.Background(), 1); err != nil {
|
||||||
t.Fatalf("Version() error = %v", err)
|
t.Fatalf("DeleteTeam() error = %v", err)
|
||||||
}
|
}
|
||||||
if want := "Bearer tdsa_abc123"; gotAuth != want {
|
if want := "Bearer tdsa_abc123"; gotAuth != want {
|
||||||
t.Errorf("Authorization header = %q, want %q", gotAuth, want)
|
t.Errorf("Authorization header = %q, want %q", gotAuth, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every delete is idempotent: the thing already being gone is success, any
|
||||||
|
// other failure is not.
|
||||||
|
func TestDeletesTreatNotFoundAsSuccess(t *testing.T) {
|
||||||
|
status := http.StatusNotFound
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := New(srv.URL)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for name, call := range map[string]func() error{
|
||||||
|
"team": func() error { return c.DeleteTeam(ctx, 1) },
|
||||||
|
"switch": func() error { return c.DeleteDeadmanSwitch(ctx, 1, 2) },
|
||||||
|
"integration": func() error { return c.DeleteIntegration(ctx, 1, 2) },
|
||||||
|
} {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
if err := call(); err != nil {
|
||||||
|
t.Errorf("%s: 404 should be success, got %v", name, err)
|
||||||
|
}
|
||||||
|
status = http.StatusConflict
|
||||||
|
if err := call(); err == nil {
|
||||||
|
t.Errorf("%s: 409 should be an error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
//go:build e2e
|
|
||||||
// +build e2e
|
|
||||||
|
|
||||||
package e2e
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/test/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// managerImage is the manager image to be built and loaded for testing.
|
|
||||||
managerImage = "example.com/terdut-operator:v0.0.1"
|
|
||||||
// shouldCleanupCertManager tracks whether CertManager was installed by this suite.
|
|
||||||
shouldCleanupCertManager = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestE2E runs the e2e test suite to validate the solution in an isolated environment.
|
|
||||||
// The default setup requires Kind and CertManager.
|
|
||||||
//
|
|
||||||
// To enable kubectl kuberc (use custom kubectl configurations), set: KUBECTL_KUBERC=true
|
|
||||||
// By default, kuberc is disabled to ensure consistent test behavior across different environments.
|
|
||||||
// To skip CertManager installation, set: CERT_MANAGER_INSTALL_SKIP=true
|
|
||||||
func TestE2E(t *testing.T) {
|
|
||||||
RegisterFailHandler(Fail)
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Starting terdut-operator e2e test suite\n")
|
|
||||||
RunSpecs(t, "e2e suite")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = BeforeSuite(func() {
|
|
||||||
By("building the manager image")
|
|
||||||
cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", managerImage))
|
|
||||||
_, err := utils.Run(cmd)
|
|
||||||
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager image")
|
|
||||||
|
|
||||||
// TODO(user): If you want to change the e2e test vendor from Kind,
|
|
||||||
// ensure the image is built and available, then remove the following block.
|
|
||||||
By("loading the manager image on Kind")
|
|
||||||
err = utils.LoadImageToKindClusterWithName(managerImage)
|
|
||||||
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager image into Kind")
|
|
||||||
|
|
||||||
configureKubectlKubeRC()
|
|
||||||
setupCertManager()
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = AfterSuite(func() {
|
|
||||||
teardownCertManager()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Disable kubectl kuberc by default for test isolation.
|
|
||||||
// This prevents local kubectl configurations from affecting test behavior.
|
|
||||||
// To enable kuberc, set: KUBECTL_KUBERC=true
|
|
||||||
func configureKubectlKubeRC() {
|
|
||||||
if os.Getenv("KUBECTL_KUBERC") != "true" {
|
|
||||||
By("disabling kubectl kuberc for test isolation")
|
|
||||||
err := os.Setenv("KUBECTL_KUBERC", "false")
|
|
||||||
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to disable kubectl kuberc")
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter,
|
|
||||||
"kubectl kuberc disabled for consistent test behavior (override with KUBECTL_KUBERC=true)\n")
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "kubectl kuberc enabled (KUBECTL_KUBERC=true)\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupCertManager installs CertManager if needed for webhook tests.
|
|
||||||
// Skips installation if CERT_MANAGER_INSTALL_SKIP=true or if already present.
|
|
||||||
func setupCertManager() {
|
|
||||||
if os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager installation (CERT_MANAGER_INSTALL_SKIP=true)\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
By("checking if CertManager is already installed")
|
|
||||||
if utils.IsCertManagerCRDsInstalled() {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "CertManager is already installed. Skipping installation.\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark for cleanup before installation to handle interruptions and partial installs.
|
|
||||||
shouldCleanupCertManager = true
|
|
||||||
|
|
||||||
By("installing CertManager")
|
|
||||||
Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager")
|
|
||||||
}
|
|
||||||
|
|
||||||
// teardownCertManager uninstalls CertManager if it was installed by setupCertManager.
|
|
||||||
// This ensures we only remove what we installed.
|
|
||||||
func teardownCertManager() {
|
|
||||||
if !shouldCleanupCertManager {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Skipping CertManager cleanup (not installed by this suite)\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
By("uninstalling CertManager")
|
|
||||||
utils.UninstallCertManager()
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
//go:build e2e
|
|
||||||
// +build e2e
|
|
||||||
|
|
||||||
package e2e
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-operator/test/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// namespace where the project is deployed in
|
|
||||||
const namespace = "terdut-operator-system"
|
|
||||||
|
|
||||||
// serviceAccountName created for the project
|
|
||||||
const serviceAccountName = "terdut-operator-controller-manager"
|
|
||||||
|
|
||||||
// metricsServiceName is the name of the metrics service of the project
|
|
||||||
const metricsServiceName = "terdut-operator-controller-manager-metrics-service"
|
|
||||||
|
|
||||||
// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data
|
|
||||||
const metricsRoleBindingName = "terdut-operator-metrics-binding"
|
|
||||||
|
|
||||||
var _ = Describe("Manager", Ordered, func() {
|
|
||||||
var controllerPodName string
|
|
||||||
|
|
||||||
// Before running the tests, set up the environment by creating the namespace,
|
|
||||||
// enforce the restricted security policy to the namespace, installing CRDs,
|
|
||||||
// and deploying the controller.
|
|
||||||
BeforeAll(func() {
|
|
||||||
By("creating manager namespace")
|
|
||||||
cmd := exec.Command("kubectl", "create", "ns", namespace)
|
|
||||||
_, err := utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to create namespace")
|
|
||||||
|
|
||||||
By("labeling the namespace to enforce the restricted security policy")
|
|
||||||
cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace,
|
|
||||||
"pod-security.kubernetes.io/enforce=restricted")
|
|
||||||
_, err = utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy")
|
|
||||||
|
|
||||||
By("installing CRDs")
|
|
||||||
cmd = exec.Command("make", "install")
|
|
||||||
_, err = utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs")
|
|
||||||
|
|
||||||
By("deploying the controller-manager")
|
|
||||||
cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", managerImage))
|
|
||||||
_, err = utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
|
|
||||||
})
|
|
||||||
|
|
||||||
// After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
|
|
||||||
// and deleting the namespace.
|
|
||||||
AfterAll(func() {
|
|
||||||
By("cleaning up the curl pod for metrics")
|
|
||||||
cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace)
|
|
||||||
_, _ = utils.Run(cmd)
|
|
||||||
|
|
||||||
By("undeploying the controller-manager")
|
|
||||||
cmd = exec.Command("make", "undeploy")
|
|
||||||
_, _ = utils.Run(cmd)
|
|
||||||
|
|
||||||
By("uninstalling CRDs")
|
|
||||||
cmd = exec.Command("make", "uninstall")
|
|
||||||
_, _ = utils.Run(cmd)
|
|
||||||
|
|
||||||
By("removing manager namespace")
|
|
||||||
cmd = exec.Command("kubectl", "delete", "ns", namespace)
|
|
||||||
_, _ = utils.Run(cmd)
|
|
||||||
})
|
|
||||||
|
|
||||||
// After each test, check for failures and collect logs, events,
|
|
||||||
// and pod descriptions for debugging.
|
|
||||||
AfterEach(func() {
|
|
||||||
specReport := CurrentSpecReport()
|
|
||||||
if specReport.Failed() {
|
|
||||||
By("Fetching controller manager pod logs")
|
|
||||||
cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
|
|
||||||
controllerLogs, err := utils.Run(cmd)
|
|
||||||
if err == nil {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs)
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
By("Fetching Kubernetes events")
|
|
||||||
cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp")
|
|
||||||
eventsOutput, err := utils.Run(cmd)
|
|
||||||
if err == nil {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput)
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
By("Fetching curl-metrics logs")
|
|
||||||
cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
|
|
||||||
metricsOutput, err := utils.Run(cmd)
|
|
||||||
if err == nil {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput)
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
By("Fetching controller manager pod description")
|
|
||||||
cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace)
|
|
||||||
podDescription, err := utils.Run(cmd)
|
|
||||||
if err == nil {
|
|
||||||
fmt.Println("Pod description:\n", podDescription)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Failed to describe controller pod")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
SetDefaultEventuallyTimeout(2 * time.Minute)
|
|
||||||
SetDefaultEventuallyPollingInterval(time.Second)
|
|
||||||
|
|
||||||
Context("Manager", func() {
|
|
||||||
It("should run successfully", func() {
|
|
||||||
By("validating that the controller-manager pod is running as expected")
|
|
||||||
verifyControllerUp := func(g Gomega) {
|
|
||||||
By("getting the name of the controller-manager pod")
|
|
||||||
cmd := exec.Command("kubectl", "get",
|
|
||||||
"pods", "-l", "control-plane=controller-manager",
|
|
||||||
"-o", "go-template={{ range .items }}"+
|
|
||||||
"{{ if not .metadata.deletionTimestamp }}"+
|
|
||||||
"{{ .metadata.name }}"+
|
|
||||||
"{{ \"\\n\" }}{{ end }}{{ end }}",
|
|
||||||
"-n", namespace,
|
|
||||||
)
|
|
||||||
|
|
||||||
podOutput, err := utils.Run(cmd)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information")
|
|
||||||
podNames := utils.GetNonEmptyLines(podOutput)
|
|
||||||
g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running")
|
|
||||||
controllerPodName = podNames[0]
|
|
||||||
g.Expect(controllerPodName).To(ContainSubstring("controller-manager"))
|
|
||||||
|
|
||||||
By("validating the pod's status")
|
|
||||||
cmd = exec.Command("kubectl", "get",
|
|
||||||
"pods", controllerPodName, "-o", "jsonpath={.status.phase}",
|
|
||||||
"-n", namespace,
|
|
||||||
)
|
|
||||||
output, err := utils.Run(cmd)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status")
|
|
||||||
}
|
|
||||||
Eventually(verifyControllerUp).Should(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should ensure the metrics endpoint is serving metrics", func() {
|
|
||||||
By("creating a ClusterRoleBinding for the service account to allow access to metrics")
|
|
||||||
cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName,
|
|
||||||
"--clusterrole=terdut-operator-metrics-reader",
|
|
||||||
fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName),
|
|
||||||
)
|
|
||||||
_, err := utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding")
|
|
||||||
|
|
||||||
By("validating that the metrics service is available")
|
|
||||||
cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace)
|
|
||||||
_, err = utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Metrics service should exist")
|
|
||||||
|
|
||||||
By("getting the service account token")
|
|
||||||
token, err := serviceAccountToken()
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(token).NotTo(BeEmpty())
|
|
||||||
|
|
||||||
By("ensuring the controller pod is ready")
|
|
||||||
verifyControllerPodReady := func(g Gomega) {
|
|
||||||
cmd := exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace,
|
|
||||||
"-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}")
|
|
||||||
output, err := utils.Run(cmd)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
g.Expect(output).To(Equal("True"), "Controller pod not ready")
|
|
||||||
}
|
|
||||||
Eventually(verifyControllerPodReady, 3*time.Minute, time.Second).Should(Succeed())
|
|
||||||
|
|
||||||
By("verifying that the controller manager is serving the metrics server")
|
|
||||||
verifyMetricsServerStarted := func(g Gomega) {
|
|
||||||
cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
|
|
||||||
output, err := utils.Run(cmd)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
g.Expect(output).To(ContainSubstring("Serving metrics server"),
|
|
||||||
"Metrics server not yet started")
|
|
||||||
}
|
|
||||||
Eventually(verifyMetricsServerStarted, 3*time.Minute, time.Second).Should(Succeed())
|
|
||||||
|
|
||||||
// +kubebuilder:scaffold:e2e-metrics-webhooks-readiness
|
|
||||||
|
|
||||||
By("creating the curl-metrics pod to access the metrics endpoint")
|
|
||||||
cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never",
|
|
||||||
"--namespace", namespace,
|
|
||||||
"--image=curlimages/curl:latest",
|
|
||||||
"--overrides",
|
|
||||||
fmt.Sprintf(`{
|
|
||||||
"spec": {
|
|
||||||
"containers": [{
|
|
||||||
"name": "curl",
|
|
||||||
"image": "curlimages/curl:latest",
|
|
||||||
"command": ["/bin/sh", "-c"],
|
|
||||||
"args": [
|
|
||||||
"for i in $(seq 1 30); do curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics && exit 0 || sleep 2; done; exit 1"
|
|
||||||
],
|
|
||||||
"securityContext": {
|
|
||||||
"readOnlyRootFilesystem": true,
|
|
||||||
"allowPrivilegeEscalation": false,
|
|
||||||
"capabilities": {
|
|
||||||
"drop": ["ALL"]
|
|
||||||
},
|
|
||||||
"runAsNonRoot": true,
|
|
||||||
"runAsUser": 1000,
|
|
||||||
"seccompProfile": {
|
|
||||||
"type": "RuntimeDefault"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
"serviceAccountName": "%s"
|
|
||||||
}
|
|
||||||
}`, token, metricsServiceName, namespace, serviceAccountName))
|
|
||||||
_, err = utils.Run(cmd)
|
|
||||||
Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod")
|
|
||||||
|
|
||||||
By("waiting for the curl-metrics pod to complete.")
|
|
||||||
verifyCurlUp := func(g Gomega) {
|
|
||||||
cmd := exec.Command("kubectl", "get", "pods", "curl-metrics",
|
|
||||||
"-o", "jsonpath={.status.phase}",
|
|
||||||
"-n", namespace)
|
|
||||||
output, err := utils.Run(cmd)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status")
|
|
||||||
}
|
|
||||||
Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed())
|
|
||||||
|
|
||||||
By("getting the metrics by checking curl-metrics logs")
|
|
||||||
verifyMetricsAvailable := func(g Gomega) {
|
|
||||||
metricsOutput, err := getMetricsOutput()
|
|
||||||
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
|
|
||||||
g.Expect(metricsOutput).NotTo(BeEmpty())
|
|
||||||
g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK"))
|
|
||||||
}
|
|
||||||
Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
// +kubebuilder:scaffold:e2e-webhooks-checks
|
|
||||||
|
|
||||||
// TODO: Customize the e2e test suite with scenarios specific to your project.
|
|
||||||
// Consider applying sample/CR(s) and check their status and/or verifying
|
|
||||||
// the reconciliation by using the metrics, i.e.:
|
|
||||||
// metricsOutput, err := getMetricsOutput()
|
|
||||||
// Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
|
|
||||||
// Expect(metricsOutput).To(ContainSubstring(
|
|
||||||
// fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`,
|
|
||||||
// strings.ToLower(<Kind>),
|
|
||||||
// ))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// serviceAccountToken returns a token for the specified service account in the given namespace.
|
|
||||||
// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
|
|
||||||
// and parsing the resulting token from the API response.
|
|
||||||
func serviceAccountToken() (string, error) {
|
|
||||||
const tokenRequestRawString = `{
|
|
||||||
"apiVersion": "authentication.k8s.io/v1",
|
|
||||||
"kind": "TokenRequest"
|
|
||||||
}`
|
|
||||||
|
|
||||||
By("creating temporary file to store the token request")
|
|
||||||
secretName := fmt.Sprintf("%s-token-request", serviceAccountName)
|
|
||||||
tokenRequestFile := filepath.Join("/tmp", secretName)
|
|
||||||
err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var out string
|
|
||||||
verifyTokenCreation := func(g Gomega) {
|
|
||||||
By("executing kubectl command to create the token")
|
|
||||||
cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf(
|
|
||||||
"/api/v1/namespaces/%s/serviceaccounts/%s/token",
|
|
||||||
namespace,
|
|
||||||
serviceAccountName,
|
|
||||||
), "-f", tokenRequestFile)
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
|
|
||||||
By("parsing the JSON output to extract the token")
|
|
||||||
var token tokenRequest
|
|
||||||
err = json.Unmarshal(output, &token)
|
|
||||||
g.Expect(err).NotTo(HaveOccurred())
|
|
||||||
|
|
||||||
out = token.Status.Token
|
|
||||||
}
|
|
||||||
Eventually(verifyTokenCreation).Should(Succeed())
|
|
||||||
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint.
|
|
||||||
func getMetricsOutput() (string, error) {
|
|
||||||
By("getting the curl-metrics logs")
|
|
||||||
cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
|
|
||||||
return utils.Run(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response,
|
|
||||||
// containing only the token field that we need to extract.
|
|
||||||
type tokenRequest struct {
|
|
||||||
Status struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
} `json:"status"`
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
certmanagerVersion = "v1.21.1"
|
|
||||||
certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml"
|
|
||||||
|
|
||||||
defaultKindBinary = "kind"
|
|
||||||
defaultKindCluster = "kind"
|
|
||||||
)
|
|
||||||
|
|
||||||
func warnError(err error) {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes the provided command within this context
|
|
||||||
func Run(cmd *exec.Cmd) (string, error) {
|
|
||||||
dir, _ := GetProjectDir()
|
|
||||||
cmd.Dir = dir
|
|
||||||
|
|
||||||
if err := os.Chdir(cmd.Dir); err != nil {
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "GO111MODULE=on")
|
|
||||||
command := strings.Join(cmd.Args, " ")
|
|
||||||
_, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UninstallCertManager uninstalls the cert manager
|
|
||||||
func UninstallCertManager() {
|
|
||||||
url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)
|
|
||||||
cmd := exec.Command("kubectl", "delete", "-f", url)
|
|
||||||
if _, err := Run(cmd); err != nil {
|
|
||||||
warnError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete leftover leases in kube-system (not cleaned by default)
|
|
||||||
kubeSystemLeases := []string{
|
|
||||||
"cert-manager-cainjector-leader-election",
|
|
||||||
"cert-manager-controller",
|
|
||||||
}
|
|
||||||
for _, lease := range kubeSystemLeases {
|
|
||||||
cmd = exec.Command("kubectl", "delete", "lease", lease,
|
|
||||||
"-n", "kube-system", "--ignore-not-found", "--force", "--grace-period=0")
|
|
||||||
if _, err := Run(cmd); err != nil {
|
|
||||||
warnError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallCertManager installs the cert manager bundle.
|
|
||||||
func InstallCertManager() error {
|
|
||||||
url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)
|
|
||||||
cmd := exec.Command("kubectl", "apply", "-f", url)
|
|
||||||
if _, err := Run(cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Wait for cert-manager-webhook to be ready, which can take time if cert-manager
|
|
||||||
// was re-installed after uninstalling on a cluster.
|
|
||||||
cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook",
|
|
||||||
"--for", "condition=Available",
|
|
||||||
"--namespace", "cert-manager",
|
|
||||||
"--timeout", "5m",
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err := Run(cmd)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed
|
|
||||||
// by verifying the existence of key CRDs related to Cert Manager.
|
|
||||||
func IsCertManagerCRDsInstalled() bool {
|
|
||||||
// List of common Cert Manager CRDs
|
|
||||||
certManagerCRDs := []string{
|
|
||||||
"certificates.cert-manager.io",
|
|
||||||
"issuers.cert-manager.io",
|
|
||||||
"clusterissuers.cert-manager.io",
|
|
||||||
"certificaterequests.cert-manager.io",
|
|
||||||
"orders.acme.cert-manager.io",
|
|
||||||
"challenges.acme.cert-manager.io",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the kubectl command to get all CRDs
|
|
||||||
cmd := exec.Command("kubectl", "get", "crds")
|
|
||||||
output, err := Run(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any of the Cert Manager CRDs are present
|
|
||||||
crdList := GetNonEmptyLines(output)
|
|
||||||
for _, crd := range certManagerCRDs {
|
|
||||||
for _, line := range crdList {
|
|
||||||
if strings.Contains(line, crd) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadImageToKindClusterWithName loads a local docker image to the kind cluster
|
|
||||||
func LoadImageToKindClusterWithName(name string) error {
|
|
||||||
cluster := defaultKindCluster
|
|
||||||
if v, ok := os.LookupEnv("KIND_CLUSTER"); ok {
|
|
||||||
cluster = v
|
|
||||||
}
|
|
||||||
kindOptions := []string{"load", "docker-image", name, "--name", cluster}
|
|
||||||
kindBinary := defaultKindBinary
|
|
||||||
if v, ok := os.LookupEnv("KIND"); ok {
|
|
||||||
kindBinary = v
|
|
||||||
}
|
|
||||||
cmd := exec.Command(kindBinary, kindOptions...)
|
|
||||||
_, err := Run(cmd)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNonEmptyLines converts given command output string into individual objects
|
|
||||||
// according to line breakers, and ignores the empty elements in it.
|
|
||||||
func GetNonEmptyLines(output string) []string {
|
|
||||||
var res []string
|
|
||||||
elements := strings.SplitSeq(output, "\n")
|
|
||||||
for element := range elements {
|
|
||||||
if element != "" {
|
|
||||||
res = append(res, element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProjectDir will return the directory where the project is
|
|
||||||
func GetProjectDir() (string, error) {
|
|
||||||
wd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return wd, fmt.Errorf("failed to get current working directory: %w", err)
|
|
||||||
}
|
|
||||||
wd = strings.ReplaceAll(wd, "/test/e2e", "")
|
|
||||||
return wd, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UncommentCode searches for target in the file and remove the comment prefix
|
|
||||||
// of the target content. The target content may span multiple lines.
|
|
||||||
func UncommentCode(filename, target, prefix string) error {
|
|
||||||
// false positive
|
|
||||||
// nolint:gosec
|
|
||||||
content, err := os.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file %q: %w", filename, err)
|
|
||||||
}
|
|
||||||
strContent := string(content)
|
|
||||||
|
|
||||||
idx := strings.Index(strContent, target)
|
|
||||||
if idx < 0 {
|
|
||||||
return fmt.Errorf("unable to find the code %q to be uncommented", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
_, err = out.Write(content[:idx])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write to output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(bytes.NewBufferString(target))
|
|
||||||
if !scanner.Scan() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil {
|
|
||||||
return fmt.Errorf("failed to write to output: %w", err)
|
|
||||||
}
|
|
||||||
// Avoid writing a newline in case the previous line was the last in target.
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if _, err = out.WriteString("\n"); err != nil {
|
|
||||||
return fmt.Errorf("failed to write to output: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err = out.Write(content[idx+len(target):]); err != nil {
|
|
||||||
return fmt.Errorf("failed to write to output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// false positive
|
|
||||||
// nolint:gosec
|
|
||||||
if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write file %q: %w", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user