build(helm): replace the minio chart with a silo chart that preserves identity

helm/minio becomes helm/silo: chart name silo, version 6.0.0 -> 7.0.0, the
MinIO wordmark icon replaced with the project's own, image.repository and
mcImage.repository pointing at pgsty/silo, and the container command changed to
silo. User-visible titles, comments and documentation links are rebranded. The
MINIO_* environment variables and every existing values key are kept - the
first Silo chart is a rename, not a values-schema migration.

The hard problem is that a chart rename normally rewrites Kubernetes resource
identity, and a StatefulSet's selector and volumeClaimTemplate are immutable.
An existing release upgraded carelessly would either fail or orphan its PVCs.
Two things address that:

- Templates no longer derive the container name from .Chart.Name. It comes from
  a helper, so nameOverride can pin it, which means an existing release can be
  upgraded with nameOverride=minio, fullnameOverride=<existing-fullname> and
  serviceAccount.name=minio-sa and render byte-stable identity while switching
  chart and image.

- helm-migration-guard and verify-helm-migration.sh make that a gate rather
  than a documented hope. The script lints the chart, renders it in distributed
  and standalone modes plus the optional templates, then renders the legacy
  chart from a pinned commit and the new chart with those three overrides and
  compares resource identity. The guard additionally rejects any rendered
  container still pulling pgsty/minio or invoking /usr/bin/minio. It runs
  through a pinned alpine/helm image when helm is not installed locally, so the
  gate does not depend on the developer's machine. Currently green over 7
  compared resources.

Rollback is asymmetric and the README says so: the old chart with the new image
survives via the entrypoint argv shim, but the new chart with an old MinIO
image does not, because `silo server` is not a command that binary knows. Only
`helm rollback` is supported, never an image-only downgrade.

