diff --git a/docs/runware_serverless_apps_env.md b/docs/runware_serverless_apps_env.md index 6a7f417..b2b7545 100644 --- a/docs/runware_serverless_apps_env.md +++ b/docs/runware_serverless_apps_env.md @@ -32,6 +32,6 @@ runware serverless apps env [flags] * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications * [runware serverless apps env list](runware_serverless_apps_env_list.md) - List environment variables for a serverless application -* [runware serverless apps env set](runware_serverless_apps_env_set.md) - Create or update an environment variable +* [runware serverless apps env set](runware_serverless_apps_env_set.md) - Create or update environment variables * [runware serverless apps env unset](runware_serverless_apps_env_unset.md) - Remove an environment variable diff --git a/docs/runware_serverless_apps_env_set.md b/docs/runware_serverless_apps_env_set.md index 5ebf5d6..36c5a64 100644 --- a/docs/runware_serverless_apps_env_set.md +++ b/docs/runware_serverless_apps_env_set.md @@ -1,19 +1,26 @@ ## runware serverless apps env set -Create or update an environment variable +Create or update environment variables ### Synopsis -Create or update one plain-text environment variable. +Create or update plain-text environment variables. -Prefer --value-file so the value is not visible in process lists; use ---value-file - to read from stdin. +A single key is the [key] argument with --value or --value-file. Prefer +--value-file so the value is not visible in process lists; use --value-file - +to read from stdin. A single-key write during an in-flight rollout returns +409 and does not store the change. + +Several keys are repeatable --env KEY=VALUE, or --env-file. The command reads +the current set, merges these keys in, and writes the set once, so one rollout +carries all of them. Keys you do not mention stay when no other writer changes +the set between the read and the write. That write returns 409 while a create +or resume rollout is already in progress, and does not store the change. A change records a new version with the same image and rolls the workload when the app is active, initializing, or failed and its image is deployable. A stopped or stopping app applies it on resume. An unchanged value records no -version. A write during an in-flight rollout returns 409 and does not store -the value. +version. The server rejects (HTTP 422) reserved platform names, names that collide with an attached secret's injected env var, and adding a binding past the @@ -21,28 +28,36 @@ with an attached secret's injected env var, and adding a binding past the allowed. ``` -runware serverless apps env set [flags] +runware serverless apps env set [key] [flags] ``` ### Examples ``` - # set an environment variable + # set one environment variable runware serverless apps env set my-app MY_KEY --value hello - # read the value from a file + # read one value from a file runware serverless apps env set my-app MY_KEY --value-file ./value.txt - # read the value from stdin + # read one value from stdin printf '%s' "$MY_VALUE" | runware serverless apps env set my-app MY_KEY --value-file - + + # set several keys in one rollout + runware serverless apps env set my-app --env FOO=bar --env BAZ=qux + + # set several keys from a file, in one rollout + runware serverless apps env set my-app --env-file .env.deploy ``` ### Options ``` - -h, --help help for set - --value string Variable value (visible in process lists; prefer --value-file) - --value-file string Read variable value from a file, or - for stdin + --env stringArray Environment variable as KEY=VALUE, merged and written once (repeatable) + --env-file stringArray File of KEY=VALUE lines to merge and write once (repeatable) + -h, --help help for set + --value string Variable value for a single (visible in process lists; prefer --value-file) + --value-file string Read one value from a file, or - for stdin ``` ### Options inherited from parent commands diff --git a/internal/cmd/serverless/env.go b/internal/cmd/serverless/env.go index 127ddb5..5159f6d 100644 --- a/internal/cmd/serverless/env.go +++ b/internal/cmd/serverless/env.go @@ -1,10 +1,12 @@ package serverless import ( + "context" "fmt" "log/slog" "os" "regexp" + "slices" "strings" "unicode/utf8" @@ -83,67 +85,185 @@ func newAppsEnvSetCmd(logger *log.Logger) *cobra.Command { var ( value string valueFile string + envPairs []string + envFiles []string ) cmd := &cobra.Command{ - Use: "set ", - Short: "Create or update an environment variable", - Long: `Create or update one plain-text environment variable. + Use: "set [key]", + Short: "Create or update environment variables", + Long: `Create or update plain-text environment variables. -Prefer --value-file so the value is not visible in process lists; use ---value-file - to read from stdin. +A single key is the [key] argument with --value or --value-file. Prefer +--value-file so the value is not visible in process lists; use --value-file - +to read from stdin. A single-key write during an in-flight rollout returns +409 and does not store the change. + +Several keys are repeatable --env KEY=VALUE, or --env-file. The command reads +the current set, merges these keys in, and writes the set once, so one rollout +carries all of them. Keys you do not mention stay when no other writer changes +the set between the read and the write. That write returns 409 while a create +or resume rollout is already in progress, and does not store the change. A change records a new version with the same image and rolls the workload when the app is active, initializing, or failed and its image is deployable. A stopped or stopping app applies it on resume. An unchanged value records no -version. A write during an in-flight rollout returns 409 and does not store -the value. +version. The server rejects (HTTP 422) reserved platform names, names that collide with an attached secret's injected env var, and adding a binding past the 100-variable-plus-secret ceiling. Overwriting an existing key is always allowed.`, - Example: ` # set an environment variable + Example: ` # set one environment variable runware serverless apps env set my-app MY_KEY --value hello - # read the value from a file + # read one value from a file runware serverless apps env set my-app MY_KEY --value-file ./value.txt - # read the value from stdin - printf '%s' "$MY_VALUE" | runware serverless apps env set my-app MY_KEY --value-file -`, - Args: cobra.ExactArgs(2), + # read one value from stdin + printf '%s' "$MY_VALUE" | runware serverless apps env set my-app MY_KEY --value-file - + + # set several keys in one rollout + runware serverless apps env set my-app --env FOO=bar --env BAZ=qux + + # set several keys from a file, in one rollout + runware serverless apps env set my-app --env-file .env.deploy`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - app := args[0] - key := args[1] - v, err := readValueFlag(value, valueFile, cmd.InOrStdin()) + appID := args[0] + hasSingleValue := cmd.Flags().Changed("value") || cmd.Flags().Changed("value-file") + bulk := len(envPairs) > 0 || len(envFiles) > 0 + if len(args) == 2 { + if bulk { + return fmt.Errorf("%s", envSetUsage) + } + return setOneEnvironmentVariable(cmd, logger, appID, args[1], value, valueFile) + } + if hasSingleValue { + return fmt.Errorf("%s", envSetUsage) + } + updates, err := buildEnvironmentVariables(envFiles, envPairs) if err != nil { return err } + if updates == nil || len(*updates) == 0 { + return fmt.Errorf("%s", envSetUsage) + } - spin := cmdutil.NewSpinner(fmt.Sprintf("Saving environment variable %s...", key)) + spin := cmdutil.NewSpinner(fmt.Sprintf("Saving %d environment variables...", len(*updates))) spin.Start() client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) - ev, err := client.UpdateAppEnvironmentVariable(cmd.Context(), app, key, serverlessapi.EnvironmentVariableUpdate{ - Value: v, - }) - if err != nil { + if err := applyEnvUpdates(cmd.Context(), client, appID, *updates); err != nil { spin.Stop() return err } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), envVarResult(*ev)) + return output.Print(cmdutil.FormatFor(cmd), envVarsResult(envVarsFromMap(*updates))) }, } - cmd.Flags().StringVar(&value, "value", "", "Variable value (visible in process lists; prefer --value-file)") - cmd.Flags().StringVar(&valueFile, "value-file", "", "Read variable value from a file, or - for stdin") + cmd.Flags().StringVar(&value, "value", "", "Variable value for a single (visible in process lists; prefer --value-file)") + cmd.Flags().StringVar(&valueFile, "value-file", "", "Read one value from a file, or - for stdin") + cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable as KEY=VALUE, merged and written once (repeatable)") + cmd.Flags().StringArrayVar(&envFiles, "env-file", nil, "File of KEY=VALUE lines to merge and write once (repeatable)") cmd.MarkFlagsMutuallyExclusive("value", "value-file") - cmd.MarkFlagsOneRequired("value", "value-file") return cmd } +const envSetUsage = "pass with --value or --value-file, or use --env / --env-file" + +func setOneEnvironmentVariable(cmd *cobra.Command, logger *log.Logger, appID, key, value, valueFile string) error { + if !cmd.Flags().Changed("value") && !cmd.Flags().Changed("value-file") { + return fmt.Errorf("%s", envSetUsage) + } + v, err := readValueFlag(value, valueFile, cmd.InOrStdin()) + if err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Saving environment variable %s...", key)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + ev, err := client.UpdateAppEnvironmentVariable(cmd.Context(), appID, key, serverlessapi.EnvironmentVariableUpdate{ + Value: v, + }) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), envVarResult(*ev)) +} + +// applyEnvUpdates merges updates into the app's current variables and writes +// the whole set in one request. A key absent from updates is kept. The API +// treats the map as a replacement, so sending only the new keys would delete +// the rest. +func applyEnvUpdates(ctx context.Context, client *serverlessapi.Client, appID string, updates map[string]string) error { + existing, err := listEnvironmentVariables(ctx, client, appID) + if err != nil { + return err + } + merged := envReplacement(existing, updates) + _, err = client.UpdateApp(ctx, appID, serverlessapi.AppUpdate{ + EnvironmentVariables: &merged, + }) + return err +} + +func listEnvironmentVariables(ctx context.Context, client *serverlessapi.Client, appID string) (map[string]string, error) { + out := map[string]string{} + var cursor string + for { + params := &serverlessapi.ListAppEnvironmentVariablesParams{} + params.Limit, params.Cursor = listPageParams(maxEnvVars, cursor) + page, err := client.ListAppEnvironmentVariables(ctx, appID, params) + if err != nil { + return nil, err + } + for i := range page.Data { + out[page.Data[i].Key] = page.Data[i].Value + } + if page.NextCursor == nil || *page.NextCursor == "" { + return out, nil + } + cursor = *page.NextCursor + } +} + +func envReplacement(existing, updates map[string]string) map[string]*string { + out := make(map[string]*string, len(existing)+len(updates)) + for key, value := range existing { + val := value + out[key] = &val + } + for key, value := range updates { + val := value + out[key] = &val + } + return out +} + +func envVarsFromMap(updates map[string]string) []serverlessapi.EnvironmentVariable { + keys := make([]string, 0, len(updates)) + for key := range updates { + keys = append(keys, key) + } + slices.Sort(keys) + out := make([]serverlessapi.EnvironmentVariable, len(keys)) + for i, key := range keys { + out[i] = serverlessapi.EnvironmentVariable{ + Key: key, + Value: updates[key], + } + } + return out +} + func newAppsEnvUnsetCmd(logger *log.Logger) *cobra.Command { return &cobra.Command{ Use: "unset ", @@ -180,7 +300,7 @@ rollout returns 409 and does not remove the value.`, } // --------------------------------------------------------------------------- -// Create-time environment variables, for `deploy --env` / `--env-file`. +// --env / --env-file parsing, shared by deploy and apps env set. // --------------------------------------------------------------------------- // Environment variable limits, mirrored from the server's EnvironmentVariableName @@ -197,12 +317,8 @@ const ( // the archive has already been uploaded. var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) -// buildEnvironmentVariables turns --env KEY=VALUE pairs and --env-file paths into -// the create request's map. After the app exists, 'apps env set' and 'apps env -// unset' record a new version with the same image and roll the workload; this -// helper only builds the create-time map. -// -// Files are read before the inline pairs are applied, so an explicit --env wins +// buildEnvironmentVariables turns --env KEY=VALUE pairs and --env-file paths +// into a name-to-value map. Files are read first, so an explicit --env wins // over a file entry with the same name. func buildEnvironmentVariables(files, pairs []string) (*map[string]string, error) { if len(files) == 0 && len(pairs) == 0 { diff --git a/internal/cmd/serverless/env_test.go b/internal/cmd/serverless/env_test.go index c59fc43..15b25e7 100644 --- a/internal/cmd/serverless/env_test.go +++ b/internal/cmd/serverless/env_test.go @@ -1,10 +1,20 @@ package serverless import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" ) func TestBuildEnvironmentVariables(t *testing.T) { @@ -241,3 +251,117 @@ func TestBuildEnvironmentVariables_LimitIsCountedInRunes(t *testing.T) { t.Error("expected a rejection one character past the limit") } } + +func TestEnvReplacement_KeepsUnmentionedKeys(t *testing.T) { + got := envReplacement( + map[string]string{"KEEP": "old"}, + map[string]string{"NEW_KEY": "n"}, + ) + if got["KEEP"] == nil || *got["KEEP"] != "old" || got["NEW_KEY"] == nil || *got["NEW_KEY"] != "n" { + t.Fatalf("replacement = %#v", derefEnv(got)) + } +} + +func TestApplyEnvUpdates_MergesIntoOneRequest(t *testing.T) { + var patches int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "environment-variables") { + if r.URL.Query().Get("cursor") == "" { + _, _ = w.Write([]byte(`{"data":[{"key":"KEEP","value":"old"}],"nextCursor":"page2"}`)) + return + } + _, _ = w.Write([]byte(`{"data":[{"key":"KEEP_TOO","value":"also"}]}`)) + return + } + if r.Method != http.MethodPatch { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + return + } + patches++ + var body serverlessapi.AppUpdate + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode: %v", err) + return + } + if body.EnvironmentVariables == nil { + t.Error("missing environmentVariables") + return + } + got := derefEnv(*body.EnvironmentVariables) + if got["KEEP"] != "old" || got["KEEP_TOO"] != "also" || got["NEW_KEY"] != "n" { + t.Errorf("environmentVariables = %#v", got) + } + _, _ = w.Write([]byte(activeAppBody(""))) + })) + defer srv.Close() + + client := serverlessapi.NewClient("test-key", srv.URL, slog.Default()) + err := applyEnvUpdates(context.Background(), client, testAppID, map[string]string{"NEW_KEY": "n"}) + if err != nil { + t.Fatalf("applyEnvUpdates: %v", err) + } + if patches != 1 { + t.Fatalf("patches = %d, want 1", patches) + } +} + +func TestEnvSet_RejectsMixedForms(t *testing.T) { + cmd := newAppsEnvSetCmd(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testAppID, testEnvKey, "--value", testEnvValue, "--env", "NEW_KEY=n"}) + err := cmd.Execute() + if err == nil || err.Error() != envSetUsage { + t.Fatalf("err = %v", err) + } +} + +func TestEnvSet_EmptyValueStillSets(t *testing.T) { + var puts int + var gotValue *string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPut { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + return + } + puts++ + var body serverlessapi.EnvironmentVariableUpdate + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode: %v", err) + return + } + value := body.Value + gotValue = &value + _, _ = w.Write([]byte(`{"key":"` + testEnvKey + `","value":""}`)) + })) + defer srv.Close() + + t.Setenv("RUNWARE_API_KEY", "test-key") + t.Setenv("RUNWARE_SERVERLESS_BASE_URL", srv.URL) + + cmd := newAppsEnvSetCmd(log.New(io.Discard)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testAppID, testEnvKey, "--value", ""}) + if err := cmd.Execute(); err != nil { + t.Fatalf("env set: %v", err) + } + if puts != 1 { + t.Fatalf("puts = %d, want 1", puts) + } + if gotValue == nil || *gotValue != "" { + t.Fatalf("value = %v, want empty", gotValue) + } +} + +func derefEnv(in map[string]*string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + if value != nil { + out[key] = *value + } + } + return out +}