Files
minio/cmd/healthcheck-main_test.go
T
Feng Ruohang b6d47b739c fix: harden healthcheck and distroless lanes per adversarial review
Findings from an adversarial review (Codex, gpt-5.6-sol at max effort)
of 2ff594f4b and 4c34d2309, each independently verified before fixing:

- SBOM generation: buildx attaches a provenance attestation, so every
  per-arch digest names an OCI index; Syft's platform default on an
  amd64 runner cannot resolve an arm64-only index and the step dies.
  Pass --platform explicitly on all four Syft calls (the two classic
  lanes had the same latent defect - the renamed workflow has not run
  yet, which is why it never fired).
- Release ordering: the HEALTHCHECK survival check now runs against
  the pushed architecture image before the versioned and rolling
  multi-arch manifests are created, so a broken health config blocks
  their promotion; the comment now states honestly that the
  arch-suffixed tags are already public at that point.
- Gate assertions: tar's member-argument mode exits non-zero on any
  missing name, which under pipefail masked a found forbidden file
  when exactly one of them existed; -tv prints symlinks as
  'name -> target', defeating $-anchored greps; and the licenses
  check proved only one-of-three. Export the rootfs once and assert
  every required and forbidden entry individually (busybox/sh and
  usr/bin/mc[li] now covered), and match the image healthcheck as an
  exact array instead of a substring.
- Probe target vs CLI-configured servers: a probe process cannot see
  PID 1's argv, so --url gains EnvVar MINIO_HEALTHCHECK_URL as the
  documented way to point the baked-in HEALTHCHECK at a server whose
  address/TLS comes from command-line arguments (verified end to end:
  server on --address :9010, env var alone turns the container
  healthy). Baseline regenerated for the new env token.
- IPv6 zone identifiers: serialize probe URLs via url.URL.String()
  so [fe80::1%eth0]:9000 becomes a valid %25-escaped URL (tests added).
- Boolean flags: read --json/--quiet via Bool() so --json=false is
  false, instead of IsSet() which treats any occurrence as true.
- Docker's HEALTHCHECK timeout raised to 10s: an outer deadline equal
  to the probe's own 5s always SIGKILLed the probe before it could
  print its diagnostic line.
- test-release path filter now also triggers on cmd/healthcheck-main.go
  and cmd/main.go, so subcommand regressions run the image gate.