Not addressed here: the default image tag is pgsty/silo:RELEASE.2026-08-04T00-00-00Z,
which does not exist yet. The chart must not be published until the first Silo
image is pushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Feng Ruohang
2026-08-06 08:48:17 +08:00
parent 30749911bd
commit e071bb77e4
32 changed files with 909 additions and 575 deletions
+331
View File
@@ -0,0 +1,331 @@
// Copyright 2026 PGSTY contributors.
//
// 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.
// helm-migration-guard compares a rendered legacy MinIO chart with the Silo
// upgrade candidate. Product labels, images, and commands may change; resource
// identity, selectors, PVCs, storage mounts, ports, secrets, and service-account
// references must not.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"go.yaml.in/yaml/v3"
)
type resource struct {
key string
doc map[string]any
}
func main() {
if len(os.Args) != 3 {
fatal(errors.New("usage: helm-migration-guard OLD_RENDER NEW_RENDER"))
}
oldResources, err := readResources(os.Args[1])
if err != nil {
fatal(err)
}
newResources, err := readResources(os.Args[2])
if err != nil {
fatal(err)
}
if err := compare(oldResources, newResources); err != nil {
fatal(err)
}
fmt.Printf("Silo Helm migration identity is stable across %d rendered resources\n", len(oldResources))
}
func readResources(path string) (map[string]resource, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer file.Close()
resources := make(map[string]resource)
decoder := yaml.NewDecoder(file)
for document := 1; ; document++ {
var doc map[string]any
err = decoder.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("decode %s document %d: %w", path, document, err)
}
if len(doc) == 0 || text(doc["kind"]) == "" {
continue
}
metadata := object(doc["metadata"])
key := strings.Join([]string{text(doc["kind"]), text(metadata["namespace"]), text(metadata["name"])}, "/")
if _, exists := resources[key]; exists {
return nil, fmt.Errorf("%s contains duplicate resource %s", path, key)
}
resources[key] = resource{key: key, doc: doc}
}
return resources, nil
}
func compare(oldResources, newResources map[string]resource) error {
for key := range oldResources {
if _, ok := newResources[key]; !ok {
return fmt.Errorf("legacy resource would be removed or renamed: %s", key)
}
}
for key := range newResources {
if _, ok := oldResources[key]; !ok {
return fmt.Errorf("upgrade candidate unexpectedly adds a resource: %s", key)
}
}
keys := make([]string, 0, len(oldResources))
for key := range oldResources {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
oldDoc := oldResources[key].doc
newDoc := newResources[key].doc
kind := text(oldDoc["kind"])
switch kind {
case "Service":
if err := same(key, "Service selector", at(oldDoc, "spec", "selector"), at(newDoc, "spec", "selector")); err != nil {
return err
}
if err := same(key, "Service ports", at(oldDoc, "spec", "ports"), at(newDoc, "spec", "ports")); err != nil {
return err
}
case "Deployment", "StatefulSet":
if err := same(key, "workload selector", at(oldDoc, "spec", "selector"), at(newDoc, "spec", "selector")); err != nil {
return err
}
}
if kind == "StatefulSet" {
if err := same(key, "StatefulSet serviceName", at(oldDoc, "spec", "serviceName"), at(newDoc, "spec", "serviceName")); err != nil {
return err
}
if err := same(key, "volume claim templates", claimTemplates(oldDoc), claimTemplates(newDoc)); err != nil {
return err
}
}
if kind == "PersistentVolumeClaim" {
if err := same(key, "PVC specification", at(oldDoc, "spec"), at(newDoc, "spec")); err != nil {
return err
}
}
if kind == "Secret" {
if err := same(key, "Secret keys", secretKeys(oldDoc), secretKeys(newDoc)); err != nil {
return err
}
}
if kind == "Deployment" || kind == "StatefulSet" || kind == "Job" {
if err := comparePod(key, kind, oldDoc, newDoc); err != nil {
return err
}
}
}
return nil
}
func comparePod(key, kind string, oldDoc, newDoc map[string]any) error {
oldPod := object(at(oldDoc, "spec", "template", "spec"))
newPod := object(at(newDoc, "spec", "template", "spec"))
if err := same(key, "service account", oldPod["serviceAccountName"], newPod["serviceAccountName"]); err != nil {
return err
}
if err := same(key, "referenced volume sources", volumeSources(oldPod), volumeSources(newPod)); err != nil {
return err
}
oldContainers := containers(oldPod)
newContainers := containers(newPod)
if err := same(key, "container identities", sortedKeys(oldContainers), sortedKeys(newContainers)); err != nil {
return err
}
for _, name := range sortedKeys(oldContainers) {
oldContainer := oldContainers[name]
newContainer := newContainers[name]
if err := same(key, name+" ports", oldContainer["ports"], newContainer["ports"]); err != nil {
return err
}
if err := same(key, name+" environment", oldContainer["env"], newContainer["env"]); err != nil {
return err
}
if err := same(key, name+" envFrom", oldContainer["envFrom"], newContainer["envFrom"]); err != nil {
return err
}
if err := same(key, name+" storage mounts", normalizedMounts(oldContainer, oldPod), normalizedMounts(newContainer, newPod)); err != nil {
return err
}
image := text(newContainer["image"])
if strings.HasPrefix(image, "pgsty/minio:") || strings.HasPrefix(image, "docker.io/pgsty/minio:") {
return fmt.Errorf("%s container %s still uses frozen image %s", key, name, image)
}
command := commandText(newContainer)
if strings.Contains(command, "/usr/bin/minio") {
return fmt.Errorf("%s container %s still invokes /usr/bin/minio", key, name)
}
if (kind == "Deployment" || kind == "StatefulSet") && strings.Contains(image, "pgsty/silo:") {
if !strings.Contains(command, "silo") || !strings.Contains(command, "server") {
return fmt.Errorf("%s container %s does not invoke the Silo server: %q", key, name, command)
}
}
}
return nil
}
func claimTemplates(doc map[string]any) []string {
var result []string
for _, raw := range list(at(doc, "spec", "volumeClaimTemplates")) {
claim := object(raw)
metadata := object(claim["metadata"])
result = append(result, text(metadata["name"])+"="+canonical(claim["spec"]))
}
sort.Strings(result)
return result
}
func secretKeys(doc map[string]any) []string {
var result []string
for _, section := range []string{"data", "stringData"} {
for key := range object(doc[section]) {
result = append(result, section+":"+key)
}
}
sort.Strings(result)
return result
}
func volumeSources(pod map[string]any) []string {
var result []string
for _, raw := range list(pod["volumes"]) {
volume := cloneObject(object(raw))
delete(volume, "name")
result = append(result, canonical(volume))
}
sort.Strings(result)
return result
}
func volumeSourceByName(pod map[string]any) map[string]string {
result := make(map[string]string)
for _, raw := range list(pod["volumes"]) {
volume := cloneObject(object(raw))
name := text(volume["name"])
delete(volume, "name")
result[name] = canonical(volume)
}
return result
}
func normalizedMounts(container, pod map[string]any) []string {
sources := volumeSourceByName(pod)
var result []string
for _, raw := range list(container["volumeMounts"]) {
mount := cloneObject(object(raw))
name := text(mount["name"])
delete(mount, "name")
mount["source"] = sources[name]
result = append(result, canonical(mount))
}
sort.Strings(result)
return result
}
func containers(pod map[string]any) map[string]map[string]any {
result := make(map[string]map[string]any)
for _, section := range []string{"initContainers", "containers"} {
for _, raw := range list(pod[section]) {
container := object(raw)
result[section+":"+text(container["name"])] = container
}
}
return result
}
func commandText(container map[string]any) string {
var parts []string
for _, field := range []string{"command", "args"} {
for _, value := range list(container[field]) {
parts = append(parts, text(value))
}
}
return strings.Join(parts, " ")
}
func at(root map[string]any, path ...string) any {
var current any = root
for _, part := range path {
current = object(current)[part]
}
return current
}
func object(value any) map[string]any {
if value == nil {
return map[string]any{}
}
result, _ := value.(map[string]any)
return result
}
func cloneObject(value map[string]any) map[string]any {
result := make(map[string]any, len(value))
for key, item := range value {
result[key] = item
}
return result
}
func list(value any) []any {
result, _ := value.([]any)
return result
}
func text(value any) string {
result, _ := value.(string)
return result
}
func sortedKeys[T any](values map[string]T) []string {
result := make([]string, 0, len(values))
for key := range values {
result = append(result, key)
}
sort.Strings(result)
return result
}
func same(resourceKey, field string, oldValue, newValue any) error {
oldCanonical := canonical(oldValue)
newCanonical := canonical(newValue)
if oldCanonical != newCanonical {
return fmt.Errorf("%s changes %s\nold: %s\nnew: %s", resourceKey, field, oldCanonical, newCanonical)
}
return nil
}
func canonical(value any) string {
data, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("<unmarshalable %T: %v>", value, err)
}
return string(data)
}
func fatal(err error) {
fmt.Fprintf(os.Stderr, "Silo Helm migration check failed: %v\n", err)
os.Exit(1)
}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(cd "${script_dir}/.." && pwd)"
baseline_commit="${HELM_LEGACY_COMMIT:-d88f46cce}"
helm_image="${HELM_IMAGE:-alpine/helm:3.18.6@sha256:c6d8088ddb279625a2e1ca3b08b22c18c946d1f65c8b810f28f1597435a1134c}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/silo-helm.XXXXXX")"
cleanup() {
rm -rf "${work_dir}"
}
trap cleanup EXIT
cd "${repo_dir}"
git cat-file -e "${baseline_commit}^{commit}"
git archive "${baseline_commit}" helm/minio | tar -x -C "${work_dir}"
if command -v helm >/dev/null 2>&1; then
new_chart="${repo_dir}/helm/silo"
old_chart="${work_dir}/helm/minio"
output_dir="${work_dir}"
helm_run() {
helm "$@"
}
else
command -v docker >/dev/null 2>&1 || {
echo "helm or docker is required" >&2
exit 1
}
new_chart=/repo/helm/silo
old_chart=/check/helm/minio
output_dir=/check
helm_run() {
docker run --rm \
-v "${repo_dir}:/repo:ro" \
-v "${work_dir}:/check" \
"${helm_image}" "$@"
}
fi
helm_run lint "${new_chart}"
helm_run template silo "${new_chart}" \
--namespace silo \
--set rootUser=silo-admin \
--set rootPassword=test-password-123456 >/dev/null
helm_run template silo "${new_chart}" \
--namespace silo \
--set mode=standalone \
--set replicas=1 \
--set persistence.enabled=false \
--set rootUser=silo-admin \
--set rootPassword=test-password-123456 >/dev/null
# Exercise optional templates that the default render leaves dormant.
helm_run template silo-all "${new_chart}" \
--namespace silo \
--set rootUser=silo-admin \
--set rootPassword=test-password-123456 \
--set tls.enabled=true \
--set tls.certSecret=silo-tls \
--set trustedCertsSecret=silo-trusted-ca \
--set ingress.enabled=true \
--set consoleIngress.enabled=true \
--set networkPolicy.enabled=true \
--set podDisruptionBudget.enabled=true \
--set metrics.serviceMonitor.enabled=true \
--set metrics.serviceMonitor.includeNode=true \
--set 'buckets[0].name=chart-test' \
--set 'buckets[0].policy=none' \
--set 'buckets[0].purge=false' >/dev/null
# Existing values commonly address the historical myminio target. Render the
# custom-command path explicitly so both the new and compatibility aliases are
# protected by the release gate rather than only by a source-text assertion.
custom_render="${work_dir}/custom-command.yaml"
helm_run template silo-custom "${new_chart}" \
--namespace silo \
--set rootUser=silo-admin \
--set rootPassword=test-password-123456 \
--set-string 'customCommands[0].command=admin info myminio' \
--show-only templates/configmap.yaml >"${custom_render}"
for expected in \
'alias set mysilo' \
'alias set myminio' \
'runCommand admin info myminio'; do
grep -F -- "${expected}" "${custom_render}" >/dev/null || {
echo "rendered custom command is missing: ${expected}" >&2
exit 1
}
done
old_render="${work_dir}/legacy.yaml"
new_render="${work_dir}/candidate.yaml"
helm_run template my-release "${old_chart}" \
--namespace my-namespace \
--set rootUser=legacy-admin \
--set rootPassword=legacy-password-123456 >"${old_render}"
helm_run template my-release "${new_chart}" \
--namespace my-namespace \
-f "${old_chart}/values.yaml" \
--set rootUser=legacy-admin \
--set rootPassword=legacy-password-123456 \
--set nameOverride=minio \
--set fullnameOverride=my-release-minio \
--set serviceAccount.name=minio-sa \
--set image.repository=pgsty/silo \
--set mcImage.repository=pgsty/silo \
--set-string image.tag=RELEASE.2026-08-04T00-00-00Z \
--set-string mcImage.tag=RELEASE.2026-08-04T00-00-00Z >"${new_render}"
go run ./buildscripts/helm-migration-guard "${old_render}" "${new_render}"
helm_run package "${new_chart}" --destination "${output_dir}" >/dev/null
test -s "${work_dir}/silo-7.0.0.tgz"
if find "${work_dir}" -maxdepth 1 -type f -name 'minio-*.tgz' | grep -q .; then
echo "Helm packaging emitted a legacy MinIO chart name" >&2
exit 1
fi
echo "Silo Helm lint, render, legacy-upgrade, and package checks passed"