3 Commits

Author SHA1 Message Date
Stephen Schuresko
4573e8fbfd Add example configuration files for zero-scale-ctl 2026-10-27 13:22:33 -04:00
Stephen Schuresko
0f1ef3fee8 Enhance doctor command with environment and zero-scale filtering options
- Added support for filtering diagnostics by environment and namespace in the doctor command.
- Introduced `--zero-scale-only` flag to restrict output to zero-scale managed apps.
- Updated tests to validate new filtering functionality.
2026-10-27 12:52:08 -04:00
Stephen Schuresko
772572e6df add .conf example 2026-10-27 12:51:53 -04:00
10 changed files with 532 additions and 28 deletions

View File

@@ -48,6 +48,7 @@ zero-scale-platform/
│ ├── deployment.yaml # Gateway Deployment (replaces manual pods)
│ ├── service.yaml # Gateway ClusterIP Service (:80)
│ └── ingress.yaml # Ingress routing configuration
├── examples/ # App .conf files for zero-scale-ctl apply
├── scripts/
│ └── zero-scale-ctl.sh # CLI tool for deploying and monitoring apps
└── services/ # Systemd service unit templates
@@ -123,6 +124,7 @@ zero-scale-ctl status myapp.example.com
# Follow real-time systemd journal logs
zero-scale-ctl logs myapp.example.com -n 100
```
See [`examples/`](examples/) for sample `.conf` files and every key `apply` understands.
---

View File

@@ -114,6 +114,106 @@ func TestDoctorReportsSingleNodeFailures(t *testing.T) {
}
}
func TestDoctorZeroScaleOnly(t *testing.T) {
fakeKubectl(t, ` "get ingress -A -o json") cat "$F/spof/ingress.json" ;;
"get service -A -o json") cat "$F/spof/service.json" ;;
"get pods -A -o json") cat "$F/spof/pods.json" ;;
"get nodes -o json") cat "$F/spof/nodes.json" ;;
"get storageclass -o json") cat "$F/spof/storageclass.json" ;;
"get pvc -A -o json") cat "$F/spof/pvc.json" ;;
"get domainmappings.serving.knative.dev -A -o json") cat "$F/spof/domainmappings.serving.knative.dev.json" ;;`)
out := captureStdout(t, func() {
if err := runDoctor([]string{"--zero-scale-only"}); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "==> Scope: zero-scale managed apps only") {
t.Errorf("missing scope header in:\n%s", out)
}
if !strings.Contains(out, "default/zero-scale-gateway: 1 pod, on steve-disks") {
t.Errorf("missing zero-scale-gateway in:\n%s", out)
}
if !strings.Contains(out, "default/oauth2-proxy-mdm: 2 pods, all on sv2d") {
t.Errorf("missing oauth2-proxy-mdm in:\n%s", out)
}
if !strings.Contains(out, "1 of 3 Ingress backends survive losing any one node (zero-scale managed apps).") {
t.Errorf("missing zero-scale survival count in:\n%s", out)
}
for _, unwanted := range []string{
"nextcloud/nextcloud",
"hopscotch-admin",
"prometheus-server",
"datadir-qa-pxc-pxc",
"kourier-system",
"Knative DomainMapping",
} {
if strings.Contains(out, unwanted) {
t.Errorf("doctor --zero-scale-only included third-party workload %q:\n%s", unwanted, out)
}
}
}
func TestDoctorEnvFilter(t *testing.T) {
fakeKubectl(t, ` "get ingress -A -o json") cat "$F/spof/ingress.json" ;;
"get service -A -o json") cat "$F/spof/service.json" ;;
"get pods -A -o json") cat "$F/spof/pods.json" ;;
"get nodes -o json") cat "$F/spof/nodes.json" ;;
"get storageclass -o json") cat "$F/spof/storageclass.json" ;;
"get pvc -A -o json") cat "$F/spof/pvc.json" ;;
"get domainmappings.serving.knative.dev -A -o json") cat "$F/spof/domainmappings.serving.knative.dev.json" ;;`)
out := captureStdout(t, func() {
if err := runDoctor([]string{"--env", "qa"}); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, `==> Scope: environment "qa"`) {
t.Errorf("missing scope header in:\n%s", out)
}
if !strings.Contains(out, "1 of 1 Ingress backends survive losing any one node (environment \"qa\").") {
t.Errorf("missing qa survival count in:\n%s", out)
}
for _, unwanted := range []string{
"default/zero-scale-gateway",
"nextcloud/nextcloud",
"default/prometheus-server",
} {
if strings.Contains(out, unwanted) {
t.Errorf("doctor --env qa included non-qa workload %q:\n%s", unwanted, out)
}
}
}
func TestDoctorEnvAndZeroScaleOnly(t *testing.T) {
fakeKubectl(t, ` "get ingress -A -o json") cat "$F/spof/ingress.json" ;;
"get service -A -o json") cat "$F/spof/service.json" ;;
"get pods -A -o json") cat "$F/spof/pods.json" ;;
"get nodes -o json") cat "$F/spof/nodes.json" ;;
"get storageclass -o json") cat "$F/spof/storageclass.json" ;;
"get pvc -A -o json") cat "$F/spof/pvc.json" ;;
"get domainmappings.serving.knative.dev -A -o json") cat "$F/spof/domainmappings.serving.knative.dev.json" ;;`)
out := captureStdout(t, func() {
if err := runDoctor([]string{"-e", "default", "-z"}); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, `==> Scope: environment "default", zero-scale managed apps only`) {
t.Errorf("missing scope header in:\n%s", out)
}
if !strings.Contains(out, "0 of 2 Ingress backends survive losing any one node (environment \"default\", zero-scale managed apps).") {
t.Errorf("missing combined survival count in:\n%s", out)
}
for _, unwanted := range []string{
"qa/zero-scale-gateway",
"nextcloud",
"prometheus-server",
"hopscotch-admin",
} {
if strings.Contains(out, unwanted) {
t.Errorf("doctor -e default -z included unwanted workload %q:\n%s", unwanted, out)
}
}
}
func TestProvisionPostgresUsesPlatformTarget(t *testing.T) {
cm := `{"data":{"version":"1","postgres.operator":"cnpg","postgres.cluster":"cnpg-system/ha-postgres","postgres.rwService":"ha-postgres-rw.cnpg-system.svc.cluster.local:5432"}}`
log := fakeKubectl(t, ` "get configmap zero-scale-platform -n zero-scale-system -o json") echo '`+cm+`' ;;

