380 lines
13 KiB
Go
380 lines
13 KiB
Go
/*
|
|
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
|
|
*/
|
|
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/yookoala/realpath"
|
|
"gopkg.in/yaml.v3"
|
|
"internal/fileflag"
|
|
)
|
|
|
|
type helmfileStructure struct {
|
|
Repositories []helmfileRepository `json:"repositories"`
|
|
Releases []helmfileHelmRelease `json:"releases"`
|
|
HelmFiles []helmfileHemfileRef `json:"helmfiles"`
|
|
Environments map[string]interface{} `json:"environments"`
|
|
}
|
|
|
|
type helmfileRepository struct {
|
|
Name string `json:"name"`
|
|
Url string `json:"url"`
|
|
}
|
|
|
|
type helmfileHelmRelease struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Enabled bool `json:"enabled"`
|
|
Installed bool `json:"installed"`
|
|
Labels string `json:"labels"`
|
|
Chart string `json:"chart"`
|
|
Version string `json:"version"`
|
|
Values []map[string]interface{} `json:"values"`
|
|
}
|
|
|
|
type helmfileHemfileRef struct {
|
|
Path string `json:"path"`
|
|
Values []map[string]interface{} `json:"values"`
|
|
}
|
|
|
|
type argoProjectMeta struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type argoProjectDestination struct {
|
|
Namespace string `json:"namespace"`
|
|
Server string `json:"server"`
|
|
}
|
|
|
|
type argoResourceWhitelistItem struct {
|
|
Group string `json:"group"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
type argoProjectSpec struct {
|
|
SourceRepos []string `json:"sourceRepos"`
|
|
Destinations []argoProjectDestination `json:"Destinations"`
|
|
ClusterResourceWhitelist []argoResourceWhitelistItem `json:"clusterResourceWhitelist"`
|
|
}
|
|
|
|
type argoProject struct {
|
|
ApiVersion string `json:"apiVersion"`
|
|
Kind string `json:"kind"`
|
|
Metadata argoProjectMeta `json:"metadata"`
|
|
Spec argoProjectSpec `json:"spec"`
|
|
}
|
|
|
|
type argoAppMeta struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
}
|
|
|
|
type argoAppSpecSourceHelm struct {
|
|
ReleaseName string `releaseName`
|
|
ValuesObject map[string]interface{} `valuesObject`
|
|
}
|
|
|
|
type argoAppSpecSource struct {
|
|
Chart string `json:"chart"`
|
|
RepoURL string `json:"repoURL"`
|
|
targetRevision string `json:"targetRevision"`
|
|
Helm argoAppSpecSourceHelm `json:"helm"`
|
|
}
|
|
|
|
type argoAppSpecDestination struct {
|
|
Server string `json:"server"`
|
|
Namespace string `json:"namespace"`
|
|
}
|
|
|
|
type argoAppSpec struct {
|
|
Project string `json:"project"`
|
|
Source argoAppSpecSource `json:"source"`
|
|
Destination argoAppSpecDestination `json: "destination"`
|
|
}
|
|
|
|
type argoApp struct {
|
|
ApiVersion string `json:"apiVersion"`
|
|
Kind string `json:"kind"`
|
|
Metadata argoAppMeta `json:"metadata"`
|
|
Spec argoAppSpec `json:"spec"`
|
|
}
|
|
|
|
var flagInputYaml fileflag.InputFileFlag = "helmfile.yaml"
|
|
var singleFile bool = false
|
|
var outputDir string = "-" //TODO: actually handle files
|
|
var appNamespace string = "argocd"
|
|
var projectName string = "helmfile-imported"
|
|
var createProject bool = true
|
|
var serverURL string = "https://kubernetes.default.svc"
|
|
var recursiveConvert bool = false
|
|
var loadEnvironmentValues string = ""
|
|
|
|
var reposFromHelmChart []helmfileRepository
|
|
var environmentValues map[string]interface{}
|
|
var filesWrittenTo []string
|
|
|
|
// convertCmd represents the convert command
|
|
var convertCmd = &cobra.Command{
|
|
Use: "convert",
|
|
Short: "Convert a helmfile to a set of argocd applications",
|
|
Long: `Takes a helmfile .yaml or .yaml.gotmpl file as input and outputs
|
|
a set of .yaml files describing argocd Applications that are built from the
|
|
releases section.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
helmfileContents := parseHelmfile(string(flagInputYaml), nil)
|
|
if recursiveConvert && len(helmfileContents.HelmFiles) > 0 {
|
|
for i := 0; i <= len(helmfileContents.HelmFiles)-1; i++ {
|
|
fmt.Print("Merging in ")
|
|
fmt.Println(helmfileContents.HelmFiles[i].Path)
|
|
mergeIn := parseHelmfile(string(helmfileContents.HelmFiles[i].Path), helmfileContents.HelmFiles[i].Values)
|
|
helmfileContents.Repositories = append(helmfileContents.Repositories, mergeIn.Repositories[:]...)
|
|
helmfileContents.Releases = append(helmfileContents.Releases, mergeIn.Releases[:]...)
|
|
maps.Copy(helmfileContents.Environments,mergeIn.Environments)
|
|
}
|
|
}
|
|
fmt.Println("converting file:", flagInputYaml)
|
|
|
|
if (loadEnvironmentValues != "") {
|
|
environmentMap, ok := helmfileContents.Environments[loadEnvironmentValues].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println("Error, no enviroments defined in helmfile, but environment passed in command.")
|
|
os.Exit(1)
|
|
}
|
|
var valueFilesMap = environmentMap["values"].([]interface{})
|
|
var valueFiles []string
|
|
for i := range valueFilesMap {
|
|
valueFiles = append(valueFiles, valueFilesMap[i].(string))
|
|
}
|
|
environmentValues = fetchEnvironmentValues(valueFiles)
|
|
}
|
|
if createProject && (len(helmfileContents.Repositories) > 0) {
|
|
outputProject := baseProject(projectName)
|
|
for i := 0; i <= len(helmfileContents.Repositories)-1; i++ {
|
|
outputProject.Spec.SourceRepos = append(outputProject.Spec.SourceRepos, helmfileContents.Repositories[i].Url)
|
|
}
|
|
outputProjectYaml, _ := yaml.Marshal(outputProject)
|
|
var outputFilename = "project-"+projectName+".yaml"
|
|
if singleFile {
|
|
outputFilename = "helmfile2argo.yaml"
|
|
}
|
|
outputYamlFile(outputProjectYaml, outputFilename)
|
|
}
|
|
reposFromHelmChart = helmfileContents.Repositories
|
|
if len(helmfileContents.Releases) > 0 {
|
|
for i := 0; i <= len(helmfileContents.Releases)-1; i++ {
|
|
thisRelease := helmfileContents.Releases[i]
|
|
outputApp := baseArgoApp(thisRelease.Name)
|
|
outputApp.Spec.Project = projectName
|
|
if thisRelease.Chart[0:4] == "http" {
|
|
outputApp.Spec.Source.Chart = "UNSUPPORTED_BY_ARGO"
|
|
outputApp.Spec.Source.RepoURL = thisRelease.Chart
|
|
} else {
|
|
var chartmatcher = regexp.MustCompile(`(^[^/]*)/(.*)`)
|
|
chart := chartmatcher.ReplaceAllString(thisRelease.Chart, `$2`)
|
|
repo := chartmatcher.ReplaceAllString(thisRelease.Chart, `$1`)
|
|
outputApp.Spec.Source.Chart = chart
|
|
var err error
|
|
err, outputApp.Spec.Source.RepoURL = repoURLbyName(repo)
|
|
if err != nil {
|
|
outputApp.Spec.Source.RepoURL = "UNDEFINED"
|
|
}
|
|
}
|
|
outputApp.Spec.Source.Helm.ReleaseName = thisRelease.Name
|
|
if (loadEnvironmentValues != "") {
|
|
//flattened := flattenHelmfileWeirdValues(thisRelease.Values)
|
|
if (environmentValues == nil) {
|
|
outputApp.Spec.Source.Helm.ValuesObject = flattenHelmfileWeirdValues(thisRelease.Values)
|
|
} else if (thisRelease.Values == nil) {
|
|
outputApp.Spec.Source.Helm.ValuesObject = environmentValues
|
|
} else {
|
|
merged := environmentValues
|
|
maps.Copy(merged, flattenHelmfileWeirdValues(thisRelease.Values))
|
|
outputApp.Spec.Source.Helm.ValuesObject = merged
|
|
}
|
|
} else {
|
|
outputApp.Spec.Source.Helm.ValuesObject = flattenHelmfileWeirdValues(thisRelease.Values)
|
|
}
|
|
|
|
outputApp.Spec.Destination.Server = serverURL
|
|
outputApp.Spec.Destination.Namespace = thisRelease.Namespace
|
|
|
|
outputAppYaml, _ := yaml.Marshal(outputApp)
|
|
var outputFilename = "app-"+thisRelease.Name+".yaml"
|
|
if singleFile {
|
|
outputFilename = "helmfile2argo.yaml"
|
|
}
|
|
outputYamlFile(outputAppYaml,outputFilename)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
func flattenHelmfileWeirdValues(hfv []map[string]interface{}) map[string]interface{} {
|
|
var nv map[string]interface{}
|
|
for i := range hfv {
|
|
if i == 0 {
|
|
nv = hfv[i]
|
|
} else {
|
|
maps.Copy(nv,hfv[i])
|
|
}
|
|
}
|
|
return nv
|
|
}
|
|
|
|
func parseHelmfile(helmfileFilename string, passedValues []map[string]interface{}) helmfileStructure {
|
|
var finalPath = helmfileFilename
|
|
if helmfileFilename != string(flagInputYaml) {
|
|
absolutePath, err := realpath.Realpath(string(flagInputYaml))
|
|
basePath := filepath.Dir(absolutePath)
|
|
finalPath, err = realpath.Realpath(basePath + string(os.PathSeparator) + helmfileFilename)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
fmt.Println("parsing file:", finalPath)
|
|
yamlFile, err := os.ReadFile(finalPath)
|
|
if err != nil {
|
|
fmt.Printf("yamlFile.Get err #%v ", err)
|
|
}
|
|
templatingMatch := regexp.MustCompile(`{{[^{}]*}}`)
|
|
passOneRemoveTemplating := templatingMatch.ReplaceAllString(string(yamlFile), `UNDEFINED`)
|
|
passTwoRemoveTemplating := templatingMatch.ReplaceAllString(passOneRemoveTemplating, `UNDEFINED`)
|
|
multidocMatch := regexp.MustCompile(`\n---\n`)
|
|
passThreeRemoveMultiDoc := multidocMatch.ReplaceAllString(passTwoRemoveTemplating, "\n")
|
|
helmfileContents := &helmfileStructure{}
|
|
|
|
err = yaml.Unmarshal([]byte(passThreeRemoveMultiDoc), helmfileContents)
|
|
if err != nil {
|
|
fmt.Printf("Unmarshal: %v", err)
|
|
}
|
|
if passedValues != nil {
|
|
for i := 0; i <= len(helmfileContents.Releases)-1; i++ {
|
|
helmfileContents.Releases[i].Values = append(helmfileContents.Releases[i].Values, passedValues...)
|
|
}
|
|
}
|
|
return *helmfileContents
|
|
}
|
|
|
|
func fetchEnvironmentValues(files []string) map[string]interface{} {
|
|
var allValues map[string]interface{}
|
|
for i := 0; i <= len(files)-1; i++ {
|
|
absolutePath, err := realpath.Realpath(string(flagInputYaml))
|
|
basePath := filepath.Dir(absolutePath)
|
|
finalPath, err := realpath.Realpath(basePath + string(os.PathSeparator) + files[i])
|
|
fmt.Print("Loading values from ")
|
|
fmt.Println(finalPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
yamlFile, err := os.ReadFile(finalPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
var thisFilesValues map[string]interface{}
|
|
if err := yaml.Unmarshal(yamlFile, &thisFilesValues); err != nil {
|
|
panic(err)
|
|
}
|
|
fmt.Println(thisFilesValues)
|
|
if i == 0 {
|
|
allValues = thisFilesValues
|
|
} else {
|
|
maps.Copy(allValues, thisFilesValues)
|
|
}
|
|
}
|
|
fmt.Println(allValues)
|
|
return allValues
|
|
}
|
|
|
|
func fetchRepos(helmfileFilename string) []helmfileRepository {
|
|
return nil
|
|
}
|
|
|
|
func repoURLbyName(repoName string) (error, string) {
|
|
for i := 0; i <= len(reposFromHelmChart)-1; i++ {
|
|
if reposFromHelmChart[i].Name == repoName {
|
|
return nil, reposFromHelmChart[i].Url
|
|
}
|
|
}
|
|
return errors.New("No repo by that name found!"), ""
|
|
}
|
|
|
|
func outputString(data string, filename string) {
|
|
if string(outputDir) == "-" {
|
|
fmt.Println(data)
|
|
} else {
|
|
var mode int
|
|
if slices.Contains(filesWrittenTo,filename) {
|
|
mode = os.O_APPEND|os.O_CREATE|os.O_WRONLY
|
|
} else {
|
|
filesWrittenTo = append(filesWrittenTo, filename)
|
|
mode = os.O_TRUNC|os.O_CREATE|os.O_WRONLY
|
|
}
|
|
f, err := os.OpenFile(outputDir+string(os.PathSeparator)+filename, mode, 0644)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(data); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func outputYamlFile(bytes []byte, filename string) {
|
|
if singleFile || (string(outputDir) == "-") {
|
|
outputString("---\n", filename)
|
|
}
|
|
outputString(string(bytes)+"\n", filename)
|
|
}
|
|
|
|
func baseProject(name string) argoProject {
|
|
outputProject := argoProject{}
|
|
outputProject.ApiVersion = "argoproj.io/v1alpha1"
|
|
outputProject.Kind = "AppProject"
|
|
outputProject.Spec.Destinations = append(outputProject.Spec.Destinations, argoProjectDestination{Namespace: "*", Server: "*"})
|
|
outputProject.Spec.ClusterResourceWhitelist = append(outputProject.Spec.ClusterResourceWhitelist, argoResourceWhitelistItem{Group: "*", Kind: "*"})
|
|
outputProject.Metadata.Name = name
|
|
return outputProject
|
|
}
|
|
|
|
func baseArgoApp(name string) argoApp {
|
|
outputApp := argoApp{}
|
|
outputApp.ApiVersion = "argoproj.io/v1alpha1"
|
|
outputApp.Kind = "Application"
|
|
outputApp.Metadata.Namespace = appNamespace
|
|
outputApp.Metadata.Name = name
|
|
return outputApp
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(convertCmd)
|
|
|
|
// Here you will define your flags and configuration settings.
|
|
|
|
// Cobra supports Persistent Flags which will work for this command
|
|
// and all subcommands, e.g.:
|
|
// convertCmd.PersistentFlags().String("foo", "", "A help for foo")
|
|
|
|
// Cobra supports local flags which will only run when this command
|
|
// is called directly, e.g.:
|
|
// convertCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
|
convertCmd.Flags().VarP(&flagInputYaml, "input", "i", `helmfile to convert`)
|
|
convertCmd.Flags().BoolVarP(&singleFile, "single-file", "1", false, `single output file (always true if output is "-")`)
|
|
convertCmd.Flags().StringVarP(&outputDir, "output", "o", "-", `output folder or "-" for stdout`)
|
|
convertCmd.Flags().StringVarP(&appNamespace, "appnamespace", "a", "argocd", `namespace for application objects`)
|
|
convertCmd.Flags().StringVarP(&projectName, "projectname", "p", "helmfile-imported", `project name for all apps`)
|
|
convertCmd.Flags().BoolVarP(&createProject, "create-project", "c", true, `create project with above projectname and all repositories from helmfile`)
|
|
convertCmd.Flags().StringVarP(&serverURL, "server-name", "n", "https://kubernetes.default.svc", `spec.destination.server value in the application objects`)
|
|
convertCmd.Flags().BoolVarP(&recursiveConvert, "recursive", "r", false, `recurse into sub helmfiles`)
|
|
convertCmd.Flags().StringVarP(&loadEnvironmentValues, "load-environment-values", "e", "", `set to an environment name to load environment-based values in the top-level helmfile`)
|
|
|
|
}
|