Add application configuration inspection commands
This commit is contained in:
86
internal/adapter/cli/config.go
Normal file
86
internal/adapter/cli/config.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||
)
|
||||
|
||||
type configurationSource struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Layer configkit.Layer `json:"layer"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
func configCommand(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "config parse error: configuration mode is required")
|
||||
return ExitRuntimeError
|
||||
}
|
||||
mode := args[0]
|
||||
if mode != "validate" && mode != "show" && mode != "sources" {
|
||||
fmt.Fprintln(stderr, "config parse error: unknown configuration mode")
|
||||
return ExitRuntimeError
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("config "+mode, flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var path, overlay string
|
||||
registerConfigSelectionFlags(fs, &path, &overlay)
|
||||
registerConfigurationOverrides(fs)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
writeApplicationError(stderr, "config parse", err)
|
||||
return ExitRuntimeError
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
fmt.Fprintln(stderr, "config parse error: positional arguments are not accepted")
|
||||
return ExitRuntimeError
|
||||
}
|
||||
|
||||
result, err := appconfig.Load(appconfig.LoadOptions{
|
||||
Path: path, Explicit: flagWasSet(fs, "config"), Overlay: overlay,
|
||||
Overrides: applicationOverrides(fs),
|
||||
})
|
||||
if err != nil {
|
||||
writeApplicationError(stderr, "config", err)
|
||||
return ExitRuntimeError
|
||||
}
|
||||
if err := writeApplicationWarnings(stderr, result.Diagnostics()); err != nil {
|
||||
return ExitRuntimeError
|
||||
}
|
||||
switch mode {
|
||||
case "validate":
|
||||
return ExitOK
|
||||
case "show":
|
||||
err = result.WriteEffective(stdout)
|
||||
case "sources":
|
||||
sources := make([]configurationSource, 0, len(result.Sources()))
|
||||
for _, source := range result.Sources() {
|
||||
sources = append(sources, configurationSource{
|
||||
ID: source.ID, Label: source.Label, Layer: source.Layer, Path: source.Path,
|
||||
})
|
||||
}
|
||||
err = json.NewEncoder(stdout).Encode(sources)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "config output error: %v\n", err)
|
||||
return ExitRuntimeError
|
||||
}
|
||||
return ExitOK
|
||||
}
|
||||
|
||||
func registerConfigurationOverrides(fs *flag.FlagSet) {
|
||||
fs.String("prompt-dir", "", "prompt-definition directory override")
|
||||
fs.String("profile-dir", "", "profile directory override")
|
||||
fs.String("schema-dir", "", "schema directory override")
|
||||
fs.String("addr", "", "HTTP listen address override")
|
||||
fs.String("artifact-root", "", "HTTP artifact root override")
|
||||
fs.Int64("max-request-bytes", 0, "HTTP request byte limit override")
|
||||
fs.Int64("max-artifact-bytes", 0, "HTTP artifact byte limit override")
|
||||
fs.Int64("max-response-bytes", 0, "HTTP response byte limit override")
|
||||
}
|
||||
128
internal/adapter/cli/config_test.go
Normal file
128
internal/adapter/cli/config_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigCommandsInspectApplicationWithoutEngine(t *testing.T) {
|
||||
path := writeAppConfigFile(t, "{}\n")
|
||||
for _, mode := range []string{"validate", "show", "sources"} {
|
||||
code, stdout, stderr := runCLICommand(t, configCommand, []string{mode, "--config", path})
|
||||
if code != ExitOK || stderr != "" {
|
||||
t.Fatalf("%s failed: code=%d stdout=%q stderr=%q", mode, code, stdout, stderr)
|
||||
}
|
||||
switch mode {
|
||||
case "validate":
|
||||
if stdout != "" {
|
||||
t.Fatalf("validation wrote stdout: %q", stdout)
|
||||
}
|
||||
case "show":
|
||||
if !strings.Contains(stdout, "render_format: text") || !strings.Contains(stdout, "max_request_bytes: 16777216") {
|
||||
t.Fatalf("effective defaults missing: %q", stdout)
|
||||
}
|
||||
case "sources":
|
||||
var sources []configurationSource
|
||||
if err := json.Unmarshal([]byte(stdout), &sources); err != nil || len(sources) != 1 || sources[0].Layer != "base" {
|
||||
t.Fatalf("unexpected source records: %q (%v)", stdout, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsExposeComposedSourcesAndResolvedValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(dir, "config.yml")
|
||||
for path, body := range map[string]string{
|
||||
base: "configkit:\n imports:\n - path: fragment.yml\nprompt_dir: ' ./before/../prompts '\nprofile_dir: ./profiles\n",
|
||||
filepath.Join(dir, "fragment.yml"): "server:\n addr: :8081\n",
|
||||
filepath.Join(dir, "overlays", "local.yml"): "server:\n addr: :9090\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--config", base, "--config-overlay", "local", "--profile-dir=", "--addr", ":7070"}
|
||||
code, stdout, stderr := runCLICommand(t, configCommand, append([]string{"show"}, args...))
|
||||
if code != ExitOK || stderr != "" || !strings.Contains(stdout, "prompt_dir: prompts") || !strings.Contains(stdout, "profile_dir: ''") || !strings.Contains(stdout, "addr: :7070") {
|
||||
t.Fatalf("resolved effective output changed: code=%d out=%q err=%q", code, stdout, stderr)
|
||||
}
|
||||
code, stdout, stderr = runCLICommand(t, configCommand, append([]string{"sources"}, args...))
|
||||
if code != ExitOK || stderr != "" || !strings.HasSuffix(stdout, "\n") {
|
||||
t.Fatalf("source command failed: code=%d out=%q err=%q", code, stdout, stderr)
|
||||
}
|
||||
var sources []configurationSource
|
||||
if err := json.Unmarshal([]byte(stdout), &sources); err != nil || len(sources) != 5 {
|
||||
t.Fatalf("unexpected composed source records: %+v (%v)", sources, err)
|
||||
}
|
||||
for i, layer := range []string{"base", "base", "overlay", "cli", "cli"} {
|
||||
if string(sources[i].Layer) != layer {
|
||||
t.Fatalf("source %d layer = %q, want %q", i, sources[i].Layer, layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInspectionKeepsReferencesAndOrdinaryParameters(t *testing.T) {
|
||||
const name = "SCRIPTORIUM_CONFIG_TEST_API_KEY"
|
||||
const secret = "synthetic-secret-value-should-not-appear"
|
||||
path := writeAppConfigFile(t, `backends:
|
||||
synthetic:
|
||||
endpoint: http://127.0.0.1:1/v1
|
||||
api_key_env: SCRIPTORIUM_CONFIG_TEST_API_KEY
|
||||
extra_params:
|
||||
visible_parameter: synthetic-ordinary-value
|
||||
`)
|
||||
t.Setenv(name, secret)
|
||||
for _, present := range []bool{true, false} {
|
||||
if !present {
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
code, stdout, stderr := runCLICommand(t, configCommand, []string{"show", "--config", path})
|
||||
if code != ExitOK || stderr != "" || !strings.Contains(stdout, name) || !strings.Contains(stdout, "synthetic-ordinary-value") || strings.Contains(stdout, secret) {
|
||||
t.Fatalf("credential boundary changed with present=%t: code=%d out=%q err=%q", present, code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
result, err := appconfig.Load(appconfig.LoadOptions{Path: path, Explicit: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := appconfig.Settings(result.Config)
|
||||
if len(settings.Backends) != 1 || settings.Backends[0].ExtraParams["visible_parameter"] != "synthetic-ordinary-value" {
|
||||
t.Fatalf("runtime backend parameters changed: %+v", settings.Backends)
|
||||
}
|
||||
}
|
||||
|
||||
type errorOutputWriter struct{}
|
||||
|
||||
func (errorOutputWriter) Write([]byte) (int, error) { return 0, errors.New("output unavailable") }
|
||||
|
||||
func TestConfigCommandFailuresDoNotProduceSuccessfulOutput(t *testing.T) {
|
||||
path := writeAppConfigFile(t, "server:\n max_request_bytes: -1\n")
|
||||
for _, mode := range []string{"validate", "show", "sources"} {
|
||||
code, stdout, stderr := runCLICommand(t, configCommand, []string{mode, "--config", path})
|
||||
if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "validate.error") {
|
||||
t.Fatalf("%s misclassified invalid config: code=%d out=%q err=%q", mode, code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
path = writeAppConfigFile(t, "{}\n")
|
||||
var stderr bytes.Buffer
|
||||
if code := configCommand([]string{"show", "--config", path}, errorOutputWriter{}, &stderr); code != ExitRuntimeError || !strings.Contains(stderr.String(), "output error") {
|
||||
t.Fatalf("writer error not reported: code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
if code := configCommand([]string{"sources", "--config", path}, errorOutputWriter{}, io.Discard); code != ExitRuntimeError {
|
||||
t.Fatalf("source writer error returned %d", code)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
||||
return serveCommand(args[1:], stderr)
|
||||
case "inspect":
|
||||
return inspectCommand(args[1:], stdout, stderr)
|
||||
case "config":
|
||||
return configCommand(args[1:], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
@@ -752,10 +754,11 @@ func runErrorMessage(err error) string {
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
|
||||
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect|config> ...")
|
||||
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
|
||||
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
|
||||
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
|
||||
fmt.Fprintln(w, " config: scriptorium config <validate|show|sources> [--config PATH] [--config-overlay NAME] [application overrides]")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func TestLoadDefaultsOnlyAndOverridesBeforeValidation(t *testing.T) {
|
||||
if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) || len(result.Sources()) != 0 {
|
||||
t.Fatalf("defaults-only load: result=%+v err=%v", result, err)
|
||||
}
|
||||
var effective strings.Builder
|
||||
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "render_format: text") || len(result.Sources()) != 0 {
|
||||
t.Fatalf("defaults-only inspection changed: effective=%q err=%v sources=%+v", effective.String(), err, result.Sources())
|
||||
}
|
||||
|
||||
result, err = Load(LoadOptions{Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("server", "max_request_bytes"), Value: int64(0), Flag: "--max-request-bytes",
|
||||
|
||||
Reference in New Issue
Block a user