Compare commits
3 Commits
zero-scale
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4573e8fbfd | ||
|
|
0f1ef3fee8 | ||
|
|
772572e6df |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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+`' ;;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
39
examples/README.md
Normal 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
17
examples/mdm-example.conf
Normal 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
17
services/mdm-example.conf
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user