From 012e283fd16da28b80930317c70fece8b719daa8 Mon Sep 17 00:00:00 2001 From: bugkeep <1921817430@qq.com> Date: Thu, 23 Jul 2026 10:52:20 +0800 Subject: [PATCH] fix: make implicit image pull policy opt-in --- conf/app.conf | 4 + store/helm.go | 3 + store/image_pull_policy.go | 223 ++++++++++++++++++++++++++++++++ store/image_pull_policy_test.go | 71 ++++++++++ 4 files changed, 301 insertions(+) create mode 100644 store/image_pull_policy.go create mode 100644 store/image_pull_policy_test.go diff --git a/conf/app.conf b/conf/app.conf index a88583e9..d1461906 100644 --- a/conf/app.conf +++ b/conf/app.conf @@ -19,6 +19,10 @@ casdoorApplication = app-casibase ; -- Outbound proxy (optional) ---- socks5Proxy = 127.0.0.1:10808 +; -- Helm images (optional) ------------------------------------------------- +; Set true only when implicit/latest chart images must use IfNotPresent. +helmImplicitLatestPullPolicy = false + ; -- Control plane ---------------------------------------------------------- apiserverPort = 6443 apiserverBind = 127.0.0.1 diff --git a/store/helm.go b/store/helm.go index 88c5d1d5..d8c67e8e 100644 --- a/store/helm.go +++ b/store/helm.go @@ -1214,6 +1214,7 @@ func InstallHelmChart(cfg *rest.Config, releaseName, namespace, chartName, repoU install.CreateNamespace = true install.Wait = true install.Timeout = helmInstallTimeout + install.PostRenderer = configuredLocalImagePullPolicyPostRenderer() _, err = install.Run(ch, vals) if err != nil { @@ -1305,6 +1306,7 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle, install.CreateNamespace = true install.Wait = true install.Timeout = helmInstallTimeout + install.PostRenderer = configuredLocalImagePullPolicyPostRenderer() if _, err = install.RunWithContext(installCtx, helmChart, vals); err != nil { for _, line := range helmReleaseDiagnostics(installCtx, cfg, releaseName, namespace) { send(line) @@ -1350,6 +1352,7 @@ func UpgradeHelmRelease(cfg *rest.Config, releaseName, namespace, chartName, rep upgrade.Namespace = namespace upgrade.Wait = true upgrade.Timeout = helmOperationTimeout + upgrade.PostRenderer = configuredLocalImagePullPolicyPostRenderer() _, err = upgrade.Run(releaseName, ch, vals) return err diff --git a/store/image_pull_policy.go b/store/image_pull_policy.go new file mode 100644 index 00000000..bf6b05c7 --- /dev/null +++ b/store/image_pull_policy.go @@ -0,0 +1,223 @@ +package store + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" + + "github.com/casosorg/casos/conf" + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/postrender" +) + +var _ postrender.PostRenderer = localImagePullPolicyPostRenderer{} + +const helmImplicitLatestPullPolicyConfigKey = "helmImplicitLatestPullPolicy" + +func configuredLocalImagePullPolicyPostRenderer() postrender.PostRenderer { + // This changes Kubernetes' implicit latest-tag behavior, so installations + // opt in explicitly instead of changing existing chart defaults globally. + if !conf.GetConfigBoolDefault(helmImplicitLatestPullPolicyConfigKey, false) { + return nil + } + return localImagePullPolicyPostRenderer{} +} + +// localImagePullPolicyPostRenderer only fills an omitted policy for images +// whose Kubernetes default would otherwise be Always. Explicit chart values +// remain untouched so chart authors retain control over update semantics. +type localImagePullPolicyPostRenderer struct{} + +func (localImagePullPolicyPostRenderer) Run(rendered *bytes.Buffer) (*bytes.Buffer, error) { + if rendered == nil { + return bytes.NewBuffer(nil), nil + } + documents, err := helmManifestDocuments(rendered.Bytes()) + if err != nil { + return nil, err + } + var output bytes.Buffer + for _, raw := range documents { + var document yaml.Node + if err := yaml.Unmarshal(raw, &document); err != nil { + return nil, fmt.Errorf("decode Helm manifest: %w", err) + } + if !patchPullPolicies(&document) { + output.Write(raw) + continue + } + if separator := helmManifestLeadingDocumentSeparator(raw); len(separator) > 0 { + output.Write(separator) + } + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return nil, fmt.Errorf("encode Helm manifest: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("finish Helm manifest encoding: %w", err) + } + } + return &output, nil +} + +func helmManifestLeadingDocumentSeparator(document []byte) []byte { + lineEnd := bytes.IndexByte(document, '\n') + if lineEnd < 0 { + lineEnd = len(document) + } else { + lineEnd++ + } + firstLine := document[:lineEnd] + if !isHelmManifestDocumentSeparator(firstLine) { + return nil + } + return firstLine +} + +func helmManifestDocuments(rendered []byte) ([][]byte, error) { + reader := bufio.NewReader(bytes.NewReader(rendered)) + documents := make([][]byte, 0, 1) + var current bytes.Buffer + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + if isHelmManifestDocumentSeparator(line) && current.Len() > 0 { + documents = append(documents, append([]byte(nil), current.Bytes()...)) + current.Reset() + } + current.Write(line) + } + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("read Helm manifest: %w", err) + } + } + if current.Len() > 0 { + documents = append(documents, current.Bytes()) + } + return documents, nil +} + +func isHelmManifestDocumentSeparator(line []byte) bool { + line = bytes.TrimRight(line, "\r\n") + if !bytes.HasPrefix(line, []byte("---")) { + return false + } + return len(line) == 3 || line[3] == ' ' || line[3] == '\t' || line[3] == '#' +} + +func patchPullPolicies(document *yaml.Node) bool { + root := yamlDocumentRoot(document) + if root == nil || root.Kind != yaml.MappingNode { + return false + } + if scalarValue(mappingValue(root, "kind")) == "List" { + items := mappingValue(root, "items") + if items == nil || items.Kind != yaml.SequenceNode { + return false + } + changed := false + for _, item := range items.Content { + changed = patchPullPolicies(item) || changed + } + return changed + } + + var podSpec *yaml.Node + switch scalarValue(mappingValue(root, "kind")) { + case "Pod": + podSpec = nestedMapping(root, "spec") + case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "ReplicationController", "Job": + podSpec = nestedMapping(root, "spec", "template", "spec") + case "CronJob": + podSpec = nestedMapping(root, "spec", "jobTemplate", "spec", "template", "spec") + default: + return false + } + if podSpec == nil { + return false + } + changed := patchContainerList(podSpec, "initContainers") + changed = patchContainerList(podSpec, "containers") || changed + return patchContainerList(podSpec, "ephemeralContainers") || changed +} + +func yamlDocumentRoot(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + return nil + } + return node.Content[0] + } + return node +} + +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(mapping.Content); index += 2 { + if mapping.Content[index].Kind == yaml.ScalarNode && mapping.Content[index].Value == key { + return mapping.Content[index+1] + } + } + return nil +} + +func nestedMapping(node *yaml.Node, path ...string) *yaml.Node { + current := node + for _, key := range path { + current = mappingValue(current, key) + if current == nil || current.Kind != yaml.MappingNode { + return nil + } + } + return current +} + +func scalarValue(node *yaml.Node) string { + if node == nil || node.Kind != yaml.ScalarNode { + return "" + } + return node.Value +} + +func patchContainerList(podSpec *yaml.Node, key string) bool { + containers := mappingValue(podSpec, key) + if containers == nil || containers.Kind != yaml.SequenceNode { + return false + } + changed := false + for _, container := range containers.Content { + if container.Kind != yaml.MappingNode || mappingValue(container, "imagePullPolicy") != nil { + continue + } + image := scalarValue(mappingValue(container, "image")) + if image == "" || !usesImplicitLatest(image) { + continue + } + container.Content = append(container.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "imagePullPolicy"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "IfNotPresent"}, + ) + changed = true + } + return changed +} + +func usesImplicitLatest(image string) bool { + if strings.Contains(image, "@") { + return false + } + lastSlash := strings.LastIndex(image, "/") + lastColon := strings.LastIndex(image, ":") + return lastColon <= lastSlash || image[lastColon+1:] == "latest" +} diff --git a/store/image_pull_policy_test.go b/store/image_pull_policy_test.go new file mode 100644 index 00000000..72c08a80 --- /dev/null +++ b/store/image_pull_policy_test.go @@ -0,0 +1,71 @@ +package store + +import ( + "bytes" + "io" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLocalImagePullPolicyPostRendererPreservesMultiDocumentBoundaries(t *testing.T) { + rendered := bytes.NewBufferString(`--- +# Source: chart/templates/first.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: first +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.0 +--- +# Source: chart/templates/second.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: second +spec: + template: + spec: + containers: + - name: busybox + image: busybox +`) + + output, err := (localImagePullPolicyPostRenderer{}).Run(rendered) + if err != nil { + t.Fatalf("render manifest: %v", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(output.Bytes())) + documents := make([]yaml.Node, 0, 2) + for { + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + if err == io.EOF { + break + } + t.Fatalf("decode rendered manifest: %v\n%s", err, output.String()) + } + if yamlDocumentRoot(&document) != nil { + documents = append(documents, document) + } + } + if len(documents) != 2 { + t.Fatalf("decoded %d documents, want 2\n%s", len(documents), output.String()) + } + + firstPodSpec := nestedMapping(yamlDocumentRoot(&documents[0]), "spec", "template", "spec") + firstContainer := mappingValue(firstPodSpec, "containers").Content[0] + if policy := mappingValue(firstContainer, "imagePullPolicy"); policy != nil { + t.Fatalf("unchanged document gained imagePullPolicy %q", scalarValue(policy)) + } + secondPodSpec := nestedMapping(yamlDocumentRoot(&documents[1]), "spec", "template", "spec") + secondContainer := mappingValue(secondPodSpec, "containers").Content[0] + if policy := scalarValue(mappingValue(secondContainer, "imagePullPolicy")); policy != "IfNotPresent" { + t.Fatalf("second document imagePullPolicy = %q, want IfNotPresent", policy) + } +}