diff --git a/integration-tests/api_curl_test.go b/integration-tests/api_curl_test.go
index 414eb34e4..17658947f 100644
--- a/integration-tests/api_curl_test.go
+++ b/integration-tests/api_curl_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os/exec"
"strings"
+ "sync"
"testing"
"github.com/go-chi/chi/v5"
@@ -13,8 +14,41 @@ import (
"github.com/stretchr/testify/assert"
)
+// tokenState holds mutable token state shared between the test and HTTP handlers.
+type tokenState struct {
+ mu sync.Mutex
+ validToken string
+ tokenFetches int
+}
+
+func (s *tokenState) setToken(token string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.validToken = token
+}
+
+func (s *tokenState) getToken() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.validToken
+}
+
+// fetchToken increments the fetch count and returns the current valid token.
+func (s *tokenState) fetchToken() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.tokenFetches++
+ return s.validToken
+}
+
+func (s *tokenState) getFetches() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.tokenFetches
+}
+
func TestApiCurlCommand(t *testing.T) {
- validToken := "valid-token"
+ state := &tokenState{validToken: "valid-token"}
mux := chi.NewMux()
if testing.Verbose() {
@@ -23,7 +57,7 @@ func TestApiCurlCommand(t *testing.T) {
mux.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/oauth2") {
- if r.Header.Get("Authorization") != "Bearer "+validToken {
+ if r.Header.Get("Authorization") != "Bearer "+state.getToken() {
w.WriteHeader(http.StatusUnauthorized)
//nolint:lll
_ = json.NewEncoder(w).Encode(map[string]any{"error": "invalid_token", "error_description": "Invalid access token."})
@@ -33,11 +67,10 @@ func TestApiCurlCommand(t *testing.T) {
next.ServeHTTP(w, r)
})
})
- var tokenFetches int
mux.Post("/oauth2/token", func(w http.ResponseWriter, _ *http.Request) {
+ tok := state.fetchToken()
w.WriteHeader(http.StatusOK)
- tokenFetches++
- _ = json.NewEncoder(w).Encode(map[string]any{"access_token": validToken, "expires_in": 900, "token_type": "bearer"})
+ _ = json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "expires_in": 900, "token_type": "bearer"})
})
mux.Get("/users/me", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"id": "userID", "email": "me@example.com"})
@@ -52,24 +85,24 @@ func TestApiCurlCommand(t *testing.T) {
// Load the first token.
assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
- assert.Equal(t, 1, tokenFetches)
+ assert.Equal(t, 1, state.getFetches())
// Revoke the access token and try the command again.
// The old token should be considered invalid, so the API call should return 401,
// and then the CLI should refresh the token and retry.
- validToken = "new-valid-token"
+ state.setToken("new-valid-token")
assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
- assert.Equal(t, 2, tokenFetches)
+ assert.Equal(t, 2, state.getFetches())
assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
- assert.Equal(t, 2, tokenFetches)
+ assert.Equal(t, 2, state.getFetches())
// If --no-retry-401 and --fail are provided then the command should return exit code 22.
- validToken = "another-new-valid-token"
+ state.setToken("another-new-valid-token")
stdOut, _, err := f.RunCombinedOutput("api:curl", "/fake-api-path", "--no-retry-401", "--fail")
exitErr := &exec.ExitError{}
assert.ErrorAs(t, err, &exitErr)
assert.Equal(t, 22, exitErr.ExitCode())
assert.Empty(t, stdOut)
- assert.Equal(t, 2, tokenFetches)
+ assert.Equal(t, 2, state.getFetches())
}
diff --git a/integration-tests/variable_write_test.go b/integration-tests/variable_write_test.go
index 14989405c..3e497701f 100644
--- a/integration-tests/variable_write_test.go
+++ b/integration-tests/variable_write_test.go
@@ -9,29 +9,54 @@ import (
"github.com/upsun/cli/pkg/mockapi"
)
-func TestVariableCreate(t *testing.T) {
+// variableTestSetup holds common test infrastructure for variable tests.
+type variableTestSetup struct {
+ authServer *httptest.Server
+ apiServer *httptest.Server
+ apiHandler *mockapi.Handler
+ projectID string
+ mainEnv *mockapi.Environment
+ factory *cmdFactory
+}
+
+// setupVariableTest creates the common test infrastructure for variable tests.
+func setupVariableTest(t *testing.T) *variableTestSetup {
authServer := mockapi.NewAuthServer(t)
- defer authServer.Close()
+ t.Cleanup(authServer.Close)
apiHandler := mockapi.NewHandler(t)
apiServer := httptest.NewServer(apiHandler)
- defer apiServer.Close()
+ t.Cleanup(apiServer.Close)
projectID := mockapi.ProjectID()
apiHandler.SetProjects([]*mockapi.Project{{
ID: projectID,
- Links: mockapi.MakeHALLinks("self=/projects/"+projectID,
- "environments=/projects/"+projectID+"/environments"),
+ Links: mockapi.MakeHALLinks(
+ "self=/projects/"+projectID,
+ "environments=/projects/"+projectID+"/environments",
+ ),
DefaultBranch: "main",
}})
- main := makeEnv(projectID, "main", "production", "active", nil)
- main.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
- main.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
- envs := []*mockapi.Environment{main}
- apiHandler.SetEnvironments(envs)
- apiHandler.SetProjectVariables(projectID, []*mockapi.Variable{
+ mainEnv := makeEnv(projectID, "main", "production", "active", nil)
+ mainEnv.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
+ mainEnv.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
+
+ return &variableTestSetup{
+ authServer: authServer,
+ apiServer: apiServer,
+ apiHandler: apiHandler,
+ projectID: projectID,
+ mainEnv: mainEnv,
+ factory: newCommandFactory(t, apiServer.URL, authServer.URL),
+ }
+}
+
+func TestVariableCreate(t *testing.T) {
+ s := setupVariableTest(t)
+ s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
+ s.apiHandler.SetProjectVariables(s.projectID, []*mockapi.Variable{
{
Name: "existing",
IsSensitive: true,
@@ -39,36 +64,99 @@ func TestVariableCreate(t *testing.T) {
},
})
- f := newCommandFactory(t, apiServer.URL, authServer.URL)
+ f, p := s.factory, s.projectID
//nolint:lll
- _, stdErr, err := f.RunCombinedOutput("var:create", "-p", projectID, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
+ _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
assert.NoError(t, err)
assert.Contains(t, stdErr, "Creating variable env:TEST on the environment main")
- assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value"))
+ assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value"))
- //nolint:lll
- _, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value")
+ _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value")
assert.NoError(t, err)
- assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+projectID)
+ assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+p)
- //nolint:lll
- assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
- //nolint:lll
- assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))
+ assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
+ assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))
- _, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "existing", "-l", "p", "--value", "test")
+ _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "existing", "-l", "p", "--value", "test")
assert.Error(t, err)
assert.Contains(t, stdErr, "The variable already exists")
- //nolint:lll
- _, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value2")
+ _, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value2")
assert.NoError(t, err)
- assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "value"))
+ assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "value"))
- assertTrimmed(t, "true", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
- _, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--visible-runtime", "false")
+ assertTrimmed(t, "true", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
+ _, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--visible-runtime", "false")
assert.NoError(t, err)
- assertTrimmed(t, "false", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
+ assertTrimmed(t, "false", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
+}
+
+func TestVariableCreateWithAppScope(t *testing.T) {
+ s := setupVariableTest(t)
+
+ // Set up deployment with app names for validation.
+ s.mainEnv.SetCurrentDeployment(&mockapi.Deployment{
+ WebApps: map[string]mockapi.App{
+ "app1": {Name: "app1", Type: "golang:1.23"},
+ "app2": {Name: "app2", Type: "php:8.3"},
+ },
+ Routes: make(map[string]any),
+ Links: mockapi.MakeHALLinks("self=/projects/" + s.projectID + "/environments/main/deployment/current"),
+ })
+ s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
+
+ f, p := s.factory, s.projectID
+
+ // Test creating project-level variable with single app-scope.
+ _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
+ "env:SCOPED", "--value", "val", "--app-scope", "app1")
+ assert.NoError(t, err)
+ assert.Contains(t, stdErr, "Creating variable env:SCOPED")
+
+ // Verify application_scope was set.
+ out := f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
+ assert.Contains(t, out, "app1")
+
+ // Test creating variable with multiple app scopes.
+ _, _, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
+ "env:MULTI", "--value", "val", "--app-scope", "app1", "--app-scope", "app2")
+ assert.NoError(t, err)
+
+ out = f.Run("var:get", "-p", p, "-l", "p", "env:MULTI", "-P", "application_scope")
+ assert.Contains(t, out, "app1")
+ assert.Contains(t, out, "app2")
+
+ // Test validation rejects invalid app names (when deployment exists).
+ _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
+ "env:BAD", "--value", "val", "--app-scope", "nonexistent")
+ assert.Error(t, err)
+ assert.Contains(t, stdErr, "was not found")
+
+ // Test updating app-scope.
+ _, _, err = f.RunCombinedOutput("var:update", "-p", p, "-l", "p",
+ "env:SCOPED", "--app-scope", "app2")
+ assert.NoError(t, err)
+
+ out = f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
+ assert.Contains(t, out, "app2")
+}
+
+func TestVariableCreateWithAppScopeNoDeployment(t *testing.T) {
+ // Uses an environment without a deployment, so app-scope validation is skipped.
+ s := setupVariableTest(t)
+ s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
+
+ f, p := s.factory, s.projectID
+
+ // Without a deployment, any app-scope value should be accepted.
+ _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
+ "env:ANY_APP", "--value", "val", "--app-scope", "anyapp")
+ assert.NoError(t, err)
+ assert.Contains(t, stdErr, "Creating variable env:ANY_APP")
+
+ out := f.Run("var:get", "-p", p, "-l", "p", "env:ANY_APP", "-P", "application_scope")
+ assert.Contains(t, out, "anyapp")
}
diff --git a/legacy/CONTRIBUTING.md b/legacy/CONTRIBUTING.md
index 8984f4d2f..15c036039 100644
--- a/legacy/CONTRIBUTING.md
+++ b/legacy/CONTRIBUTING.md
@@ -1,12 +1,10 @@
# Contributing
-Development of the Platform.sh Legacy CLI happens in public in the
-[GitHub repository](https://github.com/platformsh/legacy-cli).
+Development of the Legacy CLI happens in public in the [GitHub repository](https://github.com/platformsh/legacy-cli).
Issues and pull requests submitted via GitHub are very welcome.
-In the near future - to be confirmed - this may move to being a subtree split
-of the new CLI repository at: https://github.com/platformsh/cli
+The principal Upsun CLI repository is: https://github.com/platformsh/cli
## Developing locally
@@ -30,8 +28,8 @@ Run tests with:
## Developing in a docker container
-If you don't have PHP installed locally or for other reasons want to do development on the
-Platform.sh CLI inside a docker container, follow this procedure:
+If you don't have PHP installed locally or for other reasons want to do development on the CLI inside a docker
+container, follow this procedure:
Create a `.env` file based on the default one
diff --git a/legacy/Makefile b/legacy/Makefile
index 474db6612..8b5498c81 100644
--- a/legacy/Makefile
+++ b/legacy/Makefile
@@ -1,5 +1,3 @@
-GO_TESTS_DIR=go-tests
-
.PHONY: composer-dev
composer-dev:
composer install --no-interaction
@@ -16,19 +14,9 @@ lint-phpstan: composer-dev
lint-php-cs-fixer: composer-dev
./vendor/bin/php-cs-fixer check --config .php-cs-fixer.dist.php --diff
-.PHONY: lint-gofmt
-lint-gofmt:
- cd $(GO_TESTS_DIR) && go fmt ./...
-
-.PHONY: lint-golangci
-lint-golangci:
- command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
- cd $(GO_TESTS_DIR) && golangci-lint run
-
.PHONY: lint
-lint: lint-gofmt lint-golangci lint-php-cs-fixer lint-phpstan
+lint: lint-php-cs-fixer lint-phpstan
.PHONY: test
test:
./vendor/bin/phpunit --exclude-group slow
- cd $(GO_TESTS_DIR) && go test -v -count=1 ./...
diff --git a/legacy/README.md b/legacy/README.md
index 840fcc6a7..72ccdbe6b 100644
--- a/legacy/README.md
+++ b/legacy/README.md
@@ -1,4 +1,6 @@
-The **Legacy** Platform.sh CLI is the legacy version of the command-line interface for [Platform.sh](https://platform.sh). For the **current Platform.sh CLI**, check [this repository](https://github.com/platformsh/cli).
+The **Legacy** CLI is the legacy version of the command-line interface for [Upsun (formerly Platform.sh)](https://upsun.com).
+
+For the **current Upsun CLI**, check [this repository](https://github.com/platformsh/cli).
## Install
@@ -53,7 +55,7 @@ scoop update platform
## Usage
-You can run the Platform.sh CLI in your shell by typing `platform`.
+You can run this CLI in your shell by typing `platform`.
platform
@@ -96,7 +98,7 @@ Other customization is available via environment variables, including:
* `PLATFORMSH_CLI_SSH_AUTO_LOAD_CERT`: set to 0 to disable automatic loading of an SSH certificate when running login or SSH commands
* `PLATFORMSH_CLI_REPORT_DEPRECATIONS`: set to 1 to enable PHP deprecation notices (suppressed by default). They will only be displayed in debug mode (`-vvv`).
* `CLICOLOR_FORCE`: set to 1 or 0 to force colorized output on or off, respectively
-* `http_proxy` or `https_proxy`: specify a proxy for connecting to Platform.sh
+* `http_proxy` or `https_proxy`: specify an HTTP proxy
## Known issues
diff --git a/legacy/config-defaults.yaml b/legacy/config-defaults.yaml
index 4f13925f5..256665a74 100644
--- a/legacy/config-defaults.yaml
+++ b/legacy/config-defaults.yaml
@@ -363,6 +363,7 @@ detection:
- Unable to find stack
- Cannot build application of type
- Invalid deployment
+ - Could not perform a rolling deployment
# Pagination settings.
#
@@ -425,9 +426,8 @@ browser_login:
# css: ''
body: |
diff --git a/legacy/config.yaml b/legacy/config.yaml
index fd13cd114..254c775a4 100644
--- a/legacy/config.yaml
+++ b/legacy/config.yaml
@@ -1,7 +1,11 @@
-# Platform.sh CLI configuration overrides.
+# Upsun CLI (Platform.sh compatibility) configuration overrides
# See config-defaults.yaml for the default values and a list of other required keys.
+#
+# Platform.sh is now Upsun.
+#
+# These are settings for the 'platform' command, which is available for backwards compatibility.
application:
- name: 'Platform.sh CLI'
+ name: 'Upsun CLI (Platform.sh compatibility)'
slug: 'platformsh-cli'
executable: 'platform'
env_prefix: 'PLATFORMSH_CLI_'
@@ -17,35 +21,31 @@ application:
- self:install
- self:update
-local:
- # A legacy project config file from versions < 3.
- project_config_legacy: 'platform-project.yaml'
-
service:
- name: 'Platform.sh'
+ name: 'Upsun (formerly Platform.sh)'
env_prefix: 'PLATFORM_'
project_config_dir: '.platform'
app_config_file: '.platform.app.yaml'
- console_url: 'https://console.platform.sh'
+ console_url: 'https://console.upsun.com'
- docs_url: 'https://docs.platform.sh'
- docs_search_url: 'https://docs.platform.sh/search.html?q={{ terms }}'
+ docs_url: 'https://docs.upsun.com'
+ docs_search_url: 'https://docs.upsun.com/search.html?q={{ terms }}'
- register_url: 'https://auth.api.platform.sh/register'
- reset_password_url: 'https://auth.api.platform.sh/reset-password'
+ register_url: 'https://auth.upsun.com/register'
+ reset_password_url: 'https://auth.upsun.com/reset-password'
- pricing_url: 'https://platform.sh/pricing'
+ pricing_url: 'https://upsun.com/pricing'
activity_type_list_url: 'https://docs.upsun.com/anchors/fixed/integrations/activity-scripts/type/'
runtime_operations_help_url: 'https://docs.upsun.com/anchors/fixed/app/runtime-operations/'
api:
- base_url: 'https://api.platform.sh'
+ base_url: 'https://api.upsun.com'
- auth_url: 'https://auth.api.platform.sh'
+ auth_url: 'https://auth.upsun.com'
oauth2_client_id: 'platform-cli'
organization_types: [flexible, fixed]
@@ -56,7 +56,7 @@ api:
metrics: true
teams: true
- vendor_filter: 'platformsh'
+ vendor_filter: 'upsun'
ssh:
domain_wildcards: ['*.platform.sh']
@@ -64,7 +64,7 @@ ssh:
detection:
git_remote_name: 'platform'
git_domain: 'platform.sh' # matches git.eu-5.platform.sh, etc.
- site_domains: ['platform.sh', 'platformsh.site', 'tst.site']
+ site_domains: ['platform.sh', 'platformsh.site', 'tst.site', 'upsunapp.com', 'upsun.app']
cluster_header: 'X-Platform-Cluster'
migrate:
diff --git a/legacy/dist/dev-build-index.php b/legacy/dist/dev-build-index.php
index 6f0056581..62e331bf9 100644
--- a/legacy/dist/dev-build-index.php
+++ b/legacy/dist/dev-build-index.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* @file
- * This is the index.php script for automated CLI builds on Platform.sh.
+ * This is the index.php script for automated CLI builds on Upsun.
*/
use Platformsh\Cli\Service\Config;
diff --git a/legacy/dist/manifest.json b/legacy/dist/manifest.json
index 8a9c4f62e..f0c6096d9 100644
--- a/legacy/dist/manifest.json
+++ b/legacy/dist/manifest.json
@@ -1,10 +1,10 @@
[
{
- "version": "4.27.0",
- "sha1": "62e5f46d69cf191324b2baa4a0ee9aa1487d564a",
- "sha256": "77b998915dc64a2141809dec08a7e9988045376e4bbcb99005512249813b9c61",
+ "version": "4.30.0",
+ "sha1": "3e442238f815ba68ce46fbdfd1454c3dd644fd26",
+ "sha256": "9903c7111a1b7b0c94a44a7e5a421c3abb58c3ff53fc39079e9b896d0e93cf0c",
"name": "platform.phar",
- "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.27.0/platform.phar",
+ "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.30.0/platform.phar",
"php": {
"min": "5.5.9"
},
@@ -77,7 +77,11 @@
"4.24.0": "New features:\n\n* Support guaranteed resources, adding a new CPU type concept (`shared` or\n `guaranteed`) to the `resources` commands.\n* Support manual deployments:\n - Add an `environment:deploy` command to deploy staged changes.\n - Add an `environment:deploy:type` command to view or change the deployment\n type (between `manual` and `automatic`).\n - Add a `deployment_type` property (read-only) to the `environment:info`\n command.\n - Support the `staged` activity state in activity-related commands.\n\nOther changes:\n\n* Increase the default limits for finding activities.\n* Stop bypassing the organization endpoint for subscriptions.\n* Update Go test dependencies.\n* Add `mark_unwrapped_legacy` config option (defaults to `false`).",
"4.25.0": "New features:\n\n* Autoscaling-related features:\n - Add an `autoscaling` command to read autoscaling settings.\n - Mark services with autoscaling in the `resources` command.\n - Disallow changing the instance count in `resources:set` when autoscaling is enabled.\n* Support the OpenTelemetry Protocol (`otlp`) integration, when available on the project.\n* Add a `--strategy` (`-s`) option to the `env:deploy` command (`stopstart` or `rolling`).\n* Require confirmation on `branch` or `env:activate` commands when guaranteed resources are configured.\n\nOther changes:\n\n* Update embedded docs links to the new permalink structure.\n* Avoid writing to stdout when opening a URL.\n* Treat `upsun` and `platformsh` vendors as equivalent in the project list from 2025-09-23.\n* Fix the command recommendation when there are staged activities.\n* Fix the Drush site URL when there is no app route (e.g. with Varnish).",
"4.26.0": "New features:\n\n* Add `autoscaling:set` command, to configure CPU-based autoscaling\n* Add support for organization types\n - Display the organization type in the `orgs` list\n - Add a `--type` filter in the `orgs` list\n - Add a `--type` option to `org:create`\n - Display the organization type in the `projects` list\n - Add an `--org-type` filter in the `projects` list\n* Add a `deploy` alias for the `env:deploy` command\n\nOther changes:\n\n* Fix \"This environment is inactive\" during activation, if the deployment cannot \n be fetched.",
- "4.27.0": "* Support `memory` as a trigger for autoscaling\n* Set `--fail-with-body` by default in `:curl` commands\n* Update name of `otlp` integration to `otlplog`"
+ "4.27.0": "* Support `memory` as a trigger for autoscaling\n* Set `--fail-with-body` by default in `:curl` commands\n* Update name of `otlp` integration to `otlplog`",
+ "4.28.0": "New features:\n\n* Display activities with non-zero exit codes as failed.\n* Allow specifying the deploy strategy on push.\n* Switch to new, more efficient endpoint for querying metrics. \n Output will be mostly the same as the previous `metrics` commands, with these\n exceptions:\n - Rows for the `router` service are now displayed by default.\n - The `---internal---storage` is no longer displayed by default.\n - `inodes` columns are now displayed by default in the `metrics` command. The\n first 5 table columns are the same, and the other commands' default columns\n are the same.\n - The `--interval` option is deprecated and no longer has an effect. The\n default interval displayed changes from 2 minutes to 1 minute.\n\nOther changes:\n\n* Error earlier in non-interactive mode when an organization ID is required.\n* Add an example for adding a column in activity:list command help.\n* Remove security contact from writable properties of billing profile.",
+ "4.28.2": "* Fix: the resources:build:get command should be hidden when sizing is disabled (#1583)",
+ "4.29.0": "* Allow configuring autoscaling for all services that support it.\n* Skip the \"type\" question when creating an organization.\n* Restore the metrics --interval option.\n* Return an error for a failed rolling deployment.",
+ "4.30.0": "* Add `--app-scope` option for variables.\n* Fix `PROCESS privilege(s)` error during `db:dump` of oracle-mysql databases.\n* Add `environment.alert` activity type."
}
},
{
diff --git a/legacy/src/Command/Activity/ActivityListCommand.php b/legacy/src/Command/Activity/ActivityListCommand.php
index dfaca47b1..cddc00067 100644
--- a/legacy/src/Command/Activity/ActivityListCommand.php
+++ b/legacy/src/Command/Activity/ActivityListCommand.php
@@ -94,7 +94,8 @@ protected function configure(): void
->addExample('List recent pushes', '--type push')
->addExample('List all recent activities excluding crons and redeploys', "--exclude-type '*.cron,*.backup*'")
->addExample('List pushes made before 15 March', '--type push --start 2015-03-15')
- ->addExample('List up to 25 incomplete activities', '--limit 25 -i');
+ ->addExample('List up to 25 incomplete activities', '--limit 25 -i')
+ ->addExample('Include the activity type in the table', '--columns +type');
}
protected function execute(InputInterface $input, OutputInterface $output): int
diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php
index dc8e0102d..9ab7ebac6 100644
--- a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php
+++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php
@@ -6,10 +6,12 @@
use Platformsh\Cli\Service\Api;
use Platformsh\Cli\Service\Config;
use Platformsh\Cli\Service\PropertyFormatter;
+use Platformsh\Cli\Service\ResourcesUtil;
use Platformsh\Cli\Selector\Selector;
use Platformsh\Cli\Command\CommandBase;
use Platformsh\Cli\Service\Table;
use Platformsh\Client\Exception\EnvironmentStateException;
+use Platformsh\Client\Model\Deployment\Service;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -34,7 +36,7 @@ class AutoscalingSettingsGetCommand extends CommandBase
/** @var string[] */
protected array $defaultColumns = ['service', 'metric', 'direction', 'threshold', 'duration', 'enabled', 'instance_count'];
- public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
+ public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly ResourcesUtil $resourcesUtil, private readonly Selector $selector, private readonly Table $table)
{
parent::__construct();
}
@@ -73,13 +75,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$autoscalingSettings = $autoscalingSettings->getData();
- $services = array_merge($deployment->webapps, $deployment->workers);
+ $services = $this->resourcesUtil->allServices($deployment);
if (empty($services)) {
- $this->stdErr->writeln('No apps or workers found.');
+ $this->stdErr->writeln('No apps, workers, or services found.');
return 1;
}
if (!empty($autoscalingSettings['services'])) {
+ // Filter autoscaling settings to only show services that are allowed to be configured.
+ $filteredSettings = $this->filterAutoscalingSettings($autoscalingSettings['services'], $services, $selection->getProject());
+
+ if (empty($filteredSettings)) {
+ $this->stdErr->writeln(sprintf('No autoscaling configuration found for the project %s, environment %s.', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment)));
+ $isOriginalCommand = $input instanceof ArgvInput;
+ if ($isOriginalCommand) {
+ $this->stdErr->writeln('');
+ $this->stdErr->writeln(sprintf('You can configure autoscaling by running: %s autoscaling:set', $this->config->getStr('application.executable')));
+ }
+ return 0;
+ }
+
if (!$this->table->formatIsMachineReadable()) {
$this->stdErr->writeln(sprintf('Autoscaling configuration for the project %s, environment %s:', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment)));
}
@@ -87,7 +102,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$empty = $this->table->formatIsMachineReadable() ? '' : 'not set';
$rows = [];
- foreach ($autoscalingSettings['services'] as $service => $settings) {
+ foreach ($filteredSettings as $service => $settings) {
$row = [
'service' => $service,
'metric' => $empty,
@@ -137,4 +152,53 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 0;
}
+
+ /**
+ * Filters autoscaling settings to only include services that are allowed to be configured.
+ *
+ * @param array> $autoscalingSettings Autoscaling settings from the API
+ * @param array $services Array of Service|WebApp|Worker objects from deployment
+ * @param \Platformsh\Client\Model\Project $project
+ *
+ * @return array> Filtered autoscaling settings
+ */
+ protected function filterAutoscalingSettings(array $autoscalingSettings, array $services, \Platformsh\Client\Model\Project $project): array
+ {
+ // Force refresh to ensure we have the latest capabilities.
+ $capabilities = $this->api->getProjectCapabilities($project, true);
+ $servicesCapabilityEnabled = !empty($capabilities->autoscaling['supports_horizontal_scaling_services']);
+
+ $filtered = [];
+ foreach ($autoscalingSettings as $serviceName => $settings) {
+ // Skip if service doesn't exist in deployment
+ if (!isset($services[$serviceName])) {
+ continue;
+ }
+
+ $service = $services[$serviceName];
+ $properties = $service->getProperties();
+
+ // For apps and workers: check supports_horizontal_scaling if present, otherwise allow
+ if (!($service instanceof Service)) {
+ if (isset($properties['supports_horizontal_scaling']) && !$properties['supports_horizontal_scaling']) {
+ continue;
+ }
+ $filtered[$serviceName] = $settings;
+ continue;
+ }
+
+ // For services: check both the deployment property and the project capability
+ if (!isset($properties['supports_horizontal_scaling']) || !$properties['supports_horizontal_scaling']) {
+ continue;
+ }
+
+ if (!$servicesCapabilityEnabled) {
+ continue;
+ }
+
+ $filtered[$serviceName] = $settings;
+ }
+
+ return $filtered;
+ }
}
diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php
index 171162769..729528fa8 100644
--- a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php
+++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php
@@ -30,7 +30,7 @@ public function __construct(private readonly Api $api, private readonly Config $
protected function configure(): void
{
- $this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Name of the app or worker to configure autoscaling for')
+ $this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Name of the app, worker, or service to configure autoscaling for')
->addOption('metric', 'm', InputOption::VALUE_REQUIRED, 'Name of the metric to use for triggering autoscaling')
->addOption('enabled', null, InputOption::VALUE_REQUIRED, 'Enable autoscaling based on the given metric')
->addOption('threshold-up', null, InputOption::VALUE_REQUIRED, 'Threshold over which service will be scaled up')
@@ -47,7 +47,7 @@ protected function configure(): void
$this->selector->addEnvironmentOption($this->getDefinition());
$helpLines = [
- 'Configure automatic scaling for apps or workers in an environment.',
+ 'Configure automatic scaling for apps, workers, or services in an environment.',
'',
sprintf('You can also configure resources statically by running: %s resources:set', $this->config->getStr('application.executable')),
];
@@ -93,9 +93,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$autoscalingSettings = $autoscalingSettings->getData();
- $services = array_merge($deployment->webapps, $deployment->workers);
+ $services = $this->resourcesUtil->allServices($deployment);
if (empty($services)) {
- $this->stdErr->writeln('No apps or workers found.');
+ $this->stdErr->writeln('No apps, workers, or services found.');
return 1;
}
@@ -106,6 +106,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$service = $input->getOption('service');
if ($service !== null) {
$service = $this->validateService($service, $services);
+ $this->validateServiceSupportsAutoscaling($service, $services[$service], $selection->getProject());
}
$supportedMetrics = $this->getSupportedMetrics($defaults);
@@ -188,12 +189,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if ($showInteractiveForm) {
// Interactive mode: let user select services and configure them
- $serviceNames = array_keys($services);
+ // Filter to only show services that support autoscaling
+ $supportedServices = $this->filterServicesWithAutoscalingSupport($services, $selection->getProject());
+
+ if (empty($supportedServices)) {
+ $this->stdErr->writeln('No services that support autoscaling were found.');
+ return 1;
+ }
+
+ $serviceNames = array_keys($supportedServices);
if ($service === null) {
- // Ask user to select services to configure
+ // Ask user to select services to configure.
$default = $serviceNames[0];
- $text = 'Enter a number to choose an app or worker:' . "\n" . 'Default: ' . $default . '';
+ $text = 'Enter a number to choose an app, worker, or service:' . "\n" . 'Default: ' . $default . '';
$serviceNamesIndexed = array_combine($serviceNames, $serviceNames);
$selectedService = $this->questionHelper->choose($serviceNamesIndexed, $text, $default);
$service = $selectedService;
@@ -677,6 +686,83 @@ protected function validateService(string $value, array $services): string
throw new InvalidArgumentException(sprintf('Invalid service name %s. Available services: %s', $value, implode(', ', $serviceNames)));
}
+ /**
+ * Validates that a service supports autoscaling.
+ *
+ * @param string $serviceName
+ * @param Service|WebApp|Worker $service
+ * @param \Platformsh\Client\Model\Project $project
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function validateServiceSupportsAutoscaling(string $serviceName, Service|WebApp|Worker $service, \Platformsh\Client\Model\Project $project): void
+ {
+ $properties = $service->getProperties();
+
+ // For apps and workers: check supports_horizontal_scaling if present, otherwise allow.
+ if (!($service instanceof Service)) {
+ if (isset($properties['supports_horizontal_scaling']) && !$properties['supports_horizontal_scaling']) {
+ throw new InvalidArgumentException(sprintf(
+ 'The %s %s does not support autoscaling.',
+ $this->typeName($service),
+ $serviceName
+ ));
+ }
+ return;
+ }
+
+ // For services: check both the deployment property and the project capability.
+ if (!isset($properties['supports_horizontal_scaling']) || !$properties['supports_horizontal_scaling']) {
+ throw new InvalidArgumentException(sprintf(
+ 'The service %s does not support autoscaling.',
+ $serviceName
+ ));
+ }
+
+ // Force refresh to ensure we have the latest capabilities.
+ $capabilities = $this->api->getProjectCapabilities($project, true);
+ if (empty($capabilities->autoscaling['supports_horizontal_scaling_services'])) {
+ throw new InvalidArgumentException(sprintf(
+ 'The service %s does not support autoscaling because the project does not have horizontal scaling enabled for services.',
+ $serviceName
+ ));
+ }
+ }
+
+ /**
+ * Filters services to only those that support autoscaling.
+ *
+ * @param array $services
+ * @param \Platformsh\Client\Model\Project $project
+ *
+ * @return array
+ */
+ protected function filterServicesWithAutoscalingSupport(array $services, \Platformsh\Client\Model\Project $project): array
+ {
+ // Force refresh to ensure we have the latest capabilities.
+ $capabilities = $this->api->getProjectCapabilities($project, true);
+ $servicesCapabilityEnabled = !empty($capabilities->autoscaling['supports_horizontal_scaling_services']);
+
+ return array_filter($services, function ($service) use ($servicesCapabilityEnabled) {
+ $properties = $service->getProperties();
+
+ // For apps and workers: check supports_horizontal_scaling if present, otherwise allow
+ if (!($service instanceof Service)) {
+ if (isset($properties['supports_horizontal_scaling'])) {
+ return $properties['supports_horizontal_scaling'];
+ }
+ return true;
+ }
+
+ // For services: check both the deployment property and the project capability
+ if (!isset($properties['supports_horizontal_scaling']) || !$properties['supports_horizontal_scaling']) {
+ return false;
+ }
+
+ return $servicesCapabilityEnabled;
+ });
+ }
+
/**
* Return the names of supported metrics.
*
diff --git a/legacy/src/Command/Db/DbDumpCommand.php b/legacy/src/Command/Db/DbDumpCommand.php
index 053b1c681..c7aa57207 100644
--- a/legacy/src/Command/Db/DbDumpCommand.php
+++ b/legacy/src/Command/Db/DbDumpCommand.php
@@ -224,6 +224,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if ($schemaOnly) {
$dumpCommand .= ' --no-data';
}
+ if (!$this->relationships->isOracleDB($database)) {
+ // if ORACLE, don't dump tablespaces
+ // PROCESS privilege needed and we don't have that
+ // https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html#option_mysqldump_no-tablespaces
+ $dumpCommand .= ' --no-tablespaces';
+ }
foreach ($excludedTables as $table) {
$dumpCommand .= ' ' . OsUtil::escapePosixShellArg(sprintf('--ignore-table=%s.%s', $database['path'], $table));
}
diff --git a/legacy/src/Command/Environment/EnvironmentPushCommand.php b/legacy/src/Command/Environment/EnvironmentPushCommand.php
index bac9247a3..d1eb14000 100644
--- a/legacy/src/Command/Environment/EnvironmentPushCommand.php
+++ b/legacy/src/Command/Environment/EnvironmentPushCommand.php
@@ -66,7 +66,8 @@ protected function configure(): void
->addHiddenOption('branch', null, InputOption::VALUE_NONE, 'DEPRECATED: alias of --activate')
->addOption('parent', null, InputOption::VALUE_REQUIRED, 'Set the environment parent (only used with --activate)')
->addOption('type', null, InputOption::VALUE_REQUIRED, 'Set the environment type (only used with --activate )')
- ->addOption('no-clone-parent', null, InputOption::VALUE_NONE, "Do not clone the parent branch's data (only used with --activate)");
+ ->addOption('no-clone-parent', null, InputOption::VALUE_NONE, "Do not clone the parent branch's data (only used with --activate)")
+ ->addOption('deploy-strategy', 's', InputOption::VALUE_REQUIRED, 'Set the deployment strategy, rolling or stopstart (default)');
$this->resourcesUtil->addOption(
$this->getDefinition(),
$this->validResourcesInitOptions,
@@ -172,6 +173,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$parentId = $input->getOption('parent');
$type = $input->getOption('type');
+ $strategy = $input->getOption('deploy-strategy');
+ if ($strategy !== null && $strategy !== 'rolling' && $strategy !== 'stopstart') {
+ $this->stdErr->writeln(sprintf('Invalid deploy strategy %s, should be "rolling" or "stopstart"', $strategy));
+ return 1;
+ }
+
// Check if the environment may be a production one.
$mayBeProduction = $type === 'production'
|| ($targetEnvironment && $targetEnvironment->type === 'production')
@@ -207,6 +214,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}
+ if ($targetEnvironment) {
+ if (!$targetEnvironment->operationAvailable('deploy', true)) {
+ $this->stdErr->writeln(sprintf('Deployment strategy: %s', $strategy ?: "stopstart"));
+ } else {
+ $this->stdErr->writeln('The activity will be staged, ignoring the deployment strategy.');
+ }
+ }
+
$this->stdErr->writeln('');
if (!$this->questionHelper->confirm('Are you sure you want to continue?')) {
@@ -267,6 +282,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if ($resourcesInit !== null) {
$gitArgs[] = '--push-option=resources.init=' . $resourcesInit;
}
+ if ($strategy !== null) {
+ $gitArgs[] = '--push-option=deploy.strategy=' . $strategy;
+ }
// Build the SSH command to use with Git.
$extraSshOptions = [];
diff --git a/legacy/src/Command/Metrics/MetricsCommandBase.php b/legacy/src/Command/Metrics/MetricsCommandBase.php
index ef8e92f7b..28074594c 100644
--- a/legacy/src/Command/Metrics/MetricsCommandBase.php
+++ b/legacy/src/Command/Metrics/MetricsCommandBase.php
@@ -255,6 +255,7 @@ protected function getChooseEnvFilter(): ?callable
return null;
}
+
/**
* Validates the interval and range input, and finds defaults.
*
diff --git a/legacy/src/Command/Organization/Billing/OrganizationProfileCommand.php b/legacy/src/Command/Organization/Billing/OrganizationProfileCommand.php
index 452619da3..86f31e46a 100644
--- a/legacy/src/Command/Organization/Billing/OrganizationProfileCommand.php
+++ b/legacy/src/Command/Organization/Billing/OrganizationProfileCommand.php
@@ -122,7 +122,6 @@ private function getType(string $property): string|false
$writableProperties = [
'company_name' => 'string',
'billing_contact' => 'string',
- 'security_contact' => 'string',
'vat_number' => 'string',
'default_catalog' => 'string',
'project_options_url' => 'string',
diff --git a/legacy/src/Command/Organization/OrganizationCreateCommand.php b/legacy/src/Command/Organization/OrganizationCreateCommand.php
index be019793e..219a29ad1 100644
--- a/legacy/src/Command/Organization/OrganizationCreateCommand.php
+++ b/legacy/src/Command/Organization/OrganizationCreateCommand.php
@@ -54,6 +54,7 @@ private function getForm(): Form
$fields['type'] = new OptionsField('Type', [
'description' => 'The organization type.',
'options' => $options,
+ 'avoidQuestion' => true,
'default' => $this->config->getWithDefault('api.default_organization_type', key($options)),
]);
}
diff --git a/legacy/src/Service/ActivityLoader.php b/legacy/src/Service/ActivityLoader.php
index ee49be51c..07b925ed4 100644
--- a/legacy/src/Service/ActivityLoader.php
+++ b/legacy/src/Service/ActivityLoader.php
@@ -164,6 +164,7 @@ public static function getAvailableTypes(): array
'environment.access.add',
'environment.access.remove',
'environment.activate',
+ 'environment.alert',
'environment.backup',
'environment.backup.delete',
'environment.branch',
diff --git a/legacy/src/Service/ActivityMonitor.php b/legacy/src/Service/ActivityMonitor.php
index faacdf534..f983fb055 100644
--- a/legacy/src/Service/ActivityMonitor.php
+++ b/legacy/src/Service/ActivityMonitor.php
@@ -593,7 +593,7 @@ public static function formatResult(Activity $activity, bool $decorate = true):
foreach ($activity->commands ?? [] as $command) {
if ($command['exit_code'] > 0) {
- $name = Activity::RESULT_FAILURE;
+ $name = self::RESULT_NAMES[Activity::RESULT_FAILURE];
$result = Activity::RESULT_FAILURE;
break;
}
diff --git a/legacy/src/Service/CurlCli.php b/legacy/src/Service/CurlCli.php
index 5552e14ef..7e3efc13f 100644
--- a/legacy/src/Service/CurlCli.php
+++ b/legacy/src/Service/CurlCli.php
@@ -77,7 +77,7 @@ public function run(string $baseUrl, InputInterface $input, OutputInterface $out
}
if ($type === Process::OUT) {
if ($retryOn401) {
- // Buffer stdout when we might need to retry on 401.
+ // Buffer stdout so it can be discarded if a 401 triggers a retry.
$stdoutBuffer .= $buffer;
} else {
$output->write($buffer);
@@ -87,7 +87,7 @@ public function run(string $baseUrl, InputInterface $input, OutputInterface $out
if ($type === Process::ERR) {
if ($retryOn401 && $this->parseCurlStatusCode($buffer) === 401 && $this->api->isLoggedIn()) {
$shouldRetry = true;
- $stdoutBuffer = ''; // Discard buffered stdout from the 401 response.
+ $stdoutBuffer = '';
$process->clearErrorOutput();
$process->clearOutput();
@@ -107,6 +107,11 @@ public function run(string $baseUrl, InputInterface $input, OutputInterface $out
$process->run($onOutput);
+ if (!$shouldRetry && $stdoutBuffer !== '') {
+ $output->write($stdoutBuffer);
+ $stdoutBuffer = '';
+ }
+
if ($shouldRetry) {
// Create a new curl process, replacing the access token.
$commandline = $this->buildCurlCommand($url, $newToken, $input);
@@ -119,6 +124,11 @@ public function run(string $baseUrl, InputInterface $input, OutputInterface $out
$stdErr->writeln(sprintf('Running command: %s', $censor($commandline)), OutputInterface::VERBOSITY_VERBOSE);
$process->run($onOutput);
+
+ if ($stdoutBuffer !== '') { // @phpstan-ignore notIdentical.alwaysFalse ($stdoutBuffer is modified by reference in $onOutput)
+ $output->write($stdoutBuffer);
+ $stdoutBuffer = '';
+ }
}
// Flush buffered stdout after the final request.
diff --git a/legacy/src/Service/Relationships.php b/legacy/src/Service/Relationships.php
index 6f82a458a..8c30b1a87 100644
--- a/legacy/src/Service/Relationships.php
+++ b/legacy/src/Service/Relationships.php
@@ -231,6 +231,17 @@ public function isMariaDB(array $database): bool
return isset($database['type']) && (str_starts_with((string) $database['type'], 'mariadb:') || str_starts_with((string) $database['type'], 'mysql:'));
}
+ /**
+ * Returns whether the database is OracleDB.
+ *
+ * @param array $database The database definition from the relationships.
+ * @return bool
+ */
+ public function isOracleDB(array $database): bool
+ {
+ return isset($database['type']) && str_starts_with((string) $database['type'], 'oracle-mysql:');
+ }
+
/**
* Returns the correct command to use with a MariaDB client.
*
diff --git a/legacy/src/Service/VariableCommandUtil.php b/legacy/src/Service/VariableCommandUtil.php
index a86da8123..3f48c4ca3 100644
--- a/legacy/src/Service/VariableCommandUtil.php
+++ b/legacy/src/Service/VariableCommandUtil.php
@@ -6,9 +6,13 @@
use Platformsh\Cli\Console\AdaptiveTableCell;
use Platformsh\Cli\Selector\Selection;
+use Platformsh\Client\Exception\EnvironmentStateException;
use Platformsh\Client\Model\ApiResourceBase;
+use Platformsh\Client\Model\Environment;
+use Platformsh\Client\Model\Project;
use Platformsh\Client\Model\ProjectLevelVariable;
use Platformsh\Client\Model\Variable as EnvironmentLevelVariable;
+use Platformsh\ConsoleForm\Field\ArrayField;
use Platformsh\ConsoleForm\Field\BooleanField;
use Platformsh\ConsoleForm\Field\Field;
use Platformsh\ConsoleForm\Field\OptionsField;
@@ -170,6 +174,32 @@ public function getFields(callable $getSelection): array
'includeAsOption' => false,
'defaultCallback' => fn(): ?string => $getSelection()->hasEnvironment() ? $getSelection()->getEnvironment()->id : null,
]);
+ $fields['application_scope'] = new ArrayField('Application scope', [
+ 'optionName' => 'app-scope',
+ 'description' => 'A list of application names to which this variable will apply.',
+ 'questionLine' => 'To which applications should this variable apply?',
+ 'default' => [],
+ 'required' => false,
+ 'avoidQuestion' => true,
+ 'validator' => function ($values) use ($getSelection) {
+ $selection = $getSelection();
+ $appNames = $this->listApps($selection->getProject(), $selection->hasEnvironment() ? $selection->getEnvironment() : null);
+ if ($appNames === false) {
+ // No app names available: skip validation.
+ return true;
+ }
+ foreach ($values as $value) {
+ if (!in_array($value, $appNames, true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'The app "%s" was not found. Valid app names are: %s',
+ $value,
+ implode(', ', $appNames)
+ ));
+ }
+ }
+ return true;
+ },
+ ]);
$fields['name'] = new Field('Name', [
'description' => 'The variable name',
'validators' => [
@@ -256,4 +286,30 @@ private function getPrefixOptions(string $name): array
'env:' => 'env: The variable will be exposed directly, e.g. as $' . strtoupper($name) . '.',
];
}
+
+ /**
+ * List application names for validating application_scope values.
+ *
+ * @param Project $project
+ * @param Environment|null $environment If not provided, the project's default environment will be used.
+ *
+ * @return string[]|false
+ */
+ private function listApps(Project $project, ?Environment $environment = null): array|false
+ {
+ if (!$environment) {
+ if ($project->default_branch !== '') {
+ $environment = $this->api->getEnvironment($project->default_branch, $project);
+ }
+ if (!$environment) {
+ return false;
+ }
+ }
+ try {
+ $deployment = $this->api->getCurrentDeployment($environment, false);
+ } catch (EnvironmentStateException $e) {
+ return false;
+ }
+ return array_keys($deployment->webapps);
+ }
}
diff --git a/legacy/tests/Local/LocalProjectTest.php b/legacy/tests/Local/LocalProjectTest.php
deleted file mode 100644
index 83a85f651..000000000
--- a/legacy/tests/Local/LocalProjectTest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-tempDirSetUp();
- $testDir = $this->tempDir;
- mkdir("$testDir/1/2/3/4/5", 0o755, true);
-
- $expectedRoot = "$testDir/1";
- $config = new Config();
- $this->assertTrue($config->has('local.project_config_legacy'));
- touch("$expectedRoot/" . $config->getStr('local.project_config_legacy'));
-
- chdir($testDir);
- $localProject = new LocalProject();
- $this->assertFalse($localProject->getProjectRoot());
- $this->assertFalse($localProject->getLegacyProjectRoot());
-
- chdir($expectedRoot);
- $this->assertFalse($localProject->getProjectRoot());
- $this->assertEquals($expectedRoot, $localProject->getLegacyProjectRoot());
-
- chdir("$testDir/1/2/3/4/5");
- $this->assertFalse($localProject->getProjectRoot());
- $this->assertEquals($expectedRoot, $localProject->getLegacyProjectRoot());
- }
-}
diff --git a/pkg/mockapi/model.go b/pkg/mockapi/model.go
index 1f1032436..766257e36 100644
--- a/pkg/mockapi/model.go
+++ b/pkg/mockapi/model.go
@@ -293,11 +293,12 @@ type Activity struct {
}
type Variable struct {
- Name string `json:"name"`
- Value string `json:"value,omitempty"`
- IsSensitive bool `json:"is_sensitive"`
- VisibleBuild bool `json:"visible_build"`
- VisibleRuntime bool `json:"visible_runtime"`
+ Name string `json:"name"`
+ Value string `json:"value,omitempty"`
+ IsSensitive bool `json:"is_sensitive"`
+ VisibleBuild bool `json:"visible_build"`
+ VisibleRuntime bool `json:"visible_runtime"`
+ ApplicationScope []string `json:"application_scope,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`