|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "path/filepath" |
| 7 | + "sort" |
| 8 | + "strings" |
| 9 | + "text/tabwriter" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/spf13/cobra" |
| 13 | + |
| 14 | + "github.com/open-source-cloud/devstack/internal/config" |
| 15 | + "github.com/open-source-cloud/devstack/internal/envingest" |
| 16 | + "github.com/open-source-cloud/devstack/internal/workspace" |
| 17 | +) |
| 18 | + |
| 19 | +// newEnvCmd wires the `env` group (spec 30): view and edit a project service's |
| 20 | +// local environment variables (env.raw) — the zero-setup, no-external-store |
| 21 | +// key/value mechanism. Edits are comment/order-preserving AST rewrites of the |
| 22 | +// project's devstack.yaml (the same engine `secrets ingest` uses). A value that is |
| 23 | +// a secret:// ref is stored verbatim and resolved at generation (spec 04). |
| 24 | +func newEnvCmd(g *GlobalOpts) *cobra.Command { |
| 25 | + cmd := &cobra.Command{ |
| 26 | + Use: "env", |
| 27 | + Short: "View and set a project service's local env vars (env.raw)", |
| 28 | + } |
| 29 | + cmd.AddCommand(newEnvListCmd(g), newEnvSetCmd(g), newEnvUnsetCmd(g)) |
| 30 | + return cmd |
| 31 | +} |
| 32 | + |
| 33 | +func newEnvListCmd(g *GlobalOpts) *cobra.Command { |
| 34 | + var project, service string |
| 35 | + cmd := &cobra.Command{ |
| 36 | + Use: "list", |
| 37 | + Short: "List a service's env vars (raw, prefixed, imports)", |
| 38 | + Args: cobra.NoArgs, |
| 39 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 40 | + mgr, closeFn, err := buildManager(cmd) |
| 41 | + if err != nil { |
| 42 | + return err |
| 43 | + } |
| 44 | + defer closeFn() |
| 45 | + proj, svc, _, sv, err := resolveEnvTarget(mgr, project, service) |
| 46 | + if err != nil { |
| 47 | + return err |
| 48 | + } |
| 49 | + if g.JSON { |
| 50 | + return writeJSON(cmd, map[string]any{ |
| 51 | + "project": proj, "service": svc, |
| 52 | + "raw": sv.Env.Raw, "prefixed": sv.Env.Prefixed, "import": sv.Env.Import, |
| 53 | + }) |
| 54 | + } |
| 55 | + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) |
| 56 | + fmt.Fprintf(tw, "# %s / %s\n", proj, svc) |
| 57 | + printEnvMap(tw, "raw", sv.Env.Raw) |
| 58 | + printEnvMap(tw, "prefixed", sv.Env.Prefixed) |
| 59 | + for _, im := range sv.Env.Import { |
| 60 | + fmt.Fprintf(tw, "import\t%s\t%s\n", im.From, strings.Join(im.Vars, ",")) |
| 61 | + } |
| 62 | + return tw.Flush() |
| 63 | + }, |
| 64 | + } |
| 65 | + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") |
| 66 | + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") |
| 67 | + return cmd |
| 68 | +} |
| 69 | + |
| 70 | +func newEnvSetCmd(g *GlobalOpts) *cobra.Command { |
| 71 | + var project, service string |
| 72 | + cmd := &cobra.Command{ |
| 73 | + Use: "set KEY=VALUE [KEY=VALUE ...]", |
| 74 | + Short: "Set local env var(s) on a service (comment-preserving)", |
| 75 | + Long: "Set one or more KEY=VALUE env vars in a service's env.raw block. Existing keys\n" + |
| 76 | + "and comments are preserved. A VALUE of the form secret://... is stored as-is and\n" + |
| 77 | + "resolved from your secret provider at generation (never written to compose).", |
| 78 | + Args: cobra.MinimumNArgs(1), |
| 79 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 80 | + kv := map[string]string{} |
| 81 | + for _, a := range args { |
| 82 | + k, v, ok := strings.Cut(a, "=") |
| 83 | + if !ok || k == "" { |
| 84 | + return fmt.Errorf("invalid KEY=VALUE: %q", a) |
| 85 | + } |
| 86 | + kv[k] = v |
| 87 | + } |
| 88 | + mgr, closeFn, err := buildManager(cmd) |
| 89 | + if err != nil { |
| 90 | + return err |
| 91 | + } |
| 92 | + defer closeFn() |
| 93 | + proj, svc, path, _, err := resolveEnvTarget(mgr, project, service) |
| 94 | + if err != nil { |
| 95 | + return err |
| 96 | + } |
| 97 | + src, err := os.ReadFile(path) |
| 98 | + if err != nil { |
| 99 | + return err |
| 100 | + } |
| 101 | + out, err := envingest.SetEnv(src, svc, "raw", kv) |
| 102 | + if err != nil { |
| 103 | + return err |
| 104 | + } |
| 105 | + if err := backupAndWrite(path, out); err != nil { |
| 106 | + return err |
| 107 | + } |
| 108 | + if !g.Quiet { |
| 109 | + fmt.Fprintf(cmd.OutOrStdout(), "set %d var(s) on %s/%s\n", len(kv), proj, svc) |
| 110 | + } |
| 111 | + return nil |
| 112 | + }, |
| 113 | + } |
| 114 | + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") |
| 115 | + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") |
| 116 | + return cmd |
| 117 | +} |
| 118 | + |
| 119 | +func newEnvUnsetCmd(g *GlobalOpts) *cobra.Command { |
| 120 | + var project, service string |
| 121 | + cmd := &cobra.Command{ |
| 122 | + Use: "unset KEY [KEY ...]", |
| 123 | + Short: "Remove local env var(s) from a service", |
| 124 | + Args: cobra.MinimumNArgs(1), |
| 125 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 126 | + mgr, closeFn, err := buildManager(cmd) |
| 127 | + if err != nil { |
| 128 | + return err |
| 129 | + } |
| 130 | + defer closeFn() |
| 131 | + proj, svc, path, sv, err := resolveEnvTarget(mgr, project, service) |
| 132 | + if err != nil { |
| 133 | + return err |
| 134 | + } |
| 135 | + remaining := map[string]string{} |
| 136 | + for k, v := range sv.Env.Raw { |
| 137 | + remaining[k] = v |
| 138 | + } |
| 139 | + for _, k := range args { |
| 140 | + delete(remaining, k) |
| 141 | + } |
| 142 | + src, err := os.ReadFile(path) |
| 143 | + if err != nil { |
| 144 | + return err |
| 145 | + } |
| 146 | + out, err := envingest.ReplaceEnvBlock(src, svc, "raw", remaining) |
| 147 | + if err != nil { |
| 148 | + return err |
| 149 | + } |
| 150 | + if err := backupAndWrite(path, out); err != nil { |
| 151 | + return err |
| 152 | + } |
| 153 | + if !g.Quiet { |
| 154 | + fmt.Fprintf(cmd.OutOrStdout(), "unset %d var(s) on %s/%s\n", len(args), proj, svc) |
| 155 | + } |
| 156 | + return nil |
| 157 | + }, |
| 158 | + } |
| 159 | + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") |
| 160 | + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") |
| 161 | + return cmd |
| 162 | +} |
| 163 | + |
| 164 | +// resolveEnvTarget resolves the (project, service, devstack.yaml path, service |
| 165 | +// config). The service defaults to the sole service, else --service is required. |
| 166 | +func resolveEnvTarget(mgr *workspace.Manager, project, service string) (string, string, string, config.Service, error) { |
| 167 | + proj := project |
| 168 | + if proj == "" { |
| 169 | + proj = resolveActiveProject(mgr.Model, mgr.DB) |
| 170 | + } |
| 171 | + p, ok := mgr.Model.Projects[proj] |
| 172 | + if !ok { |
| 173 | + return "", "", "", config.Service{}, fmt.Errorf("unknown project %q", proj) |
| 174 | + } |
| 175 | + svc := service |
| 176 | + if svc == "" { |
| 177 | + names := make([]string, 0, len(p.Services)) |
| 178 | + for n := range p.Services { |
| 179 | + names = append(names, n) |
| 180 | + } |
| 181 | + if len(names) != 1 { |
| 182 | + sort.Strings(names) |
| 183 | + return "", "", "", config.Service{}, fmt.Errorf("project %q has %d services (%s) — pass --service", proj, len(names), strings.Join(names, ", ")) |
| 184 | + } |
| 185 | + svc = names[0] |
| 186 | + } |
| 187 | + sv, ok := p.Services[svc] |
| 188 | + if !ok { |
| 189 | + return "", "", "", config.Service{}, fmt.Errorf("service %q not found in project %q", svc, proj) |
| 190 | + } |
| 191 | + path := filepath.Join(mgr.Model.ProjectDir(proj), "devstack.yaml") |
| 192 | + return proj, svc, path, sv, nil |
| 193 | +} |
| 194 | + |
| 195 | +func printEnvMap(w *tabwriter.Writer, block string, m map[string]string) { |
| 196 | + keys := make([]string, 0, len(m)) |
| 197 | + for k := range m { |
| 198 | + keys = append(keys, k) |
| 199 | + } |
| 200 | + sort.Strings(keys) |
| 201 | + for _, k := range keys { |
| 202 | + v := m[k] |
| 203 | + if strings.HasPrefix(v, "secret://") { |
| 204 | + v = "secret://… (resolved at generate)" |
| 205 | + } |
| 206 | + fmt.Fprintf(w, "%s\t%s\t%s\n", block, k, v) |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +// backupAndWrite backs up path to path.bak.<ts> then writes data (0644), matching |
| 211 | +// the init/import overwrite pattern. |
| 212 | +func backupAndWrite(path string, data []byte) error { |
| 213 | + if _, err := os.Stat(path); err == nil { |
| 214 | + if err := os.Rename(path, fmt.Sprintf("%s.bak.%d", path, time.Now().Unix())); err != nil { |
| 215 | + return err |
| 216 | + } |
| 217 | + } |
| 218 | + return os.WriteFile(path, data, 0o644) |
| 219 | +} |
0 commit comments