package handler
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
rbdmodel "github.com/goodrain/rainbond/api/model"
"github.com/goodrain/rainbond/db"
dbmodel "github.com/goodrain/rainbond/db/model"
"github.com/goodrain/rainbond/pkg/component/k8s"
"github.com/goodrain/rainbond/pkg/helm"
"github.com/goodrain/rainbond/util/constants"
httputil "github.com/goodrain/rainbond/util/http"
"helm.sh/helm/v3/pkg/chart"
helmrelease "helm.sh/helm/v3/pkg/release"
k8sapierrors "k8s.io/apimachinery/pkg/api/errors"
k8sapimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/yaml"
)
type HelmReleaseHandler struct{}
const (
HelmReleaseSourceStore = "store"
HelmReleaseSourceRepo = "repo"
HelmReleaseSourceOCI = "oci"
HelmReleaseSourceUpload = "upload"
)
type HelmReleaseInstallRequest struct {
SourceType string `json:"source_type"`
Namespace string `json:"namespace"`
RepoName string `json:"repo_name"`
RepoURL string `json:"repo_url"`
Chart string `json:"chart"`
ChartName string `json:"chart_name"`
ChartURL string `json:"chart_url"`
Version string `json:"version"`
ReleaseName string `json:"release_name"`
Values string `json:"values"`
Username string `json:"username"`
Password string `json:"password"`
EventID string `json:"event_id"`
AllowChartReplace bool `json:"allow_chart_replace"`
}
type HelmReleaseChartPreview struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Icon string `json:"icon"`
Keywords []string `json:"keywords"`
AppVersion string `json:"app_version"`
Values map[string]string `json:"values"`
Readme string `json:"readme"`
}
type HelmReleaseSummary struct {
Name string `json:"name"`
Chart string `json:"chart"`
ChartVersion string `json:"chart_version"`
AppVersion string `json:"app_version"`
Status string `json:"status"`
Version int `json:"version"`
Namespace string `json:"namespace"`
Updated string `json:"updated"`
}
type HelmReleaseHistoryItem struct {
Revision int `json:"revision"`
Chart string `json:"chart"`
ChartVersion string `json:"chart_version"`
AppVersion string `json:"app_version"`
Status string `json:"status"`
Description string `json:"description"`
Updated string `json:"updated"`
}
type HelmReleaseDetailSummary struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Status string `json:"status"`
Chart string `json:"chart"`
ChartVersion string `json:"chart_version"`
AppVersion string `json:"app_version"`
Revision int `json:"revision"`
Description string `json:"description"`
Updated string `json:"updated"`
Values string `json:"values"`
}
type HelmReleaseDetail struct {
Summary *HelmReleaseDetailSummary `json:"summary"`
Workloads []NsResourceInfo `json:"workloads"`
Services []NsResourceInfo `json:"services"`
Others []NsResourceInfo `json:"others"`
History []*HelmReleaseHistoryItem `json:"history"`
}
type HelmReleaseRollbackRequest struct {
Revision int `json:"revision"`
}
var helmReleaseResourceTargets = []schema.GroupVersionResource{
{Group: "apps", Version: "v1", Resource: "deployments"},
{Group: "apps", Version: "v1", Resource: "statefulsets"},
{Group: "apps", Version: "v1", Resource: "daemonsets"},
{Group: "batch", Version: "v1", Resource: "jobs"},
{Group: "batch", Version: "v1", Resource: "cronjobs"},
{Group: "", Version: "v1", Resource: "services"},
{Group: "", Version: "v1", Resource: "configmaps"},
{Group: "", Version: "v1", Resource: "secrets"},
{Group: "", Version: "v1", Resource: "serviceaccounts"},
{Group: "", Version: "v1", Resource: "persistentvolumeclaims"},
{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "roles"},
{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"},
{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"},
{Group: "autoscaling", Version: "v2", Resource: "horizontalpodautoscalers"},
{Group: "gateway.networking.k8s.io", Version: "v1beta1", Resource: "gateways"},
{Group: "gateway.networking.k8s.io", Version: "v1beta1", Resource: "httproutes"},
{Group: "rollouts.kruise.io", Version: "v1alpha1", Resource: "rollouts"},
}
func (r *HelmReleaseInstallRequest) Normalize() {
r.SourceType = strings.TrimSpace(r.SourceType)
if r.SourceType == "" {
r.SourceType = HelmReleaseSourceStore
}
r.Namespace = strings.TrimSpace(r.Namespace)
r.Chart = strings.TrimSpace(firstNonEmpty(r.Chart, r.ChartName))
r.ChartName = strings.TrimSpace(firstNonEmpty(r.ChartName, r.Chart))
r.ChartURL = strings.TrimSpace(r.ChartURL)
r.RepoName = strings.TrimSpace(r.RepoName)
r.RepoURL = strings.TrimSpace(r.RepoURL)
r.ReleaseName = strings.TrimSpace(r.ReleaseName)
r.EventID = strings.TrimSpace(r.EventID)
}
func (r *HelmReleaseInstallRequest) Validate() error {
if r.ReleaseName == "" {
return fmt.Errorf("release_name is required")
}
switch r.SourceType {
case HelmReleaseSourceStore:
if r.RepoName == "" {
return fmt.Errorf("repo_name is required for store source")
}
if r.Chart == "" {
return fmt.Errorf("chart is required for store source")
}
case HelmReleaseSourceRepo:
hasRepoChart := r.RepoURL != "" && r.ChartName != ""
hasDirectChartURL := r.ChartURL != ""
if !hasRepoChart && !hasDirectChartURL {
return fmt.Errorf("repo source requires repo_url and chart_name, or chart_url")
}
case HelmReleaseSourceOCI:
if !strings.HasPrefix(r.ChartURL, "oci://") {
return fmt.Errorf("oci source requires chart_url with oci:// prefix")
}
case HelmReleaseSourceUpload:
if r.EventID == "" {
return fmt.Errorf("event_id is required for upload source")
}
default:
return fmt.Errorf("unsupported source_type %q", r.SourceType)
}
return nil
}
func (r *HelmReleaseInstallRequest) ValidateForPreview() error {
switch r.SourceType {
case HelmReleaseSourceStore:
if r.RepoName == "" {
return fmt.Errorf("repo_name is required for store source")
}
if r.Chart == "" && r.ChartName == "" {
return fmt.Errorf("chart is required for store source")
}
case HelmReleaseSourceRepo:
if strings.TrimSpace(firstNonEmpty(r.ChartURL, r.ChartName)) == "" {
return fmt.Errorf("chart_url is required for repo source")
}
case HelmReleaseSourceOCI:
if !strings.HasPrefix(strings.TrimSpace(r.ChartURL), "oci://") {
return fmt.Errorf("oci source requires chart_url with oci:// prefix")
}
case HelmReleaseSourceUpload:
if r.EventID == "" {
return fmt.Errorf("event_id is required for upload source")
}
default:
return fmt.Errorf("unsupported source_type %q", r.SourceType)
}
return nil
}
func (r *HelmReleaseRollbackRequest) Validate() error {
if r.Revision <= 0 {
return fmt.Errorf("revision must be greater than 0")
}
return nil
}
func (h *HelmReleaseHandler) resolveNamespace(tenantName, namespace string) (string, error) {
if strings.TrimSpace(namespace) != "" {
return strings.TrimSpace(namespace), nil
}
tenant, err := db.GetManager().TenantDao().GetTenantIDByName(tenantName)
if err != nil {
return "", fmt.Errorf("tenant %s not found: %v", tenantName, err)
}
return helmReleaseNamespace(tenant), nil
}
func (h *HelmReleaseHandler) newHelm(tenantName, namespace string) (*helm.Helm, error) {
resolvedNamespace, err := h.resolveNamespace(tenantName, namespace)
if err != nil {
return nil, err
}
return helm.NewHelm(resolvedNamespace, repoFile, repoCache)
}
func (h *HelmReleaseHandler) ListReleases(tenantName, namespace string) ([]*HelmReleaseSummary, error) {
hc, err := h.newHelm(tenantName, namespace)
if err != nil {
return nil, err
}
releases, err := hc.ListReleases()
if err != nil {
return nil, err
}
summaries := make([]*HelmReleaseSummary, 0, len(releases))
for _, release := range releases {
summaries = append(summaries, summarizeHelmRelease(release))
}
return summaries, nil
}
func (h *HelmReleaseHandler) GetReleaseHistory(tenantName, releaseName, namespace string) ([]*HelmReleaseHistoryItem, error) {
hc, err := h.newHelm(tenantName, namespace)
if err != nil {
return nil, err
}
history, err := hc.History(releaseName)
if err != nil {
return nil, err
}
return summarizeHelmReleaseHistory(history), nil
}
func (h *HelmReleaseHandler) GetReleaseDetail(tenantName, releaseName, namespace string) (*HelmReleaseDetail, error) {
hc, err := h.newHelm(tenantName, namespace)
if err != nil {
return nil, err
}
release, err := hc.Status(releaseName)
if err != nil {
return nil, err
}
history, err := hc.History(releaseName)
if err != nil {
return nil, err
}
resources, err := h.listReleaseResources(tenantName, releaseName, release.Namespace)
if err != nil {
return nil, err
}
workloads, services, others := splitHelmReleaseResources(resources)
return &HelmReleaseDetail{
Summary: summarizeHelmReleaseDetail(release),
Workloads: workloads,
Services: services,
Others: others,
History: summarizeHelmReleaseHistory(history),
}, nil
}
func (h *HelmReleaseHandler) InstallRelease(tenantName string, req HelmReleaseInstallRequest) (*helmrelease.Release, error) {
req.Normalize()
if err := req.Validate(); err != nil {
return nil, err
}
hc, err := h.newHelm(tenantName, req.Namespace)
if err != nil {
return nil, err
}
switch req.SourceType {
case HelmReleaseSourceStore:
return hc.InstallFromRepo(req.RepoName, req.Chart, req.Version, req.ReleaseName, req.Values)
case HelmReleaseSourceRepo:
chartRef := firstNonEmpty(req.ChartURL, req.ChartName)
return hc.InstallFromReference(chartRef, req.RepoURL, req.Version, req.ReleaseName, req.Values, req.Username, req.Password)
case HelmReleaseSourceOCI:
return hc.InstallFromReference(req.ChartURL, "", req.Version, req.ReleaseName, req.Values, req.Username, req.Password)
case HelmReleaseSourceUpload:
chartPath, chartVersion, err := GetUploadChartPathAndVersion(req.EventID)
if err != nil {
return nil, err
}
version := req.Version
if version == "" {
version = chartVersion
}
return hc.InstallFromChartPath(chartPath, version, req.ReleaseName, req.Values)
default:
return nil, fmt.Errorf("unsupported source_type %q", req.SourceType)
}
}
func (h *HelmReleaseHandler) PreviewChart(tenantName string, req HelmReleaseInstallRequest) (*HelmReleaseChartPreview, error) {
req.Normalize()
if err := req.ValidateForPreview(); err != nil {
return nil, err
}
hc, err := h.newHelm(tenantName, req.Namespace)
if err != nil {
return nil, err
}
ch, chartPath, version, err := h.loadTargetChart(hc, req)
if err != nil {
return nil, wrapHelmChartPreviewSourceError(err)
}
values, readme, err := readChartPreviewFiles(chartPath)
if err != nil {
return nil, wrapHelmChartPreviewSourceError(err)
}
if version == "" && ch != nil && ch.Metadata != nil {
version = ch.Metadata.Version
}
preview := &HelmReleaseChartPreview{
Version: version,
Values: values,
Readme: readme,
}
if ch != nil && ch.Metadata != nil {
preview.Name = ch.Metadata.Name
preview.Description = ch.Metadata.Description
preview.Icon = ch.Metadata.Icon
preview.Keywords = ch.Metadata.Keywords
preview.AppVersion = ch.Metadata.AppVersion
}
return preview, nil
}
func (h *HelmReleaseHandler) UpgradeRelease(tenantName, releaseName string, req HelmReleaseInstallRequest) (*helmrelease.Release, error) {
req.Normalize()
req.ReleaseName = releaseName
if err := req.Validate(); err != nil {
return nil, err
}
hc, err := h.newHelm(tenantName, req.Namespace)
if err != nil {
return nil, err
}
currentRelease, err := hc.Status(releaseName)
if err != nil {
return nil, err
}
targetChart, chartPath, version, err := h.loadTargetChart(hc, req)
if err != nil {
return nil, err
}
if err := validateUpgradeChartName(currentRelease, targetChart, req.AllowChartReplace); err != nil {
return nil, err
}
return hc.UpgradeFromChartPath(chartPath, version, releaseName, req.Values)
}
func (h *HelmReleaseHandler) RollbackRelease(tenantName, releaseName, namespace string, revision int) error {
hc, err := h.newHelm(tenantName, namespace)
if err != nil {
return err
}
return hc.Rollback(releaseName, revision)
}
func (h *HelmReleaseHandler) UninstallRelease(tenantName, releaseName, namespace string) error {
hc, err := h.newHelm(tenantName, namespace)
if err != nil {
return err
}
return hc.Uninstall(releaseName)
}
var helmReleaseHandler *HelmReleaseHandler
func GetHelmReleaseHandler() *HelmReleaseHandler {
if helmReleaseHandler == nil {
helmReleaseHandler = &HelmReleaseHandler{}
}
return helmReleaseHandler
}
func summarizeHelmRelease(release *helmrelease.Release) *HelmReleaseSummary {
summary := &HelmReleaseSummary{}
if release == nil {
return summary
}
summary.Name = release.Name
summary.Version = release.Version
summary.Namespace = release.Namespace
if release.Chart != nil && release.Chart.Metadata != nil {
summary.Chart = release.Chart.Metadata.Name
summary.ChartVersion = release.Chart.Metadata.Version
summary.AppVersion = release.Chart.Metadata.AppVersion
}
if release.Info != nil {
summary.Status = release.Info.Status.String()
if !release.Info.LastDeployed.Time.IsZero() {
summary.Updated = release.Info.LastDeployed.Time.UTC().Format(time.RFC3339)
}
}
return summary
}
func summarizeHelmReleaseDetail(release *helmrelease.Release) *HelmReleaseDetailSummary {
summary := &HelmReleaseDetailSummary{}
if release == nil {
return summary
}
summary.Name = release.Name
summary.Namespace = release.Namespace
summary.Revision = release.Version
summary.Values = marshalHelmReleaseValues(release.Config)
if release.Chart != nil && release.Chart.Metadata != nil {
summary.Chart = release.Chart.Metadata.Name
summary.ChartVersion = release.Chart.Metadata.Version
summary.AppVersion = release.Chart.Metadata.AppVersion
}
if release.Info != nil {
summary.Status = release.Info.Status.String()
summary.Description = release.Info.Description
if !release.Info.LastDeployed.Time.IsZero() {
summary.Updated = release.Info.LastDeployed.Time.UTC().Format(time.RFC3339)
}
}
return summary
}
func summarizeHelmReleaseHistory(history helm.ReleaseHistory) []*HelmReleaseHistoryItem {
items := make([]*HelmReleaseHistoryItem, 0, len(history))
for _, item := range history {
chartName := item.ChartName
chartVersion := item.ChartVersion
if chartName == "" || chartVersion == "" {
fallbackChartName, fallbackChartVersion := splitHelmChartLabel(item.Chart)
if chartName == "" {
chartName = fallbackChartName
}
if chartVersion == "" {
chartVersion = fallbackChartVersion
}
}
items = append(items, &HelmReleaseHistoryItem{
Revision: item.Revision,
Chart: chartName,
ChartVersion: chartVersion,
AppVersion: item.AppVersion,
Status: item.Status,
Description: item.Description,
Updated: formatHelmReleaseTime(item.Updated.Time),
})
}
return items
}
func marshalHelmReleaseValues(values map[string]interface{}) string {
if len(values) == 0 {
return ""
}
data, err := yaml.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func isHelmReleaseResource(labels map[string]string, releaseName string) bool {
if len(labels) == 0 || strings.TrimSpace(releaseName) == "" {
return false
}
return labels[constants.ResourceManagedByLabel] == "Helm" &&
labels[constants.ResourceInstanceLabel] == releaseName
}
func splitHelmReleaseResources(resources []NsResourceInfo) ([]NsResourceInfo, []NsResourceInfo, []NsResourceInfo) {
workloads := make([]NsResourceInfo, 0)
services := make([]NsResourceInfo, 0)
others := make([]NsResourceInfo, 0)
for _, resource := range resources {
switch resource.Kind {
case rbdmodel.Deployment, rbdmodel.StateFulSet, "DaemonSet", rbdmodel.Job, rbdmodel.CronJob, rbdmodel.Rollout:
workloads = append(workloads, resource)
case rbdmodel.Service:
services = append(services, resource)
default:
others = append(others, resource)
}
}
return workloads, services, others
}
func (h *HelmReleaseHandler) listReleaseResources(tenantName, releaseName, namespace string) ([]NsResourceInfo, error) {
ns := strings.TrimSpace(namespace)
if ns == "" {
resolvedNamespace, err := h.resolveNamespace(tenantName, namespace)
if err != nil {
return nil, err
}
ns = resolvedNamespace
}
resources := make([]NsResourceInfo, 0, 16)
for _, gvr := range helmReleaseResourceTargets {
list, err := k8s.Default().DynamicClient.Resource(gvr).Namespace(ns).List(context.Background(), metav1.ListOptions{})
if err != nil {
if k8sapimeta.IsNoMatchError(err) || k8sapierrors.IsNotFound(err) {
continue
}
return nil, err
}
for _, item := range list.Items {
if !isHelmReleaseResource(item.GetLabels(), releaseName) {
continue
}
resources = append(resources, toNsResourceInfo(item))
}
}
sort.Slice(resources, func(i, j int) bool {
if resources[i].Kind == resources[j].Kind {
return resources[i].Name < resources[j].Name
}
return resources[i].Kind < resources[j].Kind
})
return resources, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func helmReleaseNamespace(tenant *dbmodel.Tenants) string {
if tenant == nil {
return ""
}
if strings.TrimSpace(tenant.Namespace) != "" {
return tenant.Namespace
}
return tenant.UUID
}
func formatHelmReleaseTime(ts time.Time) string {
if ts.IsZero() {
return ""
}
return ts.UTC().Format(time.RFC3339)
}
func splitHelmChartLabel(chart string) (string, string) {
value := strings.TrimSpace(chart)
if value == "" {
return "", ""
}
for index := len(value) - 1; index >= 0; index-- {
if value[index] != '-' {
continue
}
if index+1 >= len(value) || (value[index+1] < '0' || value[index+1] > '9') {
continue
}
return value[:index], value[index+1:]
}
return value, ""
}
func validateUpgradeChartName(currentRelease *helmrelease.Release, targetChart *chart.Chart, allowChartReplace bool) error {
if currentRelease == nil || currentRelease.Chart == nil || currentRelease.Chart.Metadata == nil {
return nil
}
if targetChart == nil || targetChart.Metadata == nil {
return nil
}
currentName := strings.TrimSpace(currentRelease.Chart.Metadata.Name)
targetName := strings.TrimSpace(targetChart.Metadata.Name)
if currentName == "" || targetName == "" {
return nil
}
if currentName != targetName {
if allowChartReplace {
return nil
}
return fmt.Errorf("upgrade chart name %q does not match current release chart %q", targetName, currentName)
}
return nil
}
func (h *HelmReleaseHandler) loadTargetChart(hc *helm.Helm, req HelmReleaseInstallRequest) (*chart.Chart, string, string, error) {
switch req.SourceType {
case HelmReleaseSourceStore:
chartRef := fmt.Sprintf("%s/%s", req.RepoName, req.Chart)
return hc.LoadChartFromReference(chartRef, "", req.Version, "", "")
case HelmReleaseSourceRepo:
chartRef := firstNonEmpty(req.ChartURL, req.ChartName)
return hc.LoadChartFromReference(chartRef, req.RepoURL, req.Version, req.Username, req.Password)
case HelmReleaseSourceOCI:
return hc.LoadChartFromReference(req.ChartURL, "", req.Version, req.Username, req.Password)
case HelmReleaseSourceUpload:
chartPath, version, err := GetUploadChartPathAndVersion(req.EventID)
if err != nil {
return nil, "", "", err
}
ch, err := hc.LoadChartFromPath(chartPath)
return ch, chartPath, version, err
default:
return nil, "", "", fmt.Errorf("unsupported source_type %q", req.SourceType)
}
}
func wrapHelmChartPreviewSourceError(err error) error {
if err == nil {
return nil
}
if _, ok := err.(httputil.ErrBadRequest); ok {
return err
}
return httputil.NewErrBadRequest(err)
}
func readChartPreviewFiles(chartPath string) (map[string]string, string, error) {
stat, err := os.Stat(chartPath)
if err != nil {
return nil, "", err
}
if stat.IsDir() {
return readChartPreviewFilesFromDir(chartPath)
}
return readChartPreviewFilesFromArchive(chartPath)
}
func readChartPreviewFilesFromDir(chartPath string) (map[string]string, string, error) {
values := make(map[string]string)
readme := ""
err := filepath.Walk(chartPath, func(currentPath string, info os.FileInfo, walkErr error) error {
if walkErr != nil || info == nil || info.IsDir() {
return walkErr
}
relPath, err := filepath.Rel(chartPath, currentPath)
if err != nil {
return err
}
content, err := os.ReadFile(currentPath)
if err != nil {
return err
}
if strings.HasSuffix(relPath, "README.md") && readme == "" {
readme = base64.StdEncoding.EncodeToString(content)
}
if strings.HasSuffix(relPath, "values.yaml") {
values[filepath.ToSlash(relPath)] = base64.StdEncoding.EncodeToString(content)
}
return nil
})
return values, readme, err
}
func readChartPreviewFilesFromArchive(chartPath string) (map[string]string, string, error) {
file, err := os.Open(chartPath)
if err != nil {
return nil, "", err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return nil, "", err
}
defer gzr.Close()
values := make(map[string]string)
readme := ""
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
if header.FileInfo().IsDir() {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
return nil, "", err
}
if strings.HasSuffix(header.Name, "README.md") && readme == "" {
readme = base64.StdEncoding.EncodeToString(content)
}
if strings.HasSuffix(header.Name, "values.yaml") {
values[header.Name] = base64.StdEncoding.EncodeToString(content)
}
}
return values, readme, nil
}