44 "fmt"
55 "io"
66 "os"
7+ "regexp"
78 "runtime"
89 "sort"
910 "strconv"
@@ -67,6 +68,9 @@ type Config struct {
6768 Profiles map [string ][]string `yaml:"profiles"`
6869 Groups map [string ]GroupConfig `yaml:"groups"` /* key is group ID */
6970
71+ // for key/value replacements in model's cmd, cmdStop, proxy, checkEndPoint
72+ Macros map [string ]string `yaml:"macros"`
73+
7074 // map aliases to actual model IDs
7175 aliases map [string ]string
7276
@@ -141,6 +145,30 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
141145 }
142146 }
143147
148+ /* check macro constraint rules:
149+
150+ - name must fit the regex ^[a-zA-Z0-9_-]+$
151+ - names must be less than 64 characters (no reason, just cause)
152+ - name can not be any reserved macros: PORT
153+ - macro values must be less than 1024 characters
154+ */
155+ macroNameRegex := regexp .MustCompile (`^[a-zA-Z0-9_-]+$` )
156+ for macroName , macroValue := range config .Macros {
157+ if len (macroName ) >= 64 {
158+ return Config {}, fmt .Errorf ("macro name '%s' exceeds maximum length of 63 characters" , macroName )
159+ }
160+ if ! macroNameRegex .MatchString (macroName ) {
161+ return Config {}, fmt .Errorf ("macro name '%s' contains invalid characters, must match pattern ^[a-zA-Z0-9_-]+$" , macroName )
162+ }
163+ if len (macroValue ) >= 1024 {
164+ return Config {}, fmt .Errorf ("macro value for '%s' exceeds maximum length of 1024 characters" , macroName )
165+ }
166+ switch macroName {
167+ case "PORT" :
168+ return Config {}, fmt .Errorf ("macro name '%s' is reserved and cannot be used" , macroName )
169+ }
170+ }
171+
144172 // Get and sort all model IDs first, makes testing more consistent
145173 modelIds := make ([]string , 0 , len (config .Models ))
146174 for modelId := range config .Models {
@@ -151,19 +179,51 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
151179 nextPort := config .StartPort
152180 for _ , modelId := range modelIds {
153181 modelConfig := config .Models [modelId ]
154- // iterate over the models and replace any ${PORT} with the next available port
155- if strings .Contains (modelConfig .Cmd , "${PORT}" ) {
156- modelConfig .Cmd = strings .ReplaceAll (modelConfig .Cmd , "${PORT}" , strconv .Itoa (nextPort ))
182+
183+ // go through model config fields: cmd, cmdStop, proxy, checkEndPoint and replace macros with macro values
184+ for macroName , macroValue := range config .Macros {
185+ macroSlug := fmt .Sprintf ("${%s}" , macroName )
186+ modelConfig .Cmd = strings .ReplaceAll (modelConfig .Cmd , macroSlug , macroValue )
187+ modelConfig .CmdStop = strings .ReplaceAll (modelConfig .CmdStop , macroSlug , macroValue )
188+ modelConfig .Proxy = strings .ReplaceAll (modelConfig .Proxy , macroSlug , macroValue )
189+ modelConfig .CheckEndpoint = strings .ReplaceAll (modelConfig .CheckEndpoint , macroSlug , macroValue )
190+ }
191+
192+ // only iterate over models that use ${PORT} to keep port numbers from increasing unnecessarily
193+ if strings .Contains (modelConfig .Cmd , "${PORT}" ) || strings .Contains (modelConfig .Proxy , "${PORT}" ) || strings .Contains (modelConfig .CmdStop , "${PORT}" ) {
157194 if modelConfig .Proxy == "" {
158- modelConfig .Proxy = fmt .Sprintf ("http://localhost:%d" , nextPort )
159- } else {
160- modelConfig .Proxy = strings .ReplaceAll (modelConfig .Proxy , "${PORT}" , strconv .Itoa (nextPort ))
195+ modelConfig .Proxy = "http://localhost:${PORT}"
161196 }
197+
198+ nextPortStr := strconv .Itoa (nextPort )
199+ modelConfig .Cmd = strings .ReplaceAll (modelConfig .Cmd , "${PORT}" , nextPortStr )
200+ modelConfig .CmdStop = strings .ReplaceAll (modelConfig .CmdStop , "${PORT}" , nextPortStr )
201+ modelConfig .Proxy = strings .ReplaceAll (modelConfig .Proxy , "${PORT}" , nextPortStr )
162202 nextPort ++
163- config .Models [modelId ] = modelConfig
164203 } else if modelConfig .Proxy == "" {
165204 return Config {}, fmt .Errorf ("model %s requires a proxy value when not using automatic ${PORT}" , modelId )
166205 }
206+
207+ // make sure there are no unknown macros that have not been replaced
208+ macroPattern := regexp .MustCompile (`\$\{([a-zA-Z0-9_-]+)\}` )
209+ fieldMap := map [string ]string {
210+ "cmd" : modelConfig .Cmd ,
211+ "cmdStop" : modelConfig .CmdStop ,
212+ "proxy" : modelConfig .Proxy ,
213+ "checkEndpoint" : modelConfig .CheckEndpoint ,
214+ }
215+
216+ for fieldName , fieldValue := range fieldMap {
217+ matches := macroPattern .FindAllStringSubmatch (fieldValue , - 1 )
218+ for _ , match := range matches {
219+ macroName := match [1 ]
220+ if _ , exists := config .Macros [macroName ]; ! exists {
221+ return Config {}, fmt .Errorf ("unknown macro '${%s}' found in %s.%s" , macroName , modelId , fieldName )
222+ }
223+ }
224+ }
225+
226+ config .Models [modelId ] = modelConfig
167227 }
168228
169229 config = AddDefaultGroupToConfig (config )
0 commit comments