diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 3549222..5c64946 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -31,6 +31,7 @@ runware serverless apps [flags] * [runware serverless apps builds](runware_serverless_apps_builds.md) - Inspect application builds * [runware serverless apps delete](runware_serverless_apps_delete.md) - Delete a serverless application * [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List endpoints for a serverless application +* [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications * [runware serverless apps logs](runware_serverless_apps_logs.md) - Show logs for a serverless application * [runware serverless apps resume](runware_serverless_apps_resume.md) - Resume a stopped serverless application diff --git a/docs/runware_serverless_apps_env.md b/docs/runware_serverless_apps_env.md new file mode 100644 index 0000000..6a7f417 --- /dev/null +++ b/docs/runware_serverless_apps_env.md @@ -0,0 +1,37 @@ +## runware serverless apps env + +Manage plain-text environment variables for an application + +### Synopsis + +Manage plain-text environment variables on a serverless application. + +These are not organisation secrets. Values are returned by list and set. +Use 'serverless secrets' for encrypted secrets attached as env vars. + +``` +runware serverless apps env [flags] +``` + +### Options + +``` + -h, --help help for env +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [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 unset](runware_serverless_apps_env_unset.md) - Remove an environment variable + diff --git a/docs/runware_serverless_apps_env_list.md b/docs/runware_serverless_apps_env_list.md new file mode 100644 index 0000000..62a17a6 --- /dev/null +++ b/docs/runware_serverless_apps_env_list.md @@ -0,0 +1,45 @@ +## runware serverless apps env list + +List environment variables for a serverless application + +### Synopsis + +List plain-text environment variables for an application, including values. + +To list encrypted secrets attached to an application, use 'serverless secrets attachments'. + +``` +runware serverless apps env list [flags] +``` + +### Examples + +``` + # list environment variables + runware serverless apps env list my-app + + # page through results + runware serverless apps env list my-app --limit 20 --cursor +``` + +### Options + +``` + --cursor string Pagination cursor from a previous nextCursor + -h, --help help for list + --limit int Maximum number of environment variables to return (1-100) +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application + diff --git a/docs/runware_serverless_apps_env_set.md b/docs/runware_serverless_apps_env_set.md new file mode 100644 index 0000000..40c2861 --- /dev/null +++ b/docs/runware_serverless_apps_env_set.md @@ -0,0 +1,54 @@ +## runware serverless apps env set + +Create or update an environment variable + +### Synopsis + +Create or update one plain-text environment variable. + +Prefer --value-file so the value is not visible in process lists; use +--value-file - to read from stdin. + +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. + +``` +runware serverless apps env set [flags] +``` + +### Examples + +``` + # set an environment variable + runware serverless apps env set my-app MY_KEY --value hello + + # read the 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 - +``` + +### 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 +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application + diff --git a/docs/runware_serverless_apps_env_unset.md b/docs/runware_serverless_apps_env_unset.md new file mode 100644 index 0000000..0646aa9 --- /dev/null +++ b/docs/runware_serverless_apps_env_unset.md @@ -0,0 +1,38 @@ +## runware serverless apps env unset + +Remove an environment variable + +### Synopsis + +Remove one plain-text environment variable from an application. + +``` +runware serverless apps env unset [flags] +``` + +### Examples + +``` + # remove an environment variable + runware serverless apps env unset my-app MY_KEY +``` + +### Options + +``` + -h, --help help for unset +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application + diff --git a/internal/api/serverless/env.go b/internal/api/serverless/env.go new file mode 100644 index 0000000..f2be009 --- /dev/null +++ b/internal/api/serverless/env.go @@ -0,0 +1,133 @@ +package serverless + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/runware/runware-cli/internal/api/serverless/gen" + "github.com/runware/runware-cli/internal/api/transport" +) + +// EnvironmentVariable is a plain-text deployment environment variable. +type EnvironmentVariable = gen.EnvironmentVariable + +// EnvironmentVariableUpdate is the request body for updateDeploymentEnvironmentVariable. +type EnvironmentVariableUpdate = gen.EnvironmentVariableUpdate + +// ListDeploymentEnvironmentVariablesParams are optional filters for +// ListDeploymentEnvironmentVariables. +type ListDeploymentEnvironmentVariablesParams = gen.ListDeploymentEnvironmentVariablesParams + +// ListDeploymentEnvironmentVariables returns a page of plain-text environment +// variables for a deployment. Values are included in the response. +func (c *Client) ListDeploymentEnvironmentVariables(ctx context.Context, deploymentID string, params *ListDeploymentEnvironmentVariablesParams) (Page[EnvironmentVariable], error) { + if c.apiKey == "" { + return Page[EnvironmentVariable]{}, transport.ErrNoAPIKey + } + + resp, err := c.inner.ListDeploymentEnvironmentVariablesWithResponse(ctx, deploymentID, params) + if err != nil { + return Page[EnvironmentVariable]{}, fmt.Errorf("list environment variables: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID+"/environment-variables", + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return pageOf[EnvironmentVariable](nil, nil), nil + } + return pageOf(resp.JSON200.Data, resp.JSON200.NextCursor), nil + case http.StatusUnauthorized: + return Page[EnvironmentVariable]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return Page[EnvironmentVariable]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return Page[EnvironmentVariable]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + default: + return Page[EnvironmentVariable]{}, problemFromBody(resp.Body, resp.StatusCode()) + } +} + +// UpdateDeploymentEnvironmentVariable creates or replaces one plain-text +// environment variable on a deployment. +func (c *Client) UpdateDeploymentEnvironmentVariable(ctx context.Context, deploymentID, key string, body EnvironmentVariableUpdate) (*EnvironmentVariable, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.UpdateDeploymentEnvironmentVariableWithResponse(ctx, deploymentID, key, body) + if err != nil { + return nil, fmt.Errorf("update environment variable: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID+"/environment-variables/"+key, + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("update environment variable: empty 200 response") + } + return resp.JSON200, nil + case http.StatusBadRequest: + return nil, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) + case http.StatusUnauthorized: + return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusUnprocessableEntity: + return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + default: + return nil, problemFromBody(resp.Body, resp.StatusCode()) + } +} + +// DeleteDeploymentEnvironmentVariable removes one plain-text environment +// variable from a deployment. +func (c *Client) DeleteDeploymentEnvironmentVariable(ctx context.Context, deploymentID, key string) error { + if c.apiKey == "" { + return transport.ErrNoAPIKey + } + + resp, err := c.inner.DeleteDeploymentEnvironmentVariableWithResponse(ctx, deploymentID, key) + if err != nil { + return fmt.Errorf("delete environment variable: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID+"/environment-variables/"+key, + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + default: + return problemFromBody(resp.Body, resp.StatusCode()) + } +} diff --git a/internal/api/serverless/env_test.go b/internal/api/serverless/env_test.go new file mode 100644 index 0000000..b7899aa --- /dev/null +++ b/internal/api/serverless/env_test.go @@ -0,0 +1,201 @@ +package serverless + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/runware/runware-cli/internal/api/transport" +) + +const ( + testEnvVarName = "MY_KEY" + testEnvVarValue = "hello" +) + +func TestListDeploymentEnvironmentVariables(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/deployments/" + testDeploymentID + "/environment-variables" + if r.Method != http.MethodGet || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("limit query = %q, want 10", got) + } + if got := r.URL.Query().Get("cursor"); got != testCursorPage2 { + t.Errorf("cursor query = %q, want %s", got, testCursorPage2) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{ + "id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "deploymentId":"my-app", + "key":"` + testEnvVarName + `", + "value":"` + testEnvVarValue + `", + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }],"nextCursor":"` + testCursorPage3 + `"}`)) + })) + defer srv.Close() + + limit := Limit(10) + cursor := Cursor(testCursorPage2) + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + page, err := c.ListDeploymentEnvironmentVariables(context.Background(), testDeploymentID, &ListDeploymentEnvironmentVariablesParams{ + Limit: &limit, + Cursor: &cursor, + }) + if err != nil { + t.Fatalf("ListDeploymentEnvironmentVariables: %v", err) + } + if len(page.Data) != 1 || page.Data[0].Key != testEnvVarName { + t.Fatalf("unexpected env vars: %+v", page.Data) + } + if page.Data[0].Value != testEnvVarValue { + t.Errorf("unexpected value: %q", page.Data[0].Value) + } + if page.NextCursor == nil || *page.NextCursor != testCursorPage3 { + t.Fatalf("unexpected nextCursor: %+v", page.NextCursor) + } +} + +func TestListDeploymentEnvironmentVariables_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.ListDeploymentEnvironmentVariables(context.Background(), testDeploymentID, nil); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + +func TestUpdateDeploymentEnvironmentVariable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/deployments/" + testDeploymentID + "/environment-variables/" + testEnvVarName + if r.Method != http.MethodPut || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var body EnvironmentVariableUpdate + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Value != testEnvVarValue { + t.Errorf("unexpected body: %s", raw) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "deploymentId":"my-app", + "key":"` + testEnvVarName + `", + "value":"` + testEnvVarValue + `", + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + ev, err := c.UpdateDeploymentEnvironmentVariable(context.Background(), testDeploymentID, testEnvVarName, EnvironmentVariableUpdate{ + Value: testEnvVarValue, + }) + if err != nil { + t.Fatalf("UpdateDeploymentEnvironmentVariable: %v", err) + } + if ev.Key != testEnvVarName || ev.Value != testEnvVarValue { + t.Errorf("unexpected env var: %+v", ev) + } +} + +func TestUpdateDeploymentEnvironmentVariable_Unprocessable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{ + "type":"about:blank", + "title":"Unprocessable Entity", + "status":422, + "detail":"RUNTIME is a reserved platform name", + "errors":[{"detail":"reserved platform name","pointer":"/variableName"}] + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.UpdateDeploymentEnvironmentVariable(context.Background(), testDeploymentID, "RUNTIME", EnvironmentVariableUpdate{ + Value: "x", + }) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeValidation { + t.Errorf("expected CodeValidation, got %v", re.Code) + } + if re.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("expected status 422, got %d", re.StatusCode) + } + if !strings.Contains(re.Message, "RUNTIME is a reserved platform name") { + t.Errorf("missing detail: %q", re.Message) + } + if !strings.Contains(re.Message, "/variableName: reserved platform name") { + t.Errorf("missing field error: %q", re.Message) + } +} + +func TestUpdateDeploymentEnvironmentVariable_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.UpdateDeploymentEnvironmentVariable(context.Background(), testDeploymentID, testEnvVarName, EnvironmentVariableUpdate{}); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + +func TestDeleteDeploymentEnvironmentVariable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/deployments/" + testDeploymentID + "/environment-variables/" + testEnvVarName + if r.Method != http.MethodDelete || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + if err := c.DeleteDeploymentEnvironmentVariable(context.Background(), testDeploymentID, testEnvVarName); err != nil { + t.Fatalf("DeleteDeploymentEnvironmentVariable: %v", err) + } +} + +func TestDeleteDeploymentEnvironmentVariable_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No environment variable '` + testEnvVarName + `' exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.DeleteDeploymentEnvironmentVariable(context.Background(), testDeploymentID, testEnvVarName) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", re.Code) + } + if re.Message != "No environment variable '"+testEnvVarName+"' exists" { + t.Errorf("unexpected message: %q", re.Message) + } +} + +func TestDeleteDeploymentEnvironmentVariable_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if err := c.DeleteDeploymentEnvironmentVariable(context.Background(), testDeploymentID, testEnvVarName); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 1e0527b..f7acd05 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -23,6 +23,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsListCmd(logger), newAppsShowCmd(logger), newAppsEndpointsCmd(logger), + newAppsEnvCmd(logger), newAppsVersionsCmd(logger), newAppsBuildsCmd(logger), newAppsLogsCmd(), diff --git a/internal/cmd/serverless/apps_env.go b/internal/cmd/serverless/apps_env.go new file mode 100644 index 0000000..c230a35 --- /dev/null +++ b/internal/cmd/serverless/apps_env.go @@ -0,0 +1,165 @@ +package serverless + +import ( + "fmt" + "log/slog" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +func newAppsEnvCmd(logger *log.Logger) *cobra.Command { + cmd := stubGroup("env", "Manage plain-text environment variables for an application") + cmd.Long = `Manage plain-text environment variables on a serverless application. + +These are not organisation secrets. Values are returned by list and set. +Use 'serverless secrets' for encrypted secrets attached as env vars.` + cmd.AddCommand( + newAppsEnvListCmd(logger), + newAppsEnvSetCmd(logger), + newAppsEnvUnsetCmd(logger), + ) + return cmd +} + +func newAppsEnvListCmd(logger *log.Logger) *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List environment variables for a serverless application", + Long: `List plain-text environment variables for an application, including values. + +To list encrypted secrets attached to an application, use 'serverless secrets attachments'.`, + Example: ` # list environment variables + runware serverless apps env list my-app + + # page through results + runware serverless apps env list my-app --limit 20 --cursor `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateListLimit(limit); err != nil { + return err + } + id := args[0] + var params *serverlessapi.ListDeploymentEnvironmentVariablesParams + if limit > 0 || cursor != "" { + params = &serverlessapi.ListDeploymentEnvironmentVariablesParams{} + params.Limit, params.Cursor = listPageParams(limit, cursor) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching environment variables for %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.ListDeploymentEnvironmentVariables(cmd.Context(), id, params) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printPage(cmdutil.FormatFor(cmd), page, envVarsResult(page.Data), cmd.ErrOrStderr(), "") + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environment variables to return (1-100)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + return cmd +} + +func newAppsEnvSetCmd(logger *log.Logger) *cobra.Command { + var ( + value string + valueFile string + ) + + cmd := &cobra.Command{ + Use: "set ", + Short: "Create or update an environment variable", + Long: `Create or update one plain-text environment variable. + +Prefer --value-file so the value is not visible in process lists; use +--value-file - to read from stdin. + +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 + runware serverless apps env set my-app MY_KEY --value hello + + # read the 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), + RunE: func(cmd *cobra.Command, args []string) error { + app := args[0] + key := args[1] + 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.UpdateDeploymentEnvironmentVariable(cmd.Context(), app, key, serverlessapi.EnvironmentVariableUpdate{ + Value: v, + }) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), envVarResult(*ev)) + }, + } + + 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.MarkFlagsMutuallyExclusive("value", "value-file") + cmd.MarkFlagsOneRequired("value", "value-file") + return cmd +} + +func newAppsEnvUnsetCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "unset ", + Short: "Remove an environment variable", + Long: "Remove one plain-text environment variable from an application.", + Example: ` # remove an environment variable + runware serverless apps env unset my-app MY_KEY`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + app := args[0] + key := args[1] + + spin := cmdutil.NewSpinner(fmt.Sprintf("Removing environment variable %s...", key)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + if err := client.DeleteDeploymentEnvironmentVariable(cmd.Context(), app, key); err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), envUnsetResult{ + DeploymentID: app, + Key: key, + }) + }, + } +} diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index e869988..374fbae 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -15,10 +15,12 @@ const ( colName = "Name" colStatus = "Status" colCreated = "Created" + colUpdated = "Updated" colType = "Type" colField = "Field" colValue = "Value" colApp = "App" + colKey = "Key" colEnvVar = "Env var" ) @@ -305,6 +307,57 @@ func (r secretDetachResult) Rows() [][]any { return [][]any{{r.DeploymentID, r.Name}} } +// envVarResult wraps a single plain-text environment variable for table/json/yaml display. +type envVarResult serverlessapi.EnvironmentVariable + +func (r envVarResult) Headers() []string { + return []string{colKey, colValue, colCreated, colUpdated} +} + +func (r envVarResult) Rows() [][]any { + return [][]any{{ + r.Key, + r.Value, + formatOptionalTime(r.CreatedAt), + formatOptionalTime(r.UpdatedAt), + }} +} + +// envVarsResult wraps environment variable lists for table display. +type envVarsResult []serverlessapi.EnvironmentVariable + +func (r envVarsResult) Headers() []string { + return []string{colKey, colValue, colCreated, colUpdated} +} + +func (r envVarsResult) Rows() [][]any { + rows := make([][]any, len(r)) + for i := range r { + e := &r[i] + rows[i] = []any{ + e.Key, + e.Value, + formatOptionalTime(e.CreatedAt), + formatOptionalTime(e.UpdatedAt), + } + } + return rows +} + +// envUnsetResult is the success payload for removing an environment variable. +type envUnsetResult struct { + DeploymentID string `json:"deploymentId" yaml:"deploymentId"` + Key string `json:"key" yaml:"key"` +} + +func (r envUnsetResult) Headers() []string { + return []string{colApp, colKey} +} + +func (r envUnsetResult) Rows() [][]any { + return [][]any{{r.DeploymentID, r.Key}} +} + // printPage prints a cursor-paginated list. JSON/YAML use the API page shape // (data + nextCursor). Table format renders rows, then hints at --cursor on errOut // (typically cmd.ErrOrStderr()). diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index fd52e21..9fd9498 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -11,7 +11,11 @@ import ( "github.com/runware/runware-cli/internal/output" ) -const testAppID = "my-app" +const ( + testAppID = "my-app" + testEnvKey = "MY_KEY" + testEnvValue = "hello" +) func TestListPageParams(t *testing.T) { limit, cursor := listPageParams(0, "") @@ -202,6 +206,51 @@ func TestSecretsResult_NoValueColumn(t *testing.T) { } } +func TestEnvVarsResult_ShowsValue(t *testing.T) { + created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + ev := serverlessapi.EnvironmentVariable{ + Key: testEnvKey, + Value: testEnvValue, + CreatedAt: &created, + UpdatedAt: &created, + } + + tables := []output.Tabular{ + envVarsResult{ev}, + envVarResult(ev), + } + for _, table := range tables { + hasValue := false + for _, h := range table.Headers() { + if h == colValue { + hasValue = true + break + } + } + if !hasValue { + t.Fatalf("%T table must include a Value column: %v", table, table.Headers()) + } + rows := table.Rows() + if len(rows) != 1 { + t.Fatalf("%T: expected 1 row, got %d", table, len(rows)) + } + if rows[0][0] != testEnvKey || rows[0][1] != testEnvValue { + t.Fatalf("%T: unexpected row %#v", table, rows[0]) + } + } + + unset := envUnsetResult{DeploymentID: testAppID, Key: testEnvKey} + for _, h := range unset.Headers() { + if strings.EqualFold(h, "value") { + t.Fatalf("envUnsetResult must not include a Value column: %v", unset.Headers()) + } + } + rows := unset.Rows() + if len(rows) != 1 || rows[0][0] != testAppID || rows[0][1] != testEnvKey { + t.Fatalf("envUnsetResult: unexpected row %#v", rows) + } +} + func TestVersionsResult_NilBuildID(t *testing.T) { rows := (versionsResult{{ Id: uuid.MustParse("22222222-2222-2222-2222-222222222222"), diff --git a/internal/cmd/serverless/secrets.go b/internal/cmd/serverless/secrets.go index 72b0af6..fa13624 100644 --- a/internal/cmd/serverless/secrets.go +++ b/internal/cmd/serverless/secrets.go @@ -4,11 +4,8 @@ import ( "context" "errors" "fmt" - "io" "log/slog" "net/http" - "os" - "strings" "github.com/charmbracelet/log" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -113,7 +110,7 @@ in process lists; use --value-file - to read from stdin.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - secretValue, err := readSecretValue(value, valueFile, cmd.InOrStdin()) + secretValue, err := readValueFlag(value, valueFile, cmd.InOrStdin()) if err != nil { return err } @@ -296,31 +293,6 @@ organisation secret.`, return cmd } -func readSecretValue(value, valueFile string, stdin io.Reader) (string, error) { - if valueFile == "" { - return value, nil - } - - var ( - raw []byte - err error - ) - if valueFile == "-" { - raw, err = io.ReadAll(stdin) - if err != nil { - return "", fmt.Errorf("read secret from stdin: %w", err) - } - } else { - raw, err = os.ReadFile(valueFile) - if err != nil { - return "", fmt.Errorf("read secret from %s: %w", valueFile, err) - } - } - - s := strings.TrimSuffix(string(raw), "\n") - return strings.TrimSuffix(s, "\r"), nil -} - func createOrUpdateSecret(ctx context.Context, client *serverlessapi.Client, name, value string) (*serverlessapi.Secret, error) { sec, err := client.CreateSecret(ctx, serverlessapi.SecretCreate{ Name: name, diff --git a/internal/cmd/serverless/secrets_test.go b/internal/cmd/serverless/secrets_test.go index 2df480c..3f04f02 100644 --- a/internal/cmd/serverless/secrets_test.go +++ b/internal/cmd/serverless/secrets_test.go @@ -6,9 +6,6 @@ import ( "log/slog" "net/http" "net/http/httptest" - "os" - "path/filepath" - "strings" "testing" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -96,44 +93,3 @@ func TestCreateOrUpdateSecret_ConflictThenNotFoundKeepsCreateError(t *testing.T) t.Errorf("unexpected message: %q", re.Message) } } - -func TestReadSecretValue_FromFlag(t *testing.T) { - got, err := readSecretValue("keep\n", "", strings.NewReader("ignored")) - if err != nil { - t.Fatalf("readSecretValue: %v", err) - } - if got != "keep\n" { - t.Fatalf("flag value should be used as-is, got %q", got) - } -} - -func TestReadSecretValue_FromFileStripsTrailingNewline(t *testing.T) { - path := filepath.Join(t.TempDir(), "secret.txt") - if err := os.WriteFile(path, []byte("s3cret\r\n"), 0o600); err != nil { - t.Fatal(err) - } - got, err := readSecretValue("", path, strings.NewReader("")) - if err != nil { - t.Fatalf("readSecretValue: %v", err) - } - if got != "s3cret" { - t.Fatalf("got %q, want s3cret", got) - } -} - -func TestReadSecretValue_FromStdin(t *testing.T) { - got, err := readSecretValue("", "-", strings.NewReader("from-stdin\n")) - if err != nil { - t.Fatalf("readSecretValue: %v", err) - } - if got != "from-stdin" { - t.Fatalf("got %q, want from-stdin", got) - } -} - -func TestReadSecretValue_MissingFile(t *testing.T) { - _, err := readSecretValue("", filepath.Join(t.TempDir(), "missing.txt"), strings.NewReader("")) - if err == nil { - t.Fatal("expected error for missing file") - } -} diff --git a/internal/cmd/serverless/value.go b/internal/cmd/serverless/value.go new file mode 100644 index 0000000..f7fa54a --- /dev/null +++ b/internal/cmd/serverless/value.go @@ -0,0 +1,36 @@ +package serverless + +import ( + "fmt" + "io" + "os" + "strings" +) + +// readValueFlag returns a string from --value, or from --value-file (a path, +// or "-" for stdin). A trailing newline is stripped from file/stdin so typical +// text files and printf pipelines do not keep a spurious \n. +func readValueFlag(value, valueFile string, stdin io.Reader) (string, error) { + if valueFile == "" { + return value, nil + } + + var ( + raw []byte + err error + ) + if valueFile == "-" { + raw, err = io.ReadAll(stdin) + if err != nil { + return "", fmt.Errorf("read value from stdin: %w", err) + } + } else { + raw, err = os.ReadFile(valueFile) + if err != nil { + return "", fmt.Errorf("read value from %s: %w", valueFile, err) + } + } + + s := strings.TrimSuffix(string(raw), "\n") + return strings.TrimSuffix(s, "\r"), nil +} diff --git a/internal/cmd/serverless/value_test.go b/internal/cmd/serverless/value_test.go new file mode 100644 index 0000000..b28bcbb --- /dev/null +++ b/internal/cmd/serverless/value_test.go @@ -0,0 +1,49 @@ +package serverless + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadValueFlag_FromFlag(t *testing.T) { + got, err := readValueFlag("keep\n", "", strings.NewReader("ignored")) + if err != nil { + t.Fatalf("readValueFlag: %v", err) + } + if got != "keep\n" { + t.Fatalf("flag value should be used as-is, got %q", got) + } +} + +func TestReadValueFlag_FromFileStripsTrailingNewline(t *testing.T) { + path := filepath.Join(t.TempDir(), "value.txt") + if err := os.WriteFile(path, []byte("hello\r\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := readValueFlag("", path, strings.NewReader("")) + if err != nil { + t.Fatalf("readValueFlag: %v", err) + } + if got != "hello" { + t.Fatalf("got %q, want hello", got) + } +} + +func TestReadValueFlag_FromStdin(t *testing.T) { + got, err := readValueFlag("", "-", strings.NewReader("from-stdin\n")) + if err != nil { + t.Fatalf("readValueFlag: %v", err) + } + if got != "from-stdin" { + t.Fatalf("got %q, want from-stdin", got) + } +} + +func TestReadValueFlag_MissingFile(t *testing.T) { + _, err := readValueFlag("", filepath.Join(t.TempDir(), "missing.txt"), strings.NewReader("")) + if err == nil { + t.Fatal("expected error for missing file") + } +}