View File

@@ -19,6 +19,7 @@ Usage:
Platform setup:
doctor Detect HA storage, Postgres/MySQL operators, ingress and gateways (read-only)
[--env <name> | -e] [--namespace <ns> | -n] [--zero-scale-only | -z]
init Record the chosen platform pieces in the zero-scale-platform ConfigMap
[--storage-class X] [--rwx-class Y] [--postgres ns/name|none] [--mysql ns/name|none]
[--allow-non-ha] [--dry-run] [--yes]

View File

@@ -2,6 +2,7 @@ package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
@@ -238,24 +239,159 @@ func runMySQL(t *platform.DBTarget, op *platform.Operator, sql string) error {
return err
}
// DoctorFilter restricts doctor output to a specific environment, namespace, or zero-scale apps.
type DoctorFilter struct {
Env string
Namespace string
ZeroScaleOnly bool
}
func (f DoctorFilter) Active() bool {
return f.Env != "" || f.Namespace != "" || f.ZeroScaleOnly
}
func resolveEnvNamespace(k platform.Kube, envName string) (string, error) {
if envName == "" {
return "", nil
}
raw, err := k.GetJSON("get", "namespace", "-l", env.Label+"="+envName)
if err == nil {
var list struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
} `json:"items"`
}
if json.Unmarshal(raw, &list) == nil && len(list.Items) > 0 {
return list.Items[0].Metadata.Name, nil
}
}
return envName, nil
}
func filterDBCandidates(cands []platform.DBCandidate, f DoctorFilter, activeTarget *platform.DBTarget) []platform.DBCandidate {
if !f.Active() {
return cands
}
var out []platform.DBCandidate
for _, c := range cands {
match := true
if f.Namespace != "" {
match = (c.Namespace == f.Namespace) || (activeTarget != nil && c.Namespace == activeTarget.Namespace && c.Name == activeTarget.Name)
}
if match && f.ZeroScaleOnly {
isZS := c.Operator == "shared-mysql" ||
(activeTarget != nil && c.Namespace == activeTarget.Namespace && c.Name == activeTarget.Name) ||
c.Namespace == platformNamespace()
if !isZS {
match = false
}
}
if match {
out = append(out, c)
}
}
return out
}
func filterGateways(gws []platform.Gateway, f DoctorFilter) []platform.Gateway {
if f.Namespace == "" {
return gws
}
var out []platform.Gateway
for _, g := range gws {
if g.Namespace == f.Namespace {
out = append(out, g)
}
}
return out
}
func filterRedis(redis []string, f DoctorFilter) []string {
if f.Namespace == "" {
return redis
}
var out []string
for _, r := range redis {
if strings.Contains(r, "."+f.Namespace+".") || strings.HasPrefix(r, f.Namespace+"/") {
out = append(out, r)
}
}
return out
}
// ---------------------------------------------------------------- doctor / init
func runDoctor(args []string) error {
fs := flag.NewFlagSet("doctor", flag.ExitOnError)
envFlag := fs.String("env", "", "Filter diagnostics to a specific environment name")
envShort := fs.String("e", "", "Filter diagnostics to a specific environment name (shorthand)")
nsFlag := fs.String("namespace", "", "Filter diagnostics to a specific namespace")
nsShort := fs.String("n", "", "Filter diagnostics to a specific namespace (shorthand)")
zsFlag := fs.Bool("zero-scale-only", false, "Diagnose zero-scale managed apps only (hide third-party workloads)")
zsShort := fs.Bool("z", false, "Diagnose zero-scale managed apps only (shorthand)")
appsFlag := fs.Bool("apps-only", false, "Diagnose zero-scale managed apps only (alias)")
_ = fs.Parse(args)
r, err := platform.Detect(kubectlKube{})
kube := kubectlKube{}
envName := *envFlag
if envName == "" {
envName = *envShort
}
targetNS := *nsFlag
if targetNS == "" {
targetNS = *nsShort
}
if envName != "" && targetNS == "" {
ns, err := resolveEnvNamespace(kube, envName)
if err != nil {
return err
}
targetNS = ns
}
filter := DoctorFilter{
Env: envName,
Namespace: targetNS,
ZeroScaleOnly: *zsFlag || *zsShort || *appsFlag,
}
r, err := platform.Detect(kube)
if err != nil {
return err
}
printReport(r)
printNodeFailure(r)
if filter.Active() {
var parts []string
if filter.Env != "" {
if filter.Namespace != "" && filter.Namespace != filter.Env {
parts = append(parts, fmt.Sprintf("environment %q (namespace %s)", filter.Env, filter.Namespace))
} else {
parts = append(parts, fmt.Sprintf("environment %q", filter.Env))
}
} else if filter.Namespace != "" {
parts = append(parts, fmt.Sprintf("namespace %q", filter.Namespace))
}
if filter.ZeroScaleOnly {
parts = append(parts, "zero-scale managed apps only")
}
fmt.Printf("==> Scope: %s\n\n", strings.Join(parts, ", "))
}
printReport(r, filter)
printNodeFailure(r, filter)
fmt.Println("\n==> Next steps")
current, _ := loadPlatform()
if current != nil {
fmt.Printf(" Platform config exists in %s/%s. Re-run 'zero-scale-ctl init' to refresh it.\n", platformNamespace(), platform.ConfigMapName)
}
if filter.Namespace != "" && filter.Namespace != platformNamespace() {
if e, _ := loadEnv(filter.Namespace); e != nil {
fmt.Printf(" Environment config exists in %s/%s.\n", filter.Namespace, env.ConfigMapName)
}
}
_, chooseErr := platform.Choose(r, recordedOptions(current))
switch e := chooseErr.(type) {
case nil:
@@ -274,8 +410,13 @@ func runDoctor(args []string) error {
default:
fmt.Printf(" %v\n", e)
}
if len(r.Gateways) == 0 {
fmt.Println(" No zero-scale gateway found: 'zero-scale-ctl gateway install' (or deploy your Helm chart).")
gws := filterGateways(r.Gateways, filter)
if len(gws) == 0 {
if filter.Namespace != "" {
fmt.Printf(" No zero-scale gateway found in namespace %s: 'zero-scale-ctl gateway install --namespace %s'.\n", filter.Namespace, filter.Namespace)
} else {
fmt.Println(" No zero-scale gateway found: 'zero-scale-ctl gateway install' (or deploy your Helm chart).")
}
}
return nil
}
@@ -298,7 +439,7 @@ func recordedOptions(c *platform.Config) platform.Options {
// printNodeFailure lists what one node going down would take off the air, and
// Services that hijack a node's IP so that node cannot serve ingress traffic.
func printNodeFailure(r *platform.Report) {
func printNodeFailure(r *platform.Report, filter DoctorFilter) {
fmt.Println("\n==> Single node failure")
var defaults []string
for _, sc := range r.StorageClasses {
@@ -320,6 +461,12 @@ func printNodeFailure(r *platform.Report) {
fmt.Printf(" (could not check node-local volumes: %v)\n", err)
}
for _, v := range locals {
if filter.Namespace != "" && v.Namespace != filter.Namespace {
continue
}
if filter.ZeroScaleOnly && !v.ZeroScale {
continue
}
where := fmt.Sprintf("%s/%s (%s, on %s, used by %s)", v.Namespace, v.PVC, v.Class, strings.Join(v.Nodes, ", "), strings.Join(v.Pods, ", "))
switch {
case v.Temporary:
@@ -336,17 +483,40 @@ func printNodeFailure(r *platform.Report) {
fmt.Printf(" (could not check Ingress routes: %v)\n", err)
}
for _, c := range conflicts {
if filter.ZeroScaleOnly && !c.ZeroScale {
continue
}
if filter.Namespace != "" {
nsMatch := false
for _, routeStr := range c.Routes {
if strings.HasPrefix(routeStr, filter.Namespace+"/") {
nsMatch = true
break
}
}
if !nsMatch {
continue
}
}
fmt.Printf(" ⚠️ %s%s is routed by several Ingresses to different Services; only one wins: %s\n", c.Host, c.Path, strings.Join(c.Routes, "; "))
}
knativeOnly, _ := platform.KnativeOnlyHosts(kubectlKube{})
for _, h := range knativeOnly {
fmt.Printf(" ⚠️ %s is a Knative DomainMapping with no Ingress rule: it only answers on the Knative gateway's own address, not on every node.\n", h)
if !filter.ZeroScaleOnly && filter.Namespace == "" {
knativeOnly, _ := platform.KnativeOnlyHosts(kubectlKube{})
for _, h := range knativeOnly {
fmt.Printf(" ⚠️ %s is a Knative DomainMapping with no Ingress rule: it only answers on the Knative gateway's own address, not on every node.\n", h)
}
}
claims, err := platform.NodeIPClaims(kubectlKube{})
if err != nil {
fmt.Printf(" (could not check Service externalIPs: %v)\n", err)
}
for _, c := range claims {
if filter.Namespace != "" && c.Namespace != filter.Namespace {
continue
}
if filter.ZeroScaleOnly && !c.ZeroScale {
continue
}
fmt.Printf(" ❌ %s/%s claims %s (node %s) on ports %v: that node's ingress on those ports goes to this Service instead.\n", c.Namespace, c.Service, c.IP, c.Node, c.Ports)
}
exps, err := platform.IngressExposures(kubectlKube{})
@@ -354,18 +524,40 @@ func printNodeFailure(r *platform.Report) {
fmt.Printf(" (could not check Ingress backends: %v)\n", err)
return
}
ok := 0
var filteredExps []platform.Exposure
for _, e := range exps {
if filter.Namespace != "" && e.Namespace != filter.Namespace {
continue
}
if filter.ZeroScaleOnly && !e.ZeroScale {
continue
}
filteredExps = append(filteredExps, e)
}
ok := 0
for _, e := range filteredExps {
if e.Survives() {
ok++
continue
}
fmt.Printf(" ❌ %s/%s: %s (%s)\n", e.Namespace, e.Service, e.Problem(), strings.Join(e.Hosts, ", "))
}
fmt.Printf(" %d of %d Ingress backends survive losing any one node.\n", ok, len(exps))
var scopeDesc string
if filter.Env != "" && filter.ZeroScaleOnly {
scopeDesc = fmt.Sprintf(" (environment %q, zero-scale managed apps)", filter.Env)
} else if filter.Env != "" {
scopeDesc = fmt.Sprintf(" (environment %q)", filter.Env)
} else if filter.Namespace != "" && filter.ZeroScaleOnly {
scopeDesc = fmt.Sprintf(" (namespace %s, zero-scale managed apps)", filter.Namespace)
} else if filter.Namespace != "" {
scopeDesc = fmt.Sprintf(" (namespace %s)", filter.Namespace)
} else if filter.ZeroScaleOnly {
scopeDesc = " (zero-scale managed apps)"
}
fmt.Printf(" %d of %d Ingress backends survive losing any one node%s.\n", ok, len(filteredExps), scopeDesc)
}
func printReport(r *platform.Report) {
func printReport(r *platform.Report, filter DoctorFilter) {
mark := func(g platform.Grade) string {
switch g {
case platform.HA:
@@ -392,16 +584,43 @@ func printReport(r *platform.Report) {
}
fmt.Printf(" %s %-24s %-32s %s%s\n", mark(sc.Grade), sc.Name, sc.Provisioner, sc.Reason, extra)
}
var activePG, activeMY *platform.DBTarget
if filter.Namespace != "" {
if p, _ := loadPlatformFor(filter.Namespace); p != nil {
activePG = p.Postgres
activeMY = p.MySQL
}
} else if filter.ZeroScaleOnly {
if p, _ := loadPlatform(); p != nil {
activePG = p.Postgres
activeMY = p.MySQL
}
}
for _, sec := range []struct {
title string
cands []platform.DBCandidate
}{{"PostgreSQL clusters", r.Postgres}, {"MySQL clusters", r.MySQL}} {
title string
cands []platform.DBCandidate
target *platform.DBTarget
}{
{"PostgreSQL clusters", r.Postgres, activePG},
{"MySQL clusters", r.MySQL, activeMY},
} {
fmt.Printf("\n==> %s\n", sec.title)
if len(sec.cands) == 0 {
cands := filterDBCandidates(sec.cands, filter, sec.target)
if len(cands) == 0 {
fmt.Println(" (none)")
}
for _, c := range sec.cands {
fmt.Printf(" %s %-32s %s, ready: %s, rw: %s\n", mark(c.Grade), c.Ref(), c.Reason, c.Ready, c.RWService)
for _, c := range cands {
note := ""
if sec.target != nil && c.Namespace == sec.target.Namespace && c.Name == sec.target.Name {
if filter.Env != "" {
note = fmt.Sprintf(" [target for env %s]", filter.Env)
} else if filter.Namespace != "" {
note = fmt.Sprintf(" [target for %s]", filter.Namespace)
}
}
fmt.Printf(" %s %-32s %s, ready: %s, rw: %s%s\n", mark(c.Grade), c.Ref(), c.Reason, c.Ready, c.RWService, note)
}
}
fmt.Println("\n==> Ingress classes")
@@ -416,18 +635,20 @@ func printReport(r *platform.Report) {
fmt.Printf(" %-24s %s%s\n", ic.Name, ic.Controller, def)
}
fmt.Println("\n==> Zero-scale gateways")
if len(r.Gateways) == 0 {
gws := filterGateways(r.Gateways, filter)
if len(gws) == 0 {
fmt.Println(" (none)")
}
for _, g := range r.Gateways {
for _, g := range gws {
by := g.ManagedBy
if by == "" {
by = "unmanaged"
}
fmt.Printf(" %s %s/%s %d/%d ready, managed by %s\n", mark(g.Grade), g.Namespace, g.Name, g.Ready, g.Replicas, by)
}
if len(r.Redis) > 0 {
fmt.Printf("\n==> Redis\n %s\n", strings.Join(r.Redis, "\n "))
redis := filterRedis(r.Redis, filter)
if len(redis) > 0 {
fmt.Printf("\n==> Redis\n %s\n", strings.Join(redis, "\n "))
}
for _, w := range r.Warnings {
fmt.Printf("\n⚠️ %s\n", w)

39
examples/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Deployment Examples
App configs for `zero-scale-ctl apply`. Each `.conf` describes one app; `apply` provisions its
database and secrets, uploads the binary to `/opt/apps`, registers it with the gateway and routes its hosts.
```bash
zero-scale-ctl apply -f examples/mdm-example.conf
zero-scale-ctl apply -f examples/mdm-example.conf -n qa # into an environment
```
`binary_path`, `config_file` and `config_files` are resolved relative to the `.conf` file, so either put the
binary next to the config or use an absolute path.
## Keys
| Key | Meaning |
|---|---|
| `hostname` | Host the app serves; repeat it, or use a `hostname:` list, for more hosts. The first is canonical and names the app (`mdm.ffxm.cc` → `mdm`). `${ENV_SUFFIX}` and `${ENV}` expand per environment. |
| `binary_path` | Local binary to upload. Required for apps. |
| `args` | Arguments passed to the binary (e.g. `serve`). |
| `init_cmd` | Command run once in the gateway pod during `apply` (migrations). `${BINARY}` and `${HOSTNAME}` expand. |
| `user`, `dynamic_user` | Run as a fixed user, or (default) a transient `DynamicUser`. |
| `idle_timeout` | Stop the app after this much inactivity (`60s`, `5m`). |
| `scale_to_zero` | `false` keeps the app running. |
| `volumes` | List of `name:/path`, `name:/path:ro` or `name:/path:tmp`. |
| `config_files` | Files templated with `${VAR}` from env and secrets and uploaded next to the binary. Names must start with the binary or app name (`mdm-config.yaml`). |
| `env:` | Block of `KEY: value` environment variables. `${PORT}` and `${HOST}` expand. |
| `secrets:` | List of `- KEY: source`, or `- KEY` to generate a random value. |
| `database:` | `engine: postgres\|mysql` plus `env_address`, `env_host`, `env_port`, `env_database`, `env_user`, `env_password` naming the variables the app reads. |
| `redis:` | `env_address`, `env_host`, `env_port`. |
| `gateway:` | `oauth_setup`, `oauth_url`, `oauth_group` to put the app behind the shared oauth2-proxy. |
| `launcher: \|` | Indented shell script that replaces the generated launcher. |
| `target_host` | Makes the file an edge redirect: `hostname` 301s to `target_host`, with no binary. |
## Examples
| File | Shows |
|---|---|
| [`mdm-example.conf`](mdm-example.conf) | Fixed user, custom idle timeout, OAuth behind a Gitea org, secrets shared from another app. |

17
examples/mdm-example.conf Normal file
View File

@@ -0,0 +1,17 @@
hostname: mdm.ffxm.cc
binary_path: ./mdm-controller
dynamic_user: false
idle_timeout: 60s
gateway:
oauth_setup: mdm
oauth_url: https://git.fairfaxmedia.net
oauth_group: open-cream-cheese,mdm
secrets:
- FLEET_SERVER_PRIVATE_KEY: fdm.FLEET_SERVER_PRIVATE_KEY
- FLEET_MYSQL_ADDRESS: fdm.FLEET_MYSQL_ADDRESS
- FLEET_MYSQL_DATABASE: fdm.FLEET_MYSQL_DATABASE
- FLEET_MYSQL_USERNAME: fdm.FLEET_MYSQL_USERNAME
- FLEET_MYSQL_PASSWORD: fdm.FLEET_MYSQL_PASSWORD

View File

@@ -2,6 +2,7 @@ package platform
import (
"sort"
"strings"
)
// TemporaryDataAnnotation marks a PVC whose contents may be lost with its node
@@ -18,6 +19,33 @@ type LocalVolume struct {
Temporary bool // annotated zero-scale.io/data=temporary
Owner string // Kind/name of the mounting pods' first owner, e.g. StatefulSet/qa-pxc-pxc
OwnerNodes int // distinct nodes running pods of that owner
ZeroScale bool // volume belongs to zero-scale platform or managed app
}
func isZeroScalePVC(c object) bool {
name := c.str("metadata", "name")
if strings.HasPrefix(name, "zero-scale-") || strings.HasPrefix(name, "shared-") {
return true
}
if labels, ok := c.get("metadata", "labels").(map[string]any); ok {
if labels["zero-scale-app"] == "true" || labels["app.kubernetes.io/part-of"] == "zero-scale-platform" {
return true
}
}
return false
}
func isZeroScalePod(p object) bool {
name := p.str("metadata", "name")
if strings.HasPrefix(name, "zero-scale-") || strings.HasPrefix(name, "shared-") {
return true
}
if labels, ok := p.get("metadata", "labels").(map[string]any); ok {
if labels["app"] == "zero-scale-gateway" || labels["zero-scale-app"] == "true" || labels["app.kubernetes.io/part-of"] == "zero-scale-platform" {
return true
}
}
return false
}
// Replicated reports whether the workload owning the pods runs on two or more
@@ -60,6 +88,7 @@ func NodeLocalVolumes(k Kube) ([]LocalVolume, error) {
byKey[ns+"/"+name] = &LocalVolume{
Namespace: ns, PVC: name, Class: class,
Temporary: c.str("metadata", "annotations", TemporaryDataAnnotation) == "temporary",
ZeroScale: isZeroScalePVC(c),
}
}
@@ -89,6 +118,9 @@ func NodeLocalVolumes(k Kube) ([]LocalVolume, error) {
if v == nil {
continue
}
if isZeroScalePod(p) {
v.ZeroScale = true
}
v.Pods = append(v.Pods, p.str("metadata", "name"))
if n := p.str("spec", "nodeName"); !contains(v.Nodes, n) {
v.Nodes = append(v.Nodes, n)

View File

@@ -15,6 +15,45 @@ type Exposure struct {
Nodes []string // distinct nodes those pods run on
Missing bool // the Ingress names a Service that does not exist
Pinned []string // node-local PVCs with persistent data that the pods mount
ZeroScale bool // managed by zero-scale platform
}
func isZeroScaleServiceName(svc string) bool {
return svc == "zero-scale-gateway" ||
strings.HasPrefix(svc, "oauth2-proxy-") ||
svc == "shared-mysql" ||
svc == "shared-redis"
}
func isZeroScaleIngress(ing object) bool {
name := ing.str("metadata", "name")
if strings.HasPrefix(name, "zero-scale-") {
return true
}
if labels, ok := ing.get("metadata", "labels").(map[string]any); ok {
if labels["zero-scale-app"] == "true" || labels["app.kubernetes.io/part-of"] == "zero-scale-platform" {
return true
}
}
if ann, ok := ing.get("metadata", "annotations").(map[string]any); ok {
if ann["zero-scale-oauth-setup"] != nil || ann["zero-scale-type"] != nil {
return true
}
}
return false
}
func isZeroScaleService(s object) bool {
name := s.str("metadata", "name")
if isZeroScaleServiceName(name) {
return true
}
if labels, ok := s.get("metadata", "labels").(map[string]any); ok {
if labels["app"] == "zero-scale-gateway" || labels["zero-scale-app"] == "true" || labels["app.kubernetes.io/part-of"] == "zero-scale-platform" {
return true
}
}
return false
}
// Survives reports whether the Service keeps serving when any one node is lost.
@@ -71,6 +110,7 @@ func IngressExposures(k Kube) ([]Exposure, error) {
byKey := map[string]*Exposure{}
for _, ing := range ingresses {
ns := ing.str("metadata", "namespace")
ingZero := isZeroScaleIngress(ing)
add := func(svc, host string) {
if svc == "" {
return
@@ -78,8 +118,10 @@ func IngressExposures(k Kube) ([]Exposure, error) {
key := ns + "/" + svc
e := byKey[key]
if e == nil {
e = &Exposure{Namespace: ns, Service: svc}
e = &Exposure{Namespace: ns, Service: svc, ZeroScale: isZeroScaleServiceName(svc) || ingZero}
byKey[key] = e
} else if ingZero {
e.ZeroScale = true
}
if host != "" && !hasHost(e.Hosts, host) {
e.Hosts = append(e.Hosts, host)
@@ -95,13 +137,17 @@ func IngressExposures(k Kube) ([]Exposure, error) {
selectors := map[string]map[string]string{}
for _, s := range services {
key := s.str("metadata", "namespace") + "/" + s.str("metadata", "name")
if e := byKey[key]; e != nil && isZeroScaleService(s) {
e.ZeroScale = true
}
sel := map[string]string{}
if m, ok := s.get("spec", "selector").(map[string]any); ok {
for k, v := range m {
sel[k], _ = v.(string)
}
}
selectors[s.str("metadata", "namespace")+"/"+s.str("metadata", "name")] = sel
selectors[key] = sel
}
for key, e := range byKey {
@@ -175,6 +221,7 @@ type IPClaim struct {
IP string
Node string
Ports []int
ZeroScale bool
}
// NodeIPClaims lists Services that capture traffic addressed to a node IP.
@@ -204,7 +251,13 @@ func NodeIPClaims(k Kube) ([]IPClaim, error) {
if !ok {
continue
}
c := IPClaim{Namespace: s.str("metadata", "namespace"), Service: s.str("metadata", "name"), IP: ip, Node: node}
c := IPClaim{
Namespace: s.str("metadata", "namespace"),
Service: s.str("metadata", "name"),
IP: ip,
Node: node,
ZeroScale: isZeroScaleService(s),
}
for _, p := range s.list("spec", "ports") {
if f, ok := p.get("port").(float64); ok {
c.Ports = append(c.Ports, int(f))
@@ -223,6 +276,7 @@ func NodeIPClaims(k Kube) ([]IPClaim, error) {
type RouteConflict struct {
Host, Path string
Routes []string // namespace/ingress -> service
ZeroScale bool
}
// RouteConflicts lists host+path pairs routed to different backends by more than
@@ -232,10 +286,14 @@ func RouteConflicts(k Kube) ([]RouteConflict, error) {
if err != nil {
return nil, err
}
type route struct{ ingress, service string }
type route struct {
ingress, service string
zeroScale bool
}
byRoute := map[[2]string][]route{}
for _, ing := range ingresses {
ns := ing.str("metadata", "namespace")
ingZero := isZeroScaleIngress(ing)
for _, rule := range ing.list("spec", "rules") {
host := strings.ToLower(rule.str("host"))
for _, p := range rule.list("http", "paths") {
@@ -244,20 +302,30 @@ func RouteConflicts(k Kube) ([]RouteConflict, error) {
path = "/"
}
key := [2]string{host, path}
byRoute[key] = append(byRoute[key], route{ns + "/" + ing.str("metadata", "name"), ns + "/" + p.str("backend", "service", "name")})
svc := p.str("backend", "service", "name")
zs := ingZero || isZeroScaleServiceName(svc)
byRoute[key] = append(byRoute[key], route{
ingress: ns + "/" + ing.str("metadata", "name"),
service: ns + "/" + svc,
zeroScale: zs,
})
}
}
}
var out []RouteConflict
for key, routes := range byRoute {
services := map[string]bool{}
zs := false
for _, r := range routes {
services[r.service] = true
if r.zeroScale {
zs = true
}
}
if len(services) < 2 {
continue
}
c := RouteConflict{Host: key[0], Path: key[1]}
c := RouteConflict{Host: key[0], Path: key[1], ZeroScale: zs}
for _, r := range routes {
c.Routes = append(c.Routes, r.ingress+" -> "+r.service)
}

View File

@@ -38,6 +38,10 @@ func TestIngressExposures(t *testing.T) {
if e.Survives() != (i == 5) {
t.Errorf("%s/%s: Survives() = %v", e.Namespace, e.Service, e.Survives())
}
wantZS := (i == 2 || i == 3 || i == 5)
if e.ZeroScale != wantZS {
t.Errorf("%s/%s: ZeroScale = %v, want %v", e.Namespace, e.Service, e.ZeroScale, wantZS)
}
}
}
@@ -87,6 +91,9 @@ func TestRouteConflicts(t *testing.T) {
strings.Join(got[0].Routes, "; ") != "default/hoppscotch-web -> default/hoppscotch-web; default/zero-scale-gateway -> default/zero-scale-gateway" {
t.Errorf("got %+v", got)
}
if !got[0].ZeroScale {
t.Errorf("expected RouteConflict with zero-scale-gateway to have ZeroScale=true")
}
}
func TestKnativeOnlyHosts(t *testing.T) {

17
services/mdm-example.conf Normal file
View File

@@ -0,0 +1,17 @@
hostname: mdm.ffxm.cc
binary_path: ./mdm-controller
dynamic_user: false
idle_timeout: 60s
gateway:
oauth_setup: mdm
oauth_url: https://git.fairfaxmedia.net
oauth_group: open-cream-cheese,mdm
secrets:
- FLEET_SERVER_PRIVATE_KEY: fdm.FLEET_SERVER_PRIVATE_KEY
- FLEET_MYSQL_ADDRESS: fdm.FLEET_MYSQL_ADDRESS
- FLEET_MYSQL_DATABASE: fdm.FLEET_MYSQL_DATABASE
- FLEET_MYSQL_USERNAME: fdm.FLEET_MYSQL_USERNAME
- FLEET_MYSQL_PASSWORD: fdm.FLEET_MYSQL_PASSWORD