package scheduler_k3s import ( "bytes" "context" "errors" "fmt" "log" "os" "path/filepath" "sort" "strings" "time" "unicode" "github.com/dokku/dokku/plugins/common" "github.com/fluxcd/pkg/kustomize/filesys" "github.com/gofrs/flock" "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/kube" "helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/storage/driver" "sigs.k8s.io/kustomize/api/konfig" "sigs.k8s.io/kustomize/api/krusty" "sigs.k8s.io/kustomize/api/types" ) var DevNullPrinter = func(format string, v ...interface{}) {} var DeployLogPrinter = func(format string, v ...interface{}) { message := strings.TrimSpace(fmt.Sprintf(format, v...)) if message == "" { return } r := []rune(message) r[0] = unicode.ToUpper(r[0]) s := string(r) if strings.HasPrefix(s, "Beginning wait") { common.LogExclaim(s) } else if strings.HasPrefix(s, "Warning:") { common.LogExclaim(s) } else { common.LogVerboseQuiet(s) } } // ChartInput is the input for the InstallOrUpgradeChart function type ChartInput struct { // ChartPath is the path to the chart to install or upgrade ChartPath string // KustomizeRootPath is the path to the kustomize root path to use KustomizeRootPath string // Namespace is the namespace to install or upgrade the chart in Namespace string // ReleaseName is the name of the release to install or upgrade ReleaseName string // RepoURL is the URL of the chart repository to install or upgrade the chart from RepoURL string // RollbackOnFailure is whether to rollback on failure RollbackOnFailure bool // Timeout is the timeout for the install or upgrade Timeout time.Duration // Wait is whether to wait for the install or upgrade to complete Wait bool // Version is the version of the chart to install or upgrade Version string // Values is the values to pass to the chart Values map[string]interface{} } type Release struct { AppVersion string Name string Namespace string Revision int Status release.Status Version string } type HelmAgent struct { Configuration *action.Configuration Namespace string Logger action.DebugLog } func NewHelmAgent(namespace string, logger action.DebugLog) (*HelmAgent, error) { actionConfig := new(action.Configuration) helmDriver := os.Getenv("HELM_DRIVER") if helmDriver == "" { helmDriver = "secrets" } kubeconfigPath := getKubeconfigPath() kubeContext := getKubeContext() kubeConfig := kube.GetConfig(kubeconfigPath, kubeContext, namespace) if err := actionConfig.Init(kubeConfig, namespace, helmDriver, logger); err != nil { return nil, err } return &HelmAgent{ Configuration: actionConfig, Namespace: namespace, Logger: logger, }, nil } type AddRepositoryInput struct { Name string URL string } func (h *HelmAgent) AddRepository(ctx context.Context, helmRepo AddRepositoryInput) error { settings := cli.New() repoFile := settings.RepositoryConfig err := os.MkdirAll(filepath.Dir(repoFile), os.ModePerm) if err != nil && !os.IsExist(err) { return fmt.Errorf("Error creating repository directory: %w", err) } fileLock := flock.New(strings.Replace(repoFile, filepath.Ext(repoFile), ".lock", 1)) locked, err := fileLock.TryLockContext(ctx, time.Second) if err != nil { return fmt.Errorf("Error acquiring file lock: %w", err) } if !locked { return fmt.Errorf("Could not acquire file lock") } defer fileLock.Unlock() // nolint: errcheck b, err := os.ReadFile(repoFile) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("Error reading repository file: %w", err) } var f repo.File if err := yaml.Unmarshal(b, &f); err != nil { return fmt.Errorf("Error unmarshalling yaml: %w", err) } if f.Has(helmRepo.Name) { return nil } c := repo.Entry{ Name: helmRepo.Name, URL: helmRepo.URL, } r, err := repo.NewChartRepository(&c, getter.All(settings)) if err != nil { return fmt.Errorf("Error creating chart repository: %w", err) } if _, err := r.DownloadIndexFile(); err != nil { return fmt.Errorf("Specified repository '%q' is not a valid chart repository or cannot be reached: %w", helmRepo.URL, err) } f.Update(&c) if err := f.WriteFile(repoFile, 0644); err != nil { log.Fatal(err) } return nil } func (h *HelmAgent) ChartExists(releaseName string) (bool, error) { if releaseName == "" { return false, fmt.Errorf("Release name is required") } client := action.NewHistory(h.Configuration) client.Max = 1 releases, err := client.Run(releaseName) if err != nil { if errors.Is(err, driver.ErrReleaseNotFound) { return false, nil } return false, err } if len(releases) > 0 { return true, nil } return false, nil } func (h *HelmAgent) DeleteRevision(ctx context.Context, releaseName string, revision int) error { clientset, err := NewKubernetesClient() if err != nil { return fmt.Errorf("Error creating kubernetes client: %w", err) } secretName := fmt.Sprintf("sh.helm.release.v1.%s.v%d", releaseName, revision) err = clientset.DeleteSecret(ctx, DeleteSecretInput{ Name: secretName, Namespace: h.Namespace, }) if err != nil { return fmt.Errorf("Error deleting secret: %w", err) } return nil } func (h *HelmAgent) GetValues(releaseName string) (map[string]interface{}, error) { client := action.NewGetValues(h.Configuration) client.AllValues = true values, err := client.Run(releaseName) if err != nil { return nil, fmt.Errorf("Error getting values: %w", err) } return values, nil } func (h *HelmAgent) InstallOrUpgradeChart(ctx context.Context, input ChartInput) error { chartExists, err := h.ChartExists(input.ReleaseName) if err != nil { return fmt.Errorf("Error checking if chart exists: %w", err) } if chartExists { return h.UpgradeChart(ctx, input) } return h.InstallChart(ctx, input) } func (h *HelmAgent) InstallChart(ctx context.Context, input ChartInput) error { namespace := input.Namespace if namespace == "" { namespace = h.Namespace } if input.ChartPath == "" { return fmt.Errorf("Chart path is required") } if input.ReleaseName == "" { return fmt.Errorf("Release name is required") } if input.Values == nil { input.Values = map[string]interface{}{} } kustomizeRenderer := KustomizeRenderer{ ReleaseName: input.ReleaseName, KustomizeRootPath: input.KustomizeRootPath, } client := action.NewInstall(h.Configuration) client.Atomic = false client.ChartPathOptions = action.ChartPathOptions{} client.CreateNamespace = true client.DryRun = false if os.Getenv("DOKKU_TRACE") == "1" { client.PostRenderer = &DebugRenderer{ Renderer: &kustomizeRenderer, } } else { client.PostRenderer = &kustomizeRenderer } client.Namespace = namespace client.ReleaseName = input.ReleaseName client.Timeout = input.Timeout client.Wait = input.Wait settings := cli.New() if input.RepoURL != "" { client.ChartPathOptions.RepoURL = input.RepoURL } if input.Version != "" { client.ChartPathOptions.Version = input.Version } chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings) if err != nil { return fmt.Errorf("Error locating chart: %w", err) } chartRequested, err := loader.Load(chart) if err != nil { return fmt.Errorf("Error loading chart: %w", err) } _, err = client.RunWithContext(ctx, chartRequested, input.Values) if err != nil { return fmt.Errorf("Error deploying: %w", err) } return nil } func (h *HelmAgent) InstalledRevision(releaseName string) (Release, error) { revisions, err := h.ListRevisions(ListRevisionsInput{ ReleaseName: releaseName, Max: 1, }) if err != nil { return Release{}, err } if len(revisions) == 0 { return Release{}, nil } return revisions[0], nil } type ListRevisionsInput struct { ReleaseName string Max int } func (h *HelmAgent) ListRevisions(input ListRevisionsInput) ([]Release, error) { client := action.NewHistory(h.Configuration) if input.Max > 0 { client.Max = input.Max } releases := []Release{} response, err := client.Run(input.ReleaseName) if err != nil { if errors.Is(err, driver.ErrReleaseNotFound) { return releases, nil } return nil, fmt.Errorf("Error getting revisions: %w", err) } for _, release := range response { appVersion := "MISSING" if release.Chart != nil && release.Chart.Metadata != nil { appVersion = release.Chart.AppVersion() } releases = append(releases, Release{ AppVersion: appVersion, Name: release.Name, Namespace: release.Namespace, Revision: release.Version, Status: release.Info.Status, Version: release.Chart.Metadata.Version, }) } sort.Slice(releases, func(i, j int) bool { return releases[i].Revision < releases[j].Revision }) return releases, nil } func (h *HelmAgent) UpgradeChart(ctx context.Context, input ChartInput) error { namespace := input.Namespace if namespace == "" { namespace = h.Namespace } if input.ChartPath == "" { return fmt.Errorf("Chart path is required") } if input.ReleaseName == "" { return fmt.Errorf("Release name is required") } if input.Values == nil { input.Values = map[string]interface{}{} } kustomizeRenderer := KustomizeRenderer{ ReleaseName: input.ReleaseName, KustomizeRootPath: input.KustomizeRootPath, } client := action.NewUpgrade(h.Configuration) client.Atomic = input.RollbackOnFailure client.ChartPathOptions = action.ChartPathOptions{} client.CleanupOnFail = true client.MaxHistory = 10 if os.Getenv("DOKKU_TRACE") == "1" { client.PostRenderer = &DebugRenderer{ Renderer: &kustomizeRenderer, } } else { client.PostRenderer = &kustomizeRenderer } client.Namespace = namespace client.Timeout = input.Timeout client.Wait = input.Wait if input.RepoURL != "" { client.RepoURL = input.RepoURL } settings := cli.New() chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings) if err != nil { return fmt.Errorf("Error locating chart: %w", err) } chartRequested, err := loader.Load(chart) if err != nil { return fmt.Errorf("Error loading chart: %w", err) } _, err = client.RunWithContext(ctx, input.ReleaseName, chartRequested, input.Values) if err != nil { return fmt.Errorf("Error deploying: %w", err) } return nil } func (h *HelmAgent) UninstallChart(releaseName string) error { exists, err := h.ChartExists(releaseName) if err != nil { return fmt.Errorf("Error checking if chart exists: %w", err) } if !exists { return nil } uninstall := action.NewUninstall(h.Configuration) uninstall.DeletionPropagation = "foreground" _, err = uninstall.Run(releaseName) if err != nil { return fmt.Errorf("Error uninstalling chart: %w", err) } return nil } type DebugRenderer struct { Renderer postrender.PostRenderer } func (p *DebugRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { renderedManifests, err := p.Renderer.Run(renderedManifests) if err != nil { return nil, err } for _, line := range strings.Split(renderedManifests.String(), "\n") { common.LogWarn(line) } return renderedManifests, nil } // KustomizeRenderer is a post renderer that kustomizes the rendered manifests type KustomizeRenderer struct { // KustomizeRootPath is the path to the kustomize root path to use KustomizeRootPath string // ReleaseName is the name of the release to kustomize ReleaseName string } // Run kustomizes the rendered manifests func (p *KustomizeRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { if p.KustomizeRootPath == "" { return renderedManifests, nil } if !common.DirectoryExists(p.KustomizeRootPath) { return renderedManifests, nil } common.LogVerboseQuiet(fmt.Sprintf("Applying kustomization to %s", p.ReleaseName)) fs, err := filesys.MakeFsOnDiskSecureBuild(p.KustomizeRootPath) if err != nil { return nil, fmt.Errorf("Error creating filesystem: %w", err) } var kfile string for _, f := range konfig.RecognizedKustomizationFileNames() { if kf := filepath.Join(p.KustomizeRootPath, f); fs.Exists(kf) { kfile = kf break } } if kfile == "" { return nil, fmt.Errorf("%s not found", konfig.DefaultKustomizationFileName()) } if err := fs.WriteFile(filepath.Join(p.KustomizeRootPath, "rendered.yaml"), renderedManifests.Bytes()); err != nil { return nil, fmt.Errorf("Error writing rendered.yaml: %w", err) } buildOptions := &krusty.Options{ LoadRestrictions: types.LoadRestrictionsNone, PluginConfig: types.DisabledPluginConfig(), } k := krusty.MakeKustomizer(buildOptions) m, err := k.Run(fs, p.KustomizeRootPath) if err != nil { return nil, err } resources, err := m.AsYaml() if err != nil { return nil, err } return bytes.NewBuffer(resources), nil }