Compare commits
4 Commits
zero-scale
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52c28d774 | ||
|
|
4573e8fbfd | ||
|
|
0f1ef3fee8 | ||
|
|
772572e6df |
@@ -48,6 +48,7 @@ zero-scale-platform/
|
|||||||
│ ├── deployment.yaml # Gateway Deployment (replaces manual pods)
|
│ ├── deployment.yaml # Gateway Deployment (replaces manual pods)
|
||||||
│ ├── service.yaml # Gateway ClusterIP Service (:80)
|
│ ├── service.yaml # Gateway ClusterIP Service (:80)
|
||||||
│ └── ingress.yaml # Ingress routing configuration
|
│ └── ingress.yaml # Ingress routing configuration
|
||||||
|
├── examples/ # App .conf files for zero-scale-ctl apply
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── zero-scale-ctl.sh # CLI tool for deploying and monitoring apps
|
│ └── zero-scale-ctl.sh # CLI tool for deploying and monitoring apps
|
||||||
└── services/ # Systemd service unit templates
|
└── services/ # Systemd service unit templates
|
||||||
@@ -123,6 +124,7 @@ zero-scale-ctl status myapp.example.com
|
|||||||
# Follow real-time systemd journal logs
|
# Follow real-time systemd journal logs
|
||||||
zero-scale-ctl logs myapp.example.com -n 100
|
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) {
|
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"}}`
|
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+`' ;;
|
log := fakeKubectl(t, ` "get configmap zero-scale-platform -n zero-scale-system -o json") echo '`+cm+`' ;;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
|
|
||||||
Platform setup:
|
Platform setup:
|
||||||
doctor Detect HA storage, Postgres/MySQL operators, ingress and gateways (read-only)
|
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
|
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]
|
[--storage-class X] [--rwx-class Y] [--postgres ns/name|none] [--mysql ns/name|none]
|
||||||
[--allow-non-ha] [--dry-run] [--yes]
|
[--allow-non-ha] [--dry-run] [--yes]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -238,24 +239,159 @@ func runMySQL(t *platform.DBTarget, op *platform.Operator, sql string) error {
|
|||||||
return err
|
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
|
// ---------------------------------------------------------------- doctor / init
|
||||||
|
|
||||||
func runDoctor(args []string) error {
|
func runDoctor(args []string) error {
|
||||||
fs := flag.NewFlagSet("doctor", flag.ExitOnError)
|
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)
|
_ = 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 {
|
if err != nil {
|
||||||
return err
|
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")
|
fmt.Println("\n==> Next steps")
|
||||||
current, _ := loadPlatform()
|
current, _ := loadPlatform()
|
||||||
if current != nil {
|
if current != nil {
|
||||||
fmt.Printf(" Platform config exists in %s/%s. Re-run 'zero-scale-ctl init' to refresh it.\n", platformNamespace(), platform.ConfigMapName)
|
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))
|
_, chooseErr := platform.Choose(r, recordedOptions(current))
|
||||||
switch e := chooseErr.(type) {
|
switch e := chooseErr.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@@ -274,8 +410,13 @@ func runDoctor(args []string) error {
|
|||||||
default:
|
default:
|
||||||
fmt.Printf(" %v\n", e)
|
fmt.Printf(" %v\n", e)
|
||||||
}
|
}
|
||||||
if len(r.Gateways) == 0 {
|
gws := filterGateways(r.Gateways, filter)
|
||||||
fmt.Println(" No zero-scale gateway found: 'zero-scale-ctl gateway install' (or deploy your Helm chart).")
|
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
|
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
|
// 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.
|
// 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")
|
fmt.Println("\n==> Single node failure")
|
||||||
var defaults []string
|
var defaults []string
|
||||||
for _, sc := range r.StorageClasses {
|
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)
|
fmt.Printf(" (could not check node-local volumes: %v)\n", err)
|
||||||
}
|
}
|
||||||
for _, v := range locals {
|
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, ", "))
|
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 {
|
switch {
|
||||||
case v.Temporary:
|
case v.Temporary:
|
||||||
@@ -336,17 +483,40 @@ func printNodeFailure(r *platform.Report) {
|
|||||||
fmt.Printf(" (could not check Ingress routes: %v)\n", err)
|
fmt.Printf(" (could not check Ingress routes: %v)\n", err)
|
||||||
}
|
}
|
||||||
for _, c := range conflicts {
|
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, "; "))
|
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{})
|
if !filter.ZeroScaleOnly && filter.Namespace == "" {
|
||||||
for _, h := range knativeOnly {
|
knativeOnly, _ := platform.KnativeOnlyHosts(kubectlKube{})
|
||||||
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)
|
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{})
|
claims, err := platform.NodeIPClaims(kubectlKube{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" (could not check Service externalIPs: %v)\n", err)
|
fmt.Printf(" (could not check Service externalIPs: %v)\n", err)
|
||||||
}
|
}
|
||||||
for _, c := range claims {
|
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)
|
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{})
|
exps, err := platform.IngressExposures(kubectlKube{})
|
||||||
@@ -354,18 +524,40 @@ func printNodeFailure(r *platform.Report) {
|
|||||||
fmt.Printf(" (could not check Ingress backends: %v)\n", err)
|
fmt.Printf(" (could not check Ingress backends: %v)\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok := 0
|
var filteredExps []platform.Exposure
|
||||||
for _, e := range exps {
|
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() {
|
if e.Survives() {
|
||||||
ok++
|
ok++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" ❌ %s/%s: %s (%s)\n", e.Namespace, e.Service, e.Problem(), strings.Join(e.Hosts, ", "))
|
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 {
|
mark := func(g platform.Grade) string {
|
||||||
switch g {
|
switch g {
|
||||||
case platform.HA:
|
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)
|
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 {
|
for _, sec := range []struct {
|
||||||
title string
|
title string
|
||||||
cands []platform.DBCandidate
|
cands []platform.DBCandidate
|
||||||
}{{"PostgreSQL clusters", r.Postgres}, {"MySQL clusters", r.MySQL}} {
|
target *platform.DBTarget
|
||||||
|
}{
|
||||||
|
{"PostgreSQL clusters", r.Postgres, activePG},
|
||||||
|
{"MySQL clusters", r.MySQL, activeMY},
|
||||||
|
} {
|
||||||
fmt.Printf("\n==> %s\n", sec.title)
|
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)")
|
fmt.Println(" (none)")
|
||||||
}
|
}
|
||||||
for _, c := range sec.cands {
|
for _, c := range cands {
|
||||||
fmt.Printf(" %s %-32s %s, ready: %s, rw: %s\n", mark(c.Grade), c.Ref(), c.Reason, c.Ready, c.RWService)
|
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")
|
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.Printf(" %-24s %s%s\n", ic.Name, ic.Controller, def)
|
||||||
}
|
}
|
||||||
fmt.Println("\n==> Zero-scale gateways")
|
fmt.Println("\n==> Zero-scale gateways")
|
||||||
if len(r.Gateways) == 0 {
|
gws := filterGateways(r.Gateways, filter)
|
||||||
|
if len(gws) == 0 {
|
||||||
fmt.Println(" (none)")
|
fmt.Println(" (none)")
|
||||||
}
|
}
|
||||||
for _, g := range r.Gateways {
|
for _, g := range gws {
|
||||||
by := g.ManagedBy
|
by := g.ManagedBy
|
||||||
if by == "" {
|
if by == "" {
|
||||||
by = "unmanaged"
|
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)
|
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 {
|
redis := filterRedis(r.Redis, filter)
|
||||||
fmt.Printf("\n==> Redis\n %s\n", strings.Join(r.Redis, "\n "))
|
if len(redis) > 0 {
|
||||||
|
fmt.Printf("\n==> Redis\n %s\n", strings.Join(redis, "\n "))
|
||||||
}
|
}
|
||||||
for _, w := range r.Warnings {
|
for _, w := range r.Warnings {
|
||||||
fmt.Printf("\n⚠️ %s\n", w)
|
fmt.Printf("\n⚠️ %s\n", w)
|
||||||
|
|||||||
@@ -890,6 +890,33 @@ func (c *Collector) getSystemSliceDir() string {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
fqdnHostnameRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
|
||||||
|
|
||||||
|
nonDomainFileExtensions = map[string]bool{
|
||||||
|
"yaml": true, "yml": true, "json": true, "toml": true, "ini": true,
|
||||||
|
"conf": true, "cfg": true, "txt": true, "md": true, "log": true,
|
||||||
|
"key": true, "crt": true, "pem": true, "pub": true, "bak": true,
|
||||||
|
"old": true, "tmp": true, "sh": true, "bash": true, "py": true,
|
||||||
|
"go": true, "type": true, "gz": true, "tar": true, "zip": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// isValidFQDN checks whether a candidate string is a valid fully qualified domain name
|
||||||
|
// and not a configuration/source/credential file name (e.g. headscale-config.yaml or hs_noise.key).
|
||||||
|
func isValidFQDN(h string) bool {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if !fqdnHostnameRegex.MatchString(h) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(h, ".")
|
||||||
|
tld := strings.ToLower(parts[len(parts)-1])
|
||||||
|
if nonDomainFileExtensions[tld] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Collector) collectZeroScaleApps() []ZeroScaleApp {
|
func (c *Collector) collectZeroScaleApps() []ZeroScaleApp {
|
||||||
knownDescriptions := map[string]string{
|
knownDescriptions := map[string]string{
|
||||||
"bdash2.kube.fairfaxmedia.net": "High-Performance CloudNativePG Builds & Parts Dashboard",
|
"bdash2.kube.fairfaxmedia.net": "High-Performance CloudNativePG Builds & Parts Dashboard",
|
||||||
@@ -919,7 +946,7 @@ func (c *Collector) collectZeroScaleApps() []ZeroScaleApp {
|
|||||||
if err := json.Unmarshal(cmBytes, &cmList); err == nil {
|
if err := json.Unmarshal(cmBytes, &cmList); err == nil {
|
||||||
for _, item := range cmList.Items {
|
for _, item := range cmList.Items {
|
||||||
h := strings.TrimSpace(item.Data["host"])
|
h := strings.TrimSpace(item.Data["host"])
|
||||||
if h != "" {
|
if h != "" && isValidFQDN(h) {
|
||||||
appType := item.Data["type"]
|
appType := item.Data["type"]
|
||||||
if appType == "" {
|
if appType == "" {
|
||||||
appType = "elf"
|
appType = "elf"
|
||||||
@@ -944,8 +971,8 @@ func (c *Collector) collectZeroScaleApps() []ZeroScaleApp {
|
|||||||
// 3. Discover from /opt/apps directory (symlinks and binaries)
|
// 3. Discover from /opt/apps directory (symlinks and binaries)
|
||||||
if entries, err := os.ReadDir(c.appsDir); err == nil {
|
if entries, err := os.ReadDir(c.appsDir); err == nil {
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
name := e.Name()
|
name := strings.TrimSpace(e.Name())
|
||||||
if strings.Contains(name, ".") && !strings.HasSuffix(name, ".type") && !strings.HasSuffix(name, ".conf") && !strings.HasSuffix(name, ".bak") {
|
if isValidFQDN(name) {
|
||||||
if _, exists := appMap[name]; !exists {
|
if _, exists := appMap[name]; !exists {
|
||||||
appType := "elf"
|
appType := "elf"
|
||||||
typePath := filepath.Join(c.appsDir, name+".type")
|
typePath := filepath.Join(c.appsDir, name+".type")
|
||||||
@@ -1131,7 +1158,7 @@ func (c *Collector) parseIngressSites(raw []byte) []SiteGroup {
|
|||||||
for _, ing := range ingList.Items {
|
for _, ing := range ingList.Items {
|
||||||
for _, r := range ing.Spec.Rules {
|
for _, r := range ing.Spec.Rules {
|
||||||
h := strings.TrimSpace(r.Host)
|
h := strings.TrimSpace(r.Host)
|
||||||
if h == "" || seenHosts[h] {
|
if h == "" || !isValidFQDN(h) || seenHosts[h] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenHosts[h] = true
|
seenHosts[h] = true
|
||||||
|
|||||||
127
cmd/zero-scale-dashboard/go/collector_test.go
Normal file
127
cmd/zero-scale-dashboard/go/collector_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidFQDN(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"bdash2.fairfaxmedia.net",
|
||||||
|
"bdash2.kube.fairfaxmedia.net",
|
||||||
|
"fdm.ffxm.cc",
|
||||||
|
"hopscotch.kube.fairfaxmedia.net",
|
||||||
|
"hs.ffxm.cc",
|
||||||
|
"kstat.fairfaxmedia.net",
|
||||||
|
"kstat.kube.fairfaxmedia.net",
|
||||||
|
"mdm.ffxm.cc",
|
||||||
|
"mock-qa.ffxm.cc",
|
||||||
|
"status.fairfaxmedia.net",
|
||||||
|
"status.kube.fairfaxmedia.net",
|
||||||
|
"example.com",
|
||||||
|
"my-app.sub.example.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"headscale-config.yaml",
|
||||||
|
"headscale-config.yml",
|
||||||
|
"hs_noise.key",
|
||||||
|
"headscale.conf",
|
||||||
|
"app.json",
|
||||||
|
"config.toml",
|
||||||
|
"notes.txt",
|
||||||
|
"readme.md",
|
||||||
|
"server.log",
|
||||||
|
"tls.crt",
|
||||||
|
"tls.pem",
|
||||||
|
"id_rsa.pub",
|
||||||
|
"backup.bak",
|
||||||
|
"old.old",
|
||||||
|
"temp.tmp",
|
||||||
|
"deploy.sh",
|
||||||
|
"script.bash",
|
||||||
|
"helper.py",
|
||||||
|
"main.go",
|
||||||
|
"archive.tar",
|
||||||
|
"archive.gz",
|
||||||
|
"bundle.zip",
|
||||||
|
"my-app.type",
|
||||||
|
"fleet",
|
||||||
|
"mdm-controller",
|
||||||
|
"lost+found",
|
||||||
|
"notadomain",
|
||||||
|
"nodots",
|
||||||
|
"bad_underscore.com",
|
||||||
|
"-leadingdash.com",
|
||||||
|
"trailingdash-.com",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range valid {
|
||||||
|
if !isValidFQDN(v) {
|
||||||
|
t.Errorf("isValidFQDN(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inv := range invalid {
|
||||||
|
if isValidFQDN(inv) {
|
||||||
|
t.Errorf("isValidFQDN(%q) = true, want false", inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectZeroScaleAppsFiltersNonFQDN(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "test-apps-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Create sample entries in /opt/apps
|
||||||
|
testFiles := []string{
|
||||||
|
"bdash2.fairfaxmedia.net",
|
||||||
|
"headscale-config.yaml",
|
||||||
|
"hs_noise.key",
|
||||||
|
"hs.ffxm.cc",
|
||||||
|
"fleet",
|
||||||
|
"status.kube.fairfaxmedia.net",
|
||||||
|
}
|
||||||
|
for _, f := range testFiles {
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, f), []byte("dummy"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Collector{
|
||||||
|
appsDir: tmpDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
apps := c.collectZeroScaleApps()
|
||||||
|
appHosts := make(map[string]bool)
|
||||||
|
for _, a := range apps {
|
||||||
|
appHosts[a.Host] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should include valid FQDNs
|
||||||
|
if !appHosts["bdash2.fairfaxmedia.net"] {
|
||||||
|
t.Error("expected bdash2.fairfaxmedia.net to be present")
|
||||||
|
}
|
||||||
|
if !appHosts["hs.ffxm.cc"] {
|
||||||
|
t.Error("expected hs.ffxm.cc to be present")
|
||||||
|
}
|
||||||
|
if !appHosts["status.kube.fairfaxmedia.net"] {
|
||||||
|
t.Error("expected status.kube.fairfaxmedia.net to be present")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should filter out non-FQDN files
|
||||||
|
if appHosts["headscale-config.yaml"] {
|
||||||
|
t.Error("headscale-config.yaml should be filtered out")
|
||||||
|
}
|
||||||
|
if appHosts["hs_noise.key"] {
|
||||||
|
t.Error("hs_noise.key should be filtered out")
|
||||||
|
}
|
||||||
|
if appHosts["fleet"] {
|
||||||
|
t.Error("fleet should be filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
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 (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemporaryDataAnnotation marks a PVC whose contents may be lost with its node
|
// 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
|
Temporary bool // annotated zero-scale.io/data=temporary
|
||||||
Owner string // Kind/name of the mounting pods' first owner, e.g. StatefulSet/qa-pxc-pxc
|
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
|
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
|
// 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{
|
byKey[ns+"/"+name] = &LocalVolume{
|
||||||
Namespace: ns, PVC: name, Class: class,
|
Namespace: ns, PVC: name, Class: class,
|
||||||
Temporary: c.str("metadata", "annotations", TemporaryDataAnnotation) == "temporary",
|
Temporary: c.str("metadata", "annotations", TemporaryDataAnnotation) == "temporary",
|
||||||
|
ZeroScale: isZeroScalePVC(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +118,9 @@ func NodeLocalVolumes(k Kube) ([]LocalVolume, error) {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isZeroScalePod(p) {
|
||||||
|
v.ZeroScale = true
|
||||||
|
}
|
||||||
v.Pods = append(v.Pods, p.str("metadata", "name"))
|
v.Pods = append(v.Pods, p.str("metadata", "name"))
|
||||||
if n := p.str("spec", "nodeName"); !contains(v.Nodes, n) {
|
if n := p.str("spec", "nodeName"); !contains(v.Nodes, n) {
|
||||||
v.Nodes = append(v.Nodes, n)
|
v.Nodes = append(v.Nodes, n)
|
||||||
|
|||||||
@@ -15,6 +15,45 @@ type Exposure struct {
|
|||||||
Nodes []string // distinct nodes those pods run on
|
Nodes []string // distinct nodes those pods run on
|
||||||
Missing bool // the Ingress names a Service that does not exist
|
Missing bool // the Ingress names a Service that does not exist
|
||||||
Pinned []string // node-local PVCs with persistent data that the pods mount
|
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.
|
// 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{}
|
byKey := map[string]*Exposure{}
|
||||||
for _, ing := range ingresses {
|
for _, ing := range ingresses {
|
||||||
ns := ing.str("metadata", "namespace")
|
ns := ing.str("metadata", "namespace")
|
||||||
|
ingZero := isZeroScaleIngress(ing)
|
||||||
add := func(svc, host string) {
|
add := func(svc, host string) {
|
||||||
if svc == "" {
|
if svc == "" {
|
||||||
return
|
return
|
||||||
@@ -78,8 +118,10 @@ func IngressExposures(k Kube) ([]Exposure, error) {
|
|||||||
key := ns + "/" + svc
|
key := ns + "/" + svc
|
||||||
e := byKey[key]
|
e := byKey[key]
|
||||||
if e == nil {
|
if e == nil {
|
||||||
e = &Exposure{Namespace: ns, Service: svc}
|
e = &Exposure{Namespace: ns, Service: svc, ZeroScale: isZeroScaleServiceName(svc) || ingZero}
|
||||||
byKey[key] = e
|
byKey[key] = e
|
||||||
|
} else if ingZero {
|
||||||
|
e.ZeroScale = true
|
||||||
}
|
}
|
||||||
if host != "" && !hasHost(e.Hosts, host) {
|
if host != "" && !hasHost(e.Hosts, host) {
|
||||||
e.Hosts = append(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{}
|
selectors := map[string]map[string]string{}
|
||||||
for _, s := range services {
|
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{}
|
sel := map[string]string{}
|
||||||
if m, ok := s.get("spec", "selector").(map[string]any); ok {
|
if m, ok := s.get("spec", "selector").(map[string]any); ok {
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
sel[k], _ = v.(string)
|
sel[k], _ = v.(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectors[s.str("metadata", "namespace")+"/"+s.str("metadata", "name")] = sel
|
selectors[key] = sel
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, e := range byKey {
|
for key, e := range byKey {
|
||||||
@@ -175,6 +221,7 @@ type IPClaim struct {
|
|||||||
IP string
|
IP string
|
||||||
Node string
|
Node string
|
||||||
Ports []int
|
Ports []int
|
||||||
|
ZeroScale bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeIPClaims lists Services that capture traffic addressed to a node IP.
|
// NodeIPClaims lists Services that capture traffic addressed to a node IP.
|
||||||
@@ -204,7 +251,13 @@ func NodeIPClaims(k Kube) ([]IPClaim, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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") {
|
for _, p := range s.list("spec", "ports") {
|
||||||
if f, ok := p.get("port").(float64); ok {
|
if f, ok := p.get("port").(float64); ok {
|
||||||
c.Ports = append(c.Ports, int(f))
|
c.Ports = append(c.Ports, int(f))
|
||||||
@@ -223,6 +276,7 @@ func NodeIPClaims(k Kube) ([]IPClaim, error) {
|
|||||||
type RouteConflict struct {
|
type RouteConflict struct {
|
||||||
Host, Path string
|
Host, Path string
|
||||||
Routes []string // namespace/ingress -> service
|
Routes []string // namespace/ingress -> service
|
||||||
|
ZeroScale bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteConflicts lists host+path pairs routed to different backends by more than
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
type route struct{ ingress, service string }
|
type route struct {
|
||||||
|
ingress, service string
|
||||||
|
zeroScale bool
|
||||||
|
}
|
||||||
byRoute := map[[2]string][]route{}
|
byRoute := map[[2]string][]route{}
|
||||||
for _, ing := range ingresses {
|
for _, ing := range ingresses {
|
||||||
ns := ing.str("metadata", "namespace")
|
ns := ing.str("metadata", "namespace")
|
||||||
|
ingZero := isZeroScaleIngress(ing)
|
||||||
for _, rule := range ing.list("spec", "rules") {
|
for _, rule := range ing.list("spec", "rules") {
|
||||||
host := strings.ToLower(rule.str("host"))
|
host := strings.ToLower(rule.str("host"))
|
||||||
for _, p := range rule.list("http", "paths") {
|
for _, p := range rule.list("http", "paths") {
|
||||||
@@ -244,20 +302,30 @@ func RouteConflicts(k Kube) ([]RouteConflict, error) {
|
|||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
key := [2]string{host, 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
|
var out []RouteConflict
|
||||||
for key, routes := range byRoute {
|
for key, routes := range byRoute {
|
||||||
services := map[string]bool{}
|
services := map[string]bool{}
|
||||||
|
zs := false
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
services[r.service] = true
|
services[r.service] = true
|
||||||
|
if r.zeroScale {
|
||||||
|
zs = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(services) < 2 {
|
if len(services) < 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := RouteConflict{Host: key[0], Path: key[1]}
|
c := RouteConflict{Host: key[0], Path: key[1], ZeroScale: zs}
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
c.Routes = append(c.Routes, r.ingress+" -> "+r.service)
|
c.Routes = append(c.Routes, r.ingress+" -> "+r.service)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func TestIngressExposures(t *testing.T) {
|
|||||||
if e.Survives() != (i == 5) {
|
if e.Survives() != (i == 5) {
|
||||||
t.Errorf("%s/%s: Survives() = %v", e.Namespace, e.Service, e.Survives())
|
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" {
|
strings.Join(got[0].Routes, "; ") != "default/hoppscotch-web -> default/hoppscotch-web; default/zero-scale-gateway -> default/zero-scale-gateway" {
|
||||||
t.Errorf("got %+v", got)
|
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) {
|
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