Not adopted: require_text's comment-insensitivity in verify-rebrand.sh
(snapshot-tripwire by design, consistent with its other assertions -
the semantic check lives in the CI gate now), and full
staging-then-promote tag publishing (a workflow-wide redesign shared
with the classic lanes, tracked as follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:27:22 +08:00

203 lines
7.4 KiB
Go

// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
xhttp "github.com/minio/minio/internal/http"
)
func TestHealthcheckTarget(t *testing.T) {
plainDir := t.TempDir()
tlsDir := t.TempDir()
for _, name := range []string{publicCertFile, privateKeyFile} {
if err := os.WriteFile(filepath.Join(tlsDir, name), []byte("test"), 0o600); err != nil {
t.Fatal(err)
}
}
// A lone public.crt without its key must not flip the scheme.
halfDir := t.TempDir()
if err := os.WriteFile(filepath.Join(halfDir, publicCertFile), []byte("test"), 0o600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
rawURL string
address string
certsDir string
want string
wantErr bool
}{
{name: "default address", address: ":9000", certsDir: plainDir, want: "http://127.0.0.1:9000"},
{name: "explicit host", address: "10.0.0.7:9010", certsDir: plainDir, want: "http://10.0.0.7:9010"},
{name: "ipv6 address", address: "[::1]:9000", certsDir: plainDir, want: "http://[::1]:9000"},
{name: "ipv6 zone is escaped", address: "[fe80::1%eth0]:9000", certsDir: plainDir, want: "http://[fe80::1%25eth0]:9000"},
{name: "ipv6 zone in url", rawURL: "http://[fe80::1%25eth0]:9000", certsDir: plainDir, want: "http://[fe80::1%25eth0]:9000"},
{name: "tls certs present", address: ":9000", certsDir: tlsDir, want: "https://127.0.0.1:9000"},
{name: "cert without key stays http", address: ":9000", certsDir: halfDir, want: "http://127.0.0.1:9000"},
{name: "url override wins", rawURL: "https://silo.internal:9000", address: ":9000", certsDir: plainDir, want: "https://silo.internal:9000"},
{name: "url path is dropped", rawURL: "http://silo.internal:9000/minio/health/live", address: ":9000", certsDir: plainDir, want: "http://silo.internal:9000"},
{name: "address without port", address: "localhost", certsDir: plainDir, wantErr: true},
{name: "url without scheme", rawURL: "silo.internal:9000", certsDir: plainDir, wantErr: true},
{name: "url with bad scheme", rawURL: "ftp://silo.internal:9000", certsDir: plainDir, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := healthcheckTarget(test.rawURL, test.address, test.certsDir)
if (err != nil) != test.wantErr {
t.Fatalf("healthcheckTarget() error = %v, wantErr = %v", err, test.wantErr)
}
if err == nil && got != test.want {
t.Fatalf("healthcheckTarget() = %q, want %q", got, test.want)
}
})
}
}
func TestProbeHealthChecksAndVerdicts(t *testing.T) {
var gotPath, gotQuery, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotAuth = r.Header.Get("Authorization")
switch r.URL.Path {
case "/minio/health/live", "/minio/health/ready":
w.WriteHeader(http.StatusOK)
case "/minio/health/cluster":
if r.URL.Query().Get("maintenance") == "true" {
w.Header().Set(xhttp.MinIOWriteQuorum, "3")
w.Header().Set(xhttp.MinIOHealingDrives, "2")
w.WriteHeader(http.StatusPreconditionFailed)
return
}
w.Header().Set(xhttp.MinIOServerStatus, "iam-offline")
w.Header().Set(xhttp.MinIOWriteQuorum, "3")
w.WriteHeader(http.StatusServiceUnavailable)
case "/minio/health/cluster/read":
w.Header().Set(xhttp.MinIOReadQuorum, "2")
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
res := probeHealth(srv.URL, "live", false, time.Second)
if !res.Healthy || res.StatusCode != http.StatusOK {
t.Fatalf("live: expected healthy 200, got %+v", res)
}
if gotPath != "/minio/health/live" {
t.Fatalf("live: probed %q", gotPath)
}
if gotAuth != "" {
t.Fatalf("probe must be anonymous, sent Authorization %q", gotAuth)
}
res = probeHealth(srv.URL, "cluster", false, time.Second)
if res.Healthy || res.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("cluster: expected unhealthy 503, got %+v", res)
}
if res.ServerStatus != "iam-offline" || res.WriteQuorum != "3" {
t.Fatalf("cluster: headers not decoded, got %+v", res)
}
if gotQuery != "" {
t.Fatalf("cluster without --maintenance sent query %q", gotQuery)
}
res = probeHealth(srv.URL, "cluster", true, time.Second)
if res.Healthy || res.StatusCode != http.StatusPreconditionFailed {
t.Fatalf("cluster maintenance: expected 412, got %+v", res)
}
if res.HealingDrives != "2" {
t.Fatalf("cluster maintenance: headers not decoded, got %+v", res)
}
if gotQuery != "maintenance=true" {
t.Fatalf("cluster --maintenance sent query %q", gotQuery)
}
res = probeHealth(srv.URL, "cluster-read", false, time.Second)
if !res.Healthy || res.ReadQuorum != "2" {
t.Fatalf("cluster-read: expected healthy with read quorum, got %+v", res)
}
if gotPath != "/minio/health/cluster/read" {
t.Fatalf("cluster-read: probed %q", gotPath)
}
}
func TestProbeHealthTLSSkipsVerification(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
res := probeHealth(srv.URL, "live", false, time.Second)
if !res.Healthy {
t.Fatalf("self-signed TLS probe must succeed, got %+v", res)
}
}
func TestProbeHealthUnreachableAndTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
deadURL := srv.URL
srv.Close()
res := probeHealth(deadURL, "live", false, time.Second)
if res.Healthy || res.Err == "" {
t.Fatalf("probe of a closed server must report unreachable, got %+v", res)
}
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer slow.Close()
res = probeHealth(slow.URL, "live", false, 50*time.Millisecond)
if res.Healthy || res.Err == "" {
t.Fatalf("probe past its deadline must fail, got %+v", res)
}
}
func TestHealthcheckResultLine(t *testing.T) {
tests := []struct {
res healthcheckResult
want string
}{
{healthcheckResult{Check: "live", Healthy: true, StatusCode: 200, DurationMS: 2}, "live: ok (200, 2ms)"},
{healthcheckResult{Check: "cluster", StatusCode: 503, ServerStatus: "iam-offline", WriteQuorum: "3", HealingDrives: "2"}, "cluster: unhealthy (503) server-status=iam-offline write-quorum=3 healing-drives=2"},
{healthcheckResult{Check: "cluster", StatusCode: 412, WriteQuorum: "3"}, "cluster: not safe for maintenance (412) write-quorum=3"},
{healthcheckResult{Check: "ready", Err: "connection refused"}, "ready: unreachable (connection refused)"},
}
for _, test := range tests {
if got := test.res.line(); got != test.want {
t.Fatalf("line() = %q, want %q", got, test.want)
}
}
}