package cmd import ( "fmt" "sort" "strings" "github.com/jensbecker/homelab/knecht/portainer" "github.com/jensbecker/homelab/knecht/stack" "github.com/spf13/cobra" ) var ( deployFrom string deployEnv []string ) var deployCmd = &cobra.Command{ Use: "deploy ", Short: "Deploy a new stack from services//docker-compose.yml", Long: "Deploy a new stack from services//docker-compose.yml.\n\n" + "Env var keys are taken from .env.example. Values are empty unless provided via\n" + "--from (copy values from an existing stack) or -e KEY=VALUE.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { _, client, svcPath, err := setup() if err != nil { return err } name := args[0] local, err := stack.Get(svcPath, name) if err != nil { return err } // Check if already deployed existing, err := client.GetStackByName(name) if err != nil { return err } if existing != nil { return fmt.Errorf("stack %q already exists (id %d) — use `knecht update %s` instead", name, existing.ID, name) } compose, err := local.ReadCompose() if err != nil { return err } exampleKeys, err := local.EnvExampleKeys() if err != nil { return err } env, empties, err := buildDeployEnv(client, exampleKeys) if err != nil { return err } s, err := client.CreateStack(name, compose, env) if err != nil { return err } fmt.Printf("Deployed stack %q (id %d)\n", s.Name, s.ID) if len(empties) > 0 { fmt.Println("Warning: these env vars have no value yet — set them in Portainer (or redeploy with --from/-e):") for _, k := range empties { fmt.Printf(" • %s\n", k) } } return nil }, } // buildDeployEnv assembles the env var list for a new stack. // Keys come from .env.example (empty by default); values are seeded from // --from and then overridden by -e KEY=VALUE flags. // It returns the ordered env slice and the names of keys left empty. func buildDeployEnv(client *portainer.Client, exampleKeys []string) ([]portainer.EnvVar, []string, error) { envMap := make(map[string]string, len(exampleKeys)) for _, k := range exampleKeys { envMap[k] = "" } if deployFrom != "" { src, err := client.GetStackByName(deployFrom) if err != nil { return nil, nil, err } if src == nil { return nil, nil, fmt.Errorf("source stack %q not found", deployFrom) } for _, e := range src.Env { if _, ok := envMap[e.Name]; ok { envMap[e.Name] = e.Value } } } for _, kv := range deployEnv { k, v, ok := strings.Cut(kv, "=") k = strings.TrimSpace(k) if !ok || k == "" { return nil, nil, fmt.Errorf("invalid -e %q, expected KEY=VALUE", kv) } envMap[k] = v } // Ordered output: .env.example order first, then any extra keys (sorted). ordered := make([]string, 0, len(envMap)) seen := make(map[string]bool, len(envMap)) for _, k := range exampleKeys { if !seen[k] { ordered = append(ordered, k) seen[k] = true } } var extra []string for k := range envMap { if !seen[k] { extra = append(extra, k) } } sort.Strings(extra) ordered = append(ordered, extra...) env := make([]portainer.EnvVar, 0, len(ordered)) var empties []string for _, k := range ordered { env = append(env, portainer.EnvVar{Name: k, Value: envMap[k]}) if envMap[k] == "" { empties = append(empties, k) } } return env, empties, nil } func init() { deployCmd.Flags().StringVar(&deployFrom, "from", "", "copy env var values from an existing deployed stack") deployCmd.Flags().StringArrayVarP(&deployEnv, "env", "e", nil, "set env var value (KEY=VALUE, repeatable)") rootCmd.AddCommand(deployCmd) }