Skip to content

Commit ce4d789

Browse files
authored
mcpd config export: support 'Portable Execution Context' (#81)
Partial support for the full intention of mcpd config export cmd
1 parent 1381ad8 commit ce4d789

File tree

8 files changed

+467
-39
lines changed

8 files changed

+467
-39
lines changed

cmd/config/config.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/mozilla-ai/mcpd/v2/cmd/config/args"
77
"github.com/mozilla-ai/mcpd/v2/cmd/config/env"
8+
"github.com/mozilla-ai/mcpd/v2/cmd/config/export"
89
"github.com/mozilla-ai/mcpd/v2/cmd/config/tools"
910
"github.com/mozilla-ai/mcpd/v2/internal/cmd"
1011
"github.com/mozilla-ai/mcpd/v2/internal/cmd/options"
@@ -19,9 +20,10 @@ func NewConfigCmd(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Comman
1920

2021
// Sub-commands for: mcpd config
2122
fns := []func(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Command, error){
22-
args.NewCmd, // args
23-
env.NewCmd, // env
24-
tools.NewCmd, // tools
23+
args.NewCmd, // args
24+
env.NewCmd, // env
25+
tools.NewCmd, // tools
26+
export.NewCmd, // export
2527
}
2628

2729
for _, fn := range fns {

cmd/config/export/export.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package export
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/mozilla-ai/mcpd/v2/internal/cmd"
9+
"github.com/mozilla-ai/mcpd/v2/internal/cmd/options"
10+
cmdopts "github.com/mozilla-ai/mcpd/v2/internal/cmd/options"
11+
"github.com/mozilla-ai/mcpd/v2/internal/config"
12+
"github.com/mozilla-ai/mcpd/v2/internal/context"
13+
"github.com/mozilla-ai/mcpd/v2/internal/flags"
14+
)
15+
16+
type Cmd struct {
17+
*cmd.BaseCmd
18+
Format cmd.ExportFormat
19+
ContractOutput string
20+
ContextOutput string
21+
cfgLoader config.Loader
22+
ctxLoader context.Loader
23+
}
24+
25+
func NewCmd(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Command, error) {
26+
opts, err := cmdopts.NewOptions(opt...)
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
c := &Cmd{
32+
BaseCmd: baseCmd,
33+
Format: cmd.FormatDotEnv, // Default to dotenv
34+
cfgLoader: opts.ConfigLoader,
35+
ctxLoader: opts.ContextLoader,
36+
}
37+
38+
cobraCmd := &cobra.Command{
39+
Use: "export",
40+
Short: "Exports current configuration, generating a pair of safe and portable configuration files",
41+
Long: "Exports current configuration, generating a pair of safe and portable configuration files.\n\n" +
42+
"Using a project's required configuration (e.g. .mcpd.toml) and the locally configured runtime values " +
43+
"from the execution context file (e.g. ~/.config/mcpd/secrets.dev.toml), outputs both an 'Environment Contract' " +
44+
"and 'Portable Execution Context' file." +
45+
"These files are safe to check into version control if required.\n\n" +
46+
"This allows running an mcpd project in any environment, cleanly separating the configuration structure " +
47+
"from the secret values.",
48+
RunE: c.run,
49+
}
50+
51+
// Portable Execution Context:
52+
//
53+
// A new secrets.toml file that defines the runtime args and env sections for each server,
54+
// using the placeholders from the environment contract.
55+
cobraCmd.Flags().StringVar(
56+
&c.ContextOutput,
57+
"context-output",
58+
"secrets.prod.toml",
59+
"Optional, specify the output path for the templated execution context config file",
60+
)
61+
62+
// Environment Contract:
63+
//
64+
// Lists all required and configured environment variables as secure, namespaced placeholders
65+
// e.g. MCPD__{SERVER_NAME}__{ENV_VAR}
66+
// Creates placeholders for command line arguments to be populated with env vars
67+
// e.g. MCPD__{SERVER_NAME}__ARG_{ARG_NAME}
68+
// This file is intended for the platform operator or CI/CD system.
69+
cobraCmd.Flags().StringVar(
70+
&c.ContractOutput,
71+
"contract-output",
72+
".env",
73+
"Optional, specify the output path for the templated environment file",
74+
)
75+
76+
allowed := cmd.AllowedFormats()
77+
cobraCmd.Flags().Var(
78+
&c.Format,
79+
"format",
80+
fmt.Sprintf("Specify the format of the contract output file (one of: %s)", allowed.String()),
81+
)
82+
83+
return cobraCmd, nil
84+
}
85+
86+
func (c *Cmd) run(cmd *cobra.Command, args []string) error {
87+
contextPath := c.ContextOutput
88+
// contractPath := c.ContractOutput
89+
90+
if err := exportPortableExecutionContext(c.ctxLoader, flags.RuntimeFile, contextPath); err != nil {
91+
return err
92+
}
93+
94+
fmt.Fprintf(cmd.OutOrStdout(), "✓ Portable Execution Context exported: %s\n", contextPath)
95+
96+
// Export 'Environment Contract'
97+
// TODO: export to contractPath based on format
98+
// fmt.Fprintf(cmd.OutOrStdout(), "✓ Environment Contract exported: %s\n", contractPath)
99+
100+
fmt.Fprintf(cmd.OutOrStdout(), "✓ Export completed successfully!\n")
101+
102+
return nil
103+
}
104+
105+
func exportPortableExecutionContext(loader context.Loader, src string, dest string) error {
106+
mod, err := loader.Load(src)
107+
if err != nil {
108+
return fmt.Errorf("failed to load execution context config: %w", err)
109+
}
110+
111+
exp, ok := mod.(context.Exporter)
112+
if !ok {
113+
return fmt.Errorf("execution context config does not support exporting")
114+
}
115+
116+
return exp.Export(dest)
117+
}

internal/cmd/export.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// ExportFormat represents an enum for the supported formats we can export to.
9+
type ExportFormat string
10+
11+
// ExportFormats is a wrapper which allows 'helper' receivers to be declared,
12+
// such as String().
13+
type ExportFormats []ExportFormat
14+
15+
const (
16+
// FormatDotEnv for contract files that should be dotenv (.env) format.
17+
FormatDotEnv ExportFormat = "dotenv"
18+
19+
// FormatGitHubActions for contract files that should be GitHub Actions format.
20+
FormatGitHubActions ExportFormat = "github"
21+
22+
// FormatKubernetesSecret for contract files that should be Kubernetes Secret format.
23+
FormatKubernetesSecret ExportFormat = "k8s"
24+
)
25+
26+
// AllowedFormats returns the allowed formats for the export command.
27+
func AllowedFormats() ExportFormats {
28+
return ExportFormats{
29+
FormatDotEnv,
30+
// TODO: Uncomment to enable, as we add support for each.
31+
// FormatGitHubActions,
32+
// FormatKubernetesSecret,
33+
}
34+
}
35+
36+
// String implements fmt.Stringer for a collection of export formats,
37+
// converting them to a comma separated string.
38+
func (f *ExportFormats) String() string {
39+
efs := *f
40+
out := make([]string, len(efs))
41+
for i := range efs {
42+
out[i] = efs[i].String()
43+
}
44+
return strings.Join(out, ", ")
45+
}
46+
47+
// String implements fmt.Stringer for an export format.
48+
// This is also required by Cobra as part of implementing flag.Value.
49+
func (f *ExportFormat) String() string {
50+
return strings.ToLower(string(*f))
51+
}
52+
53+
// Set is used by Cobra to set the export format value from a string.
54+
// This is also required by Cobra as part of implementing flag.Value.
55+
func (f *ExportFormat) Set(v string) error {
56+
allowed := AllowedFormats()
57+
58+
for _, a := range allowed {
59+
if string(a) == v {
60+
*f = ExportFormat(v)
61+
return nil
62+
}
63+
}
64+
65+
return fmt.Errorf("invalid format '%s', must be one of %v", v, allowed.String())
66+
}
67+
68+
// Type is used by Cobra to get the 'type' of an export format for display purposes.
69+
// This is also required by Cobra as part of implementing flag.Value.
70+
func (f *ExportFormat) Type() string {
71+
return "format"
72+
}

internal/context/context.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package context
22

33
import (
4+
"cmp"
45
"errors"
56
"fmt"
67
"maps"
@@ -93,6 +94,63 @@ func NewExecutionContextConfig() ExecutionContextConfig {
9394
}
9495
}
9596

97+
func (c *ExecutionContextConfig) Export(path string) error {
98+
if len(c.Servers) == 0 {
99+
return fmt.Errorf("export error, no servers defined in execution context config")
100+
}
101+
102+
const appName = "MCPD" // TODO: Reference shared app name.
103+
104+
res := ExecutionContextConfig{
105+
Servers: map[string]ServerExecutionContext{},
106+
filePath: path,
107+
}
108+
109+
for name, srv := range c.Servers {
110+
n := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
111+
envs := map[string]string{}
112+
args := make([]string, len(srv.Args))
113+
114+
for k := range srv.Env {
115+
key := strings.ToUpper(k)
116+
value := fmt.Sprintf("${%s__%s__%s}", appName, n, key)
117+
envs[k] = value
118+
}
119+
120+
for i, v := range srv.Args {
121+
if idx := strings.IndexByte(v, '='); idx != -1 {
122+
arg := v[:idx]
123+
parsed := strings.ToUpper(strings.ReplaceAll(strings.TrimLeft(arg, "--"), "-", "_"))
124+
args[i] = fmt.Sprintf("%s=${%s__%s__ARG__%s}", arg, appName, n, parsed)
125+
} else {
126+
args[i] = v
127+
}
128+
}
129+
130+
res.Servers[name] = ServerExecutionContext{
131+
Name: name,
132+
Args: args,
133+
Env: envs,
134+
}
135+
}
136+
137+
if err := res.saveConfig(); err != nil {
138+
return err
139+
}
140+
141+
return nil
142+
}
143+
144+
func (c *ExecutionContextConfig) List() []ServerExecutionContext {
145+
servers := slices.Collect(maps.Values(c.Servers))
146+
147+
slices.SortFunc(servers, func(a, b ServerExecutionContext) int {
148+
return cmp.Compare(a.Name, b.Name)
149+
})
150+
151+
return servers
152+
}
153+
96154
func (c *ExecutionContextConfig) Get(name string) (ServerExecutionContext, bool) {
97155
name = strings.TrimSpace(name)
98156
if name == "" {

0 commit comments

Comments
 (